feat: polish life os cards and voice stack

This commit is contained in:
kacper 2026-03-24 08:54:47 -04:00
parent 66362c7176
commit 0edf8c3fef
21 changed files with 3681 additions and 502 deletions

View file

@ -0,0 +1,17 @@
{
"id": "live-calorie-tracker",
"kind": "text",
"title": "Calories",
"question": "",
"choices": [],
"response_value": "",
"slot": "live-calorie-tracker",
"lane": "context",
"priority": 76,
"state": "active",
"template_key": "list-total-live",
"context_summary": "",
"chat_id": "web",
"created_at": "2026-03-21T00:00:00+00:00",
"updated_at": "2026-03-21T00:00:00+00:00"
}

View file

@ -0,0 +1,9 @@
{
"left_label": "Cal",
"right_label": "Food",
"total_label": "Total",
"total_suffix": "cal",
"max_digits": 4,
"score": 76,
"rows": []
}

View file

@ -1,10 +1,12 @@
{
"title": "Weather 01545",
"subtitle": "OpenWeatherMap live context",
"subtitle": "Weather",
"tool_name": "mcp_home_assistant_GetLiveContext",
"forecast_tool_name": "exec",
"forecast_command": "python3 /home/kacper/nanobot/scripts/card_upcoming_conditions.py --nws-entity weather.korh --uv-entity weather.openweathermap_2 --forecast-type hourly --limit 4",
"provider_prefix": "OpenWeatherMap",
"temperature_name": "OpenWeatherMap Temperature",
"humidity_name": "OpenWeatherMap Humidity",
"condition_label": "OpenWeatherMap live context",
"condition_label": "Weather",
"refresh_ms": 86400000
}

View file

@ -0,0 +1,24 @@
{
"key": "list-total-live",
"title": "List Total",
"notes": "Generic editable two-column list card with a numeric left column, freeform right column, and a running total persisted in template_state. Configure left_label, right_label, total_label, total_suffix, max_digits, and rows.",
"example_state": {
"left_label": "Cal",
"right_label": "Food",
"total_label": "Total",
"total_suffix": "cal",
"max_digits": 4,
"rows": [
{
"value": "420",
"name": "Lunch"
},
{
"value": "180",
"name": "Snack"
}
]
},
"created_at": "2026-03-21T00:00:00+00:00",
"updated_at": "2026-03-21T00:00:00+00:00"
}

View file

@ -0,0 +1,336 @@
<style>
.list-total-card {
display: grid;
gap: 10px;
color: #4d392d;
}
.list-total-card__labels,
.list-total-card__row,
.list-total-card__total {
display: grid;
grid-template-columns: 68px minmax(0, 1fr);
gap: 8px;
align-items: center;
}
.list-total-card__labels {
color: rgba(77, 57, 45, 0.72);
font: 700 0.62rem/1 'M-1m Code', var(--card-font, 'SF Mono', ui-monospace, Menlo, Consolas, monospace);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.list-total-card__rows {
display: grid;
gap: 6px;
}
.list-total-card__input {
width: 100%;
min-width: 0;
box-sizing: border-box;
border: 0;
border-bottom: 1px solid rgba(92, 70, 55, 0.14);
border-radius: 0;
background: transparent;
color: #473429;
padding: 4px 0;
outline: none;
box-shadow: none;
}
.list-total-card__input::placeholder {
color: rgba(77, 57, 45, 0.42);
}
.list-total-card__value {
font: 700 0.84rem/1 'M-1m Code', var(--card-font, 'SF Mono', ui-monospace, Menlo, Consolas, monospace);
text-align: right;
}
.list-total-card__name {
font-family: 'IBM Plex Sans Condensed', 'Arial Narrow', sans-serif;
font-size: 0.92rem;
line-height: 1.08;
font-weight: 600;
letter-spacing: -0.008em;
}
.list-total-card__status {
min-height: 0.9rem;
color: #8e3023;
font: 700 0.62rem/1 'M-1m Code', var(--card-font, 'SF Mono', ui-monospace, Menlo, Consolas, monospace);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.list-total-card__status[data-kind='ok'] {
color: rgba(77, 57, 45, 0.5);
}
.list-total-card__total {
padding-top: 8px;
border-top: 1px solid rgba(92, 70, 55, 0.18);
color: #35271f;
}
.list-total-card__total-label {
font: 700 0.66rem/1 'M-1m Code', var(--card-font, 'SF Mono', ui-monospace, Menlo, Consolas, monospace);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.list-total-card__total-value {
font: 700 0.98rem/1 'M-1m Code', var(--card-font, 'SF Mono', ui-monospace, Menlo, Consolas, monospace);
text-align: right;
}
</style>
<div class="list-total-card" data-list-total-card>
<div class="list-total-card__labels">
<div data-list-total-left-label>Value</div>
<div data-list-total-right-label>Item</div>
</div>
<div class="list-total-card__rows" data-list-total-rows></div>
<div class="list-total-card__status" data-list-total-status></div>
<div class="list-total-card__total">
<div class="list-total-card__total-label" data-list-total-total-label>Total</div>
<div class="list-total-card__total-value" data-list-total-total>0</div>
</div>
</div>
<script>
(() => {
const script = document.currentScript;
const root = script?.closest('[data-nanobot-card-root]');
const state = window.__nanobotGetCardState?.(script) || {};
if (!(root instanceof HTMLElement)) return;
const cardId = String(root.dataset.cardId || '').trim();
const rowsEl = root.querySelector('[data-list-total-rows]');
const statusEl = root.querySelector('[data-list-total-status]');
const totalEl = root.querySelector('[data-list-total-total]');
const totalLabelEl = root.querySelector('[data-list-total-total-label]');
const leftLabelEl = root.querySelector('[data-list-total-left-label]');
const rightLabelEl = root.querySelector('[data-list-total-right-label]');
if (
!(rowsEl instanceof HTMLElement) ||
!(statusEl instanceof HTMLElement) ||
!(totalEl instanceof HTMLElement) ||
!(totalLabelEl instanceof HTMLElement) ||
!(leftLabelEl instanceof HTMLElement) ||
!(rightLabelEl instanceof HTMLElement)
) {
return;
}
const maxDigits = Math.max(
1,
Math.min(4, Number.isFinite(Number(state.max_digits)) ? Number(state.max_digits) : 4),
);
const totalSuffix = String(state.total_suffix || '').trim();
const leftLabel = String(state.left_label || 'Value').trim() || 'Value';
const rightLabel = String(state.right_label || 'Item').trim() || 'Item';
const totalLabel = String(state.total_label || 'Total').trim() || 'Total';
leftLabelEl.textContent = leftLabel;
rightLabelEl.textContent = rightLabel;
totalLabelEl.textContent = totalLabel;
function sanitizeValue(raw) {
return String(raw || '').replace(/\D+/g, '').slice(0, maxDigits);
}
function sanitizeName(raw) {
return String(raw || '').replace(/\s+/g, ' ').trimStart();
}
function normalizeRows(raw) {
if (!Array.isArray(raw)) return [];
return raw
.filter((row) => row && typeof row === 'object' && !Array.isArray(row))
.map((row) => ({
value: sanitizeValue(row.value),
name: sanitizeName(row.name),
}));
}
function isBlankRow(row) {
return !row || (!String(row.value || '').trim() && !String(row.name || '').trim());
}
function ensureTrailingBlankRow(items) {
const next = items.map((row) => ({
value: sanitizeValue(row.value),
name: sanitizeName(row.name),
}));
if (!next.length || !isBlankRow(next[next.length - 1])) {
next.push({ value: '', name: '' });
}
return next;
}
function persistedRows() {
return rows
.filter((row) => !isBlankRow(row))
.map((row) => ({
value: sanitizeValue(row.value),
name: sanitizeName(row.name),
}));
}
function computeTotal() {
return persistedRows().reduce((sum, row) => sum + (Number.parseInt(row.value, 10) || 0), 0);
}
function updateTotal() {
const total = computeTotal();
totalEl.textContent = `${total.toLocaleString()}${totalSuffix ? totalSuffix : ''}`;
window.__nanobotSetCardLiveContent?.(script, {
kind: 'list_total',
item_count: persistedRows().length,
total,
total_suffix: totalSuffix || null,
score: persistedRows().length ? 24 : 16,
});
}
function setStatus(text, kind) {
statusEl.textContent = text || '';
statusEl.dataset.kind = kind || '';
}
let rows = ensureTrailingBlankRow(normalizeRows(state.rows));
let saveTimer = null;
let inFlightSave = null;
async function persistState() {
if (!cardId) return;
const nextState = {
...state,
left_label: leftLabel,
right_label: rightLabel,
total_label: totalLabel,
total_suffix: totalSuffix,
max_digits: maxDigits,
rows: persistedRows(),
};
try {
setStatus('Saving', 'ok');
inFlightSave = fetch(`/cards/${encodeURIComponent(cardId)}/state`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ template_state: nextState }),
});
const response = await inFlightSave;
if (!response.ok) {
let message = `save failed (${response.status})`;
try {
const payload = await response.json();
if (payload && typeof payload.error === 'string' && payload.error) {
message = payload.error;
}
} catch (_) {}
throw new Error(message);
}
setStatus('', '');
} catch (error) {
setStatus(error instanceof Error ? error.message : 'save failed', 'error');
} finally {
inFlightSave = null;
}
}
function schedulePersist() {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = window.setTimeout(() => {
void persistState();
}, 280);
}
function pruneRows() {
rows = ensureTrailingBlankRow(
rows.filter((row, index) => !isBlankRow(row) || index === rows.length - 1),
);
}
function renderRows() {
rowsEl.innerHTML = '';
rows.forEach((row, index) => {
const rowEl = document.createElement('div');
rowEl.className = 'list-total-card__row';
const valueInput = document.createElement('input');
valueInput.className = 'list-total-card__input list-total-card__value';
valueInput.type = 'text';
valueInput.inputMode = 'numeric';
valueInput.maxLength = maxDigits;
valueInput.placeholder = '0';
valueInput.value = row.value;
const nameInput = document.createElement('input');
nameInput.className = 'list-total-card__input list-total-card__name';
nameInput.type = 'text';
nameInput.placeholder = 'Item';
nameInput.value = row.name;
valueInput.addEventListener('input', () => {
rows[index].value = sanitizeValue(valueInput.value);
valueInput.value = rows[index].value;
if (index === rows.length - 1 && !isBlankRow(rows[index])) {
rows = ensureTrailingBlankRow(rows);
renderRows();
schedulePersist();
return;
}
updateTotal();
schedulePersist();
});
nameInput.addEventListener('input', () => {
rows[index].name = sanitizeName(nameInput.value);
if (index === rows.length - 1 && !isBlankRow(rows[index])) {
rows = ensureTrailingBlankRow(rows);
renderRows();
schedulePersist();
return;
}
updateTotal();
schedulePersist();
});
const handleBlur = () => {
rows[index].value = sanitizeValue(valueInput.value);
rows[index].name = sanitizeName(nameInput.value);
const nextRows = ensureTrailingBlankRow(
rows.filter((candidate, candidateIndex) => !isBlankRow(candidate) || candidateIndex === rows.length - 1),
);
const changed = JSON.stringify(nextRows) !== JSON.stringify(rows);
rows = nextRows;
if (changed) {
renderRows();
} else {
updateTotal();
}
schedulePersist();
};
valueInput.addEventListener('blur', handleBlur);
nameInput.addEventListener('blur', handleBlur);
rowEl.append(valueInput, nameInput);
rowsEl.appendChild(rowEl);
});
updateTotal();
}
window.__nanobotSetCardRefresh?.(script, () => {
pruneRows();
renderRows();
});
renderRows();
})();
</script>

View file

@ -235,6 +235,8 @@
letter-spacing: 0.005em;
color: #624d40;
opacity: 0.95;
white-space: pre-wrap;
overflow-wrap: anywhere;
cursor: pointer;
touch-action: manipulation;
}
@ -324,175 +326,43 @@
text-align: left;
}
.task-card__editor-overlay {
display: none;
position: fixed;
inset: 0;
z-index: 10001;
padding:
max(18px, env(safe-area-inset-top))
max(16px, env(safe-area-inset-right))
max(18px, env(safe-area-inset-bottom))
max(16px, env(safe-area-inset-left));
background: rgba(38, 27, 21, 0.42);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
box-sizing: border-box;
.task-card__title--editing,
.task-card__body--editing {
cursor: text;
}
.task-card__editor-sheet {
.task-card__inline-editor {
display: block;
width: 100%;
max-width: 100%;
min-width: 0;
height: 100%;
display: grid;
grid-template-rows: auto 1fr auto;
gap: 14px;
border-radius: 22px;
border: 1px solid rgba(87, 65, 50, 0.16);
background:
radial-gradient(circle at top right, rgba(255, 255, 255, 0.74), transparent 28%),
linear-gradient(160deg, rgba(254, 246, 237, 0.985), rgba(240, 226, 210, 0.985));
color: var(--task-ink);
box-shadow:
0 22px 48px rgba(48, 32, 24, 0.24),
inset 0 1px 0 rgba(255, 255, 255, 0.7);
padding: 18px 16px 16px;
box-sizing: border-box;
overflow: hidden;
}
.task-card__editor-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-width: 0;
}
.task-card__editor-kicker {
font-size: 0.66rem;
line-height: 1.1;
letter-spacing: 0.12em;
text-transform: uppercase;
font-weight: 700;
color: var(--task-muted);
}
.task-card__editor-title {
margin-top: 3px;
font-family: 'IBM Plex Sans Condensed', 'Arial Narrow', sans-serif;
font-size: 1.08rem;
line-height: 1.04;
font-weight: 700;
letter-spacing: -0.012em;
color: var(--task-ink);
min-width: 0;
overflow-wrap: anywhere;
}
.task-card__editor-close {
appearance: none;
margin: 0;
padding: 0;
border: 0;
background: transparent;
color: var(--task-muted);
font: 700 0.86rem/1 'M-1m Code', var(--card-font, 'SF Mono', ui-monospace, Menlo, Consolas, monospace);
letter-spacing: 0.02em;
padding: 6px 4px;
cursor: pointer;
flex: 0 0 auto;
}
.task-card__editor-fields {
min-height: 0;
min-width: 0;
display: grid;
grid-template-rows: auto auto 1fr;
gap: 12px;
}
.task-card__editor-group {
display: grid;
gap: 5px;
min-height: 0;
min-width: 0;
}
.task-card__editor-label {
font-size: 0.66rem;
line-height: 1.1;
letter-spacing: 0.12em;
text-transform: uppercase;
font-weight: 700;
color: var(--task-muted);
}
.task-card__editor-input,
.task-card__editor-textarea {
width: 100%;
max-width: 100%;
min-width: 0;
box-sizing: border-box;
border-radius: 16px;
border: 1px solid rgba(87, 65, 50, 0.14);
background: rgba(255, 251, 246, 0.92);
color: var(--task-ink);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.78);
outline: none;
}
.task-card__editor-input {
min-height: 52px;
padding: 12px 13px;
font-family: 'IBM Plex Sans Condensed', 'Arial Narrow', sans-serif;
font-size: 1.02rem;
line-height: 1.1;
font-weight: 700;
letter-spacing: -0.012em;
}
.task-card__editor-textarea {
min-height: 0;
height: 100%;
max-height: 100%;
padding: 12px 13px;
resize: none;
font-family: 'IBM Plex Sans Condensed', 'Arial Narrow', sans-serif;
font-size: 0.94rem;
line-height: 1.36;
font-weight: 400;
letter-spacing: 0.004em;
overflow: hidden;
background: transparent;
color: inherit;
font: inherit;
line-height: inherit;
letter-spacing: inherit;
border-radius: 0;
box-shadow: none;
}
.task-card__editor-input:focus,
.task-card__editor-textarea:focus {
border-color: rgba(88, 112, 111, 0.48);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.82),
0 0 0 3px rgba(88, 112, 111, 0.12);
.task-card__inline-editor::placeholder {
color: rgba(98, 77, 64, 0.6);
opacity: 1;
font-style: italic;
}
.task-card__editor-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
min-width: 0;
flex-wrap: wrap;
.task-card__inline-editor--title {
min-height: 1.2em;
}
.task-card__editor-action-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex-wrap: wrap;
justify-content: flex-end;
margin-left: auto;
}
.task-card__editor-action-row > .task-card__button {
flex: 0 0 auto;
.task-card__inline-editor--body {
min-height: 1.34em;
}
</style>
@ -759,49 +629,7 @@
}
};
const editorOverlayEl = doc.createElement('div');
editorOverlayEl.className = 'task-card__editor-overlay';
editorOverlayEl.innerHTML = `
<div class="task-card__editor-sheet" role="dialog" aria-modal="true" aria-labelledby="task-editor-title">
<div class="task-card__editor-head">
<div>
<div class="task-card__editor-kicker">${laneLabels[lane] || 'Task'}</div>
<div id="task-editor-title" class="task-card__editor-title">Edit task</div>
</div>
<button type="button" class="task-card__editor-close" data-task-editor-close>Close</button>
</div>
<form class="task-card__editor-fields" data-task-editor-form>
<div class="task-card__editor-group">
<label class="task-card__editor-label" for="task-editor-title-input">Title</label>
<input id="task-editor-title-input" data-task-editor-title-input class="task-card__editor-input" type="text" maxlength="240" />
</div>
<div class="task-card__editor-group" style="min-height:0;">
<label class="task-card__editor-label" for="task-editor-body-input">Description</label>
<textarea id="task-editor-body-input" data-task-editor-body-input class="task-card__editor-textarea" placeholder="Add notes, context, or next steps"></textarea>
</div>
<div class="task-card__editor-actions">
<div class="task-card__editor-action-row">
<button type="button" class="task-card__button task-card__button--secondary" data-task-editor-cancel>Cancel</button>
<button type="submit" class="task-card__button" data-task-editor-save>Save</button>
</div>
</div>
</form>
</div>
`;
const editorFormEl = editorOverlayEl.querySelector('[data-task-editor-form]');
const editorTitleInputEl = editorOverlayEl.querySelector('[data-task-editor-title-input]');
const editorBodyInputEl = editorOverlayEl.querySelector('[data-task-editor-body-input]');
const editorCloseEl = editorOverlayEl.querySelector('[data-task-editor-close]');
const editorCancelEl = editorOverlayEl.querySelector('[data-task-editor-cancel]');
const editorSaveEl = editorOverlayEl.querySelector('[data-task-editor-save]');
if (
!(editorFormEl instanceof HTMLFormElement) ||
!(editorTitleInputEl instanceof HTMLInputElement) ||
!(editorBodyInputEl instanceof HTMLTextAreaElement) ||
!(editorCloseEl instanceof HTMLButtonElement) ||
!(editorCancelEl instanceof HTMLButtonElement) ||
!(editorSaveEl instanceof HTMLButtonElement)
) return;
let activeInlineEdit = null;
const setBusy = (busy) => {
laneToggleEl.disabled = busy || !taskPath;
@ -811,11 +639,11 @@
for (const button of tagsEl.querySelectorAll('button')) {
if (button instanceof HTMLButtonElement) button.disabled = busy;
}
editorTitleInputEl.disabled = busy;
editorBodyInputEl.disabled = busy;
editorCloseEl.disabled = busy;
editorCancelEl.disabled = busy;
editorSaveEl.disabled = busy;
summaryEl.style.pointerEvents = busy ? 'none' : '';
descriptionEl.style.pointerEvents = busy ? 'none' : '';
if (activeInlineEdit?.input instanceof HTMLTextAreaElement) {
activeInlineEdit.input.disabled = busy;
}
};
const closeMoveMenu = () => {
@ -858,26 +686,97 @@
window.dispatchEvent(new Event('nanobot:cards-refresh'));
};
const closeEditor = () => {
editorOverlayEl.style.display = 'none';
const autosizeInlineEditor = (editor, minHeight = 0) => {
editor.style.height = '0px';
const nextHeight = Math.max(Math.ceil(minHeight), editor.scrollHeight);
editor.style.height = `${Math.max(nextHeight, 20)}px`;
};
const openEditor = (focusField = 'title') => {
if (!taskPath) return;
const beginInlineEdit = (field) => {
if (!taskPath || activeInlineEdit) return;
closeMoveMenu();
editorTitleInputEl.value = title;
editorBodyInputEl.value = body;
if (editorOverlayEl.parentElement !== doc.body) {
doc.body.appendChild(editorOverlayEl);
}
editorOverlayEl.style.display = 'block';
view.requestAnimationFrame(() => {
const target = focusField === 'description' ? editorBodyInputEl : editorTitleInputEl;
target.focus();
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
const end = target.value.length;
target.setSelectionRange(end, end);
const host = field === 'title' ? summaryEl : descriptionEl;
const currentValue = field === 'title' ? title : body;
const editor = document.createElement('textarea');
const minHeight = host.getBoundingClientRect().height;
editor.className = `task-card__inline-editor ${
field === 'title' ? 'task-card__inline-editor--title' : 'task-card__inline-editor--body'
}`;
editor.rows = 1;
editor.value = currentValue;
editor.placeholder = field === 'description' ? 'Add description' : '';
editor.setAttribute('aria-label', field === 'title' ? 'Edit task title' : 'Edit task description');
host.textContent = '';
host.classList.remove('task-card__body--placeholder');
host.classList.add(field === 'title' ? 'task-card__title--editing' : 'task-card__body--editing');
host.appendChild(editor);
autosizeInlineEditor(editor, minHeight);
const cancel = () => {
if (activeInlineEdit?.input !== editor) return;
activeInlineEdit = null;
render();
};
const commit = async () => {
if (activeInlineEdit?.input !== editor) return;
const nextValue = editor.value.trim();
if (field === 'title' && !nextValue) {
editor.focus();
return;
}
activeInlineEdit = null;
if (nextValue === currentValue) {
render();
return;
}
const ok = await runTaskEdit(field === 'title' ? { title: nextValue } : { description: nextValue });
if (!ok) render();
};
activeInlineEdit = {
field,
input: editor,
cancel,
commit,
};
editor.addEventListener('input', () => {
autosizeInlineEditor(editor, minHeight);
});
editor.addEventListener('click', (event) => {
event.stopPropagation();
});
editor.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
event.preventDefault();
cancel();
return;
}
if (field === 'title' && event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
editor.blur();
return;
}
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault();
editor.blur();
}
});
editor.addEventListener('blur', () => {
if (activeInlineEdit?.input !== editor) return;
void commit();
});
view.requestAnimationFrame(() => {
editor.focus();
const end = editor.value.length;
editor.setSelectionRange(end, end);
});
};
@ -958,13 +857,14 @@
if (payload && typeof payload === 'object' && payload.error) {
throw new Error(String(payload.error));
}
closeEditor();
refreshCards();
return true;
} catch (error) {
console.error('Task edit failed', error);
setBusy(false);
setStatus('Unavailable', '#8e3023', '#f3d3cc');
publishLiveContent(lane, true, String(error));
return false;
}
};
@ -1094,48 +994,16 @@
doc.body.appendChild(moveMenuEl);
}
editorCloseEl.addEventListener('click', () => {
closeEditor();
});
editorCancelEl.addEventListener('click', () => {
closeEditor();
});
editorOverlayEl.addEventListener('pointerdown', (event) => {
if (event.target === editorOverlayEl) {
closeEditor();
}
});
editorFormEl.addEventListener('submit', (event) => {
event.preventDefault();
const nextTitle = editorTitleInputEl.value.trim();
const nextDescription = editorBodyInputEl.value.trim();
if (!nextTitle) {
editorTitleInputEl.focus();
return;
}
if (nextTitle === title && nextDescription === body) {
closeEditor();
return;
}
void runTaskEdit({
title: nextTitle,
description: nextDescription,
});
});
summaryEl.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
openEditor('title');
beginInlineEdit('title');
});
descriptionEl.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
openEditor('description');
beginInlineEdit('description');
});
const render = () => {

View file

@ -3,12 +3,14 @@
"title": "Live Weather",
"notes": "Live weather summary card. Fill template_state with subtitle, tool_name (defaults to Home Assistant GetLiveContext), provider_prefix or exact sensor names, optional condition_label, and refresh_ms. Wind and pressure render when matching sensors exist in the live context payload.",
"example_state": {
"subtitle": "OpenWeatherMap live context",
"subtitle": "Weather",
"tool_name": "mcp_home_assistant_GetLiveContext",
"forecast_tool_name": "exec",
"forecast_command": "python3 /home/kacper/nanobot/scripts/card_upcoming_conditions.py --nws-entity weather.korh --uv-entity weather.openweathermap_2 --forecast-type hourly --limit 4",
"provider_prefix": "OpenWeatherMap",
"temperature_name": "OpenWeatherMap Temperature",
"humidity_name": "OpenWeatherMap Humidity",
"condition_label": "OpenWeatherMap live context",
"condition_label": "Weather",
"refresh_ms": 86400000
},
"created_at": "2026-03-11T04:12:48.601255+00:00",

View file

@ -1,4 +1,13 @@
<div data-weather-card style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background:#ffffff; color:#111827; padding:14px 16px;">
<style>
@font-face {
font-family: 'BlexMono Nerd Font Mono';
src: url('/card-templates/upcoming-conditions-live/assets/BlexMonoNerdFontMono-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
</style>
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:12px; margin-bottom:8px;">
<div data-weather-subtitle style="font-size:0.86rem; line-height:1.35; color:#4b5563; font-weight:600;">Loading…</div>
<span data-weather-status style="font-size:0.8rem; line-height:1.2; font-weight:700; color:#6b7280; white-space:nowrap;">Loading…</span>
@ -9,24 +18,22 @@
<span data-weather-unit style="font-size:1.05rem; font-weight:700; color:#4b5563; padding-bottom:0.28rem;">°F</span>
</div>
<div data-weather-condition style="font-size:1rem; line-height:1.3; font-weight:700; color:#1f2937; margin-bottom:10px; text-transform:capitalize;">--</div>
<div style="display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:10px 12px;">
<div>
<div style="font-size:0.74rem; line-height:1.2; text-transform:uppercase; letter-spacing:0.04em; color:#6b7280;">Humidity</div>
<div title="Humidity" style="font-family:'BlexMono Nerd Font Mono', monospace; font-size:0.86rem; line-height:1.2; color:#6b7280;">󰖎</div>
<div data-weather-humidity style="margin-top:2px; font-size:1rem; line-height:1.25; font-weight:700; color:#111827;">--</div>
</div>
<div>
<div style="font-size:0.74rem; line-height:1.2; text-transform:uppercase; letter-spacing:0.04em; color:#6b7280;">Wind</div>
<div title="Wind" style="font-family:'BlexMono Nerd Font Mono', monospace; font-size:0.86rem; line-height:1.2; color:#6b7280;"></div>
<div data-weather-wind style="margin-top:2px; font-size:1rem; line-height:1.25; font-weight:700; color:#111827;">--</div>
</div>
<div>
<div style="font-size:0.74rem; line-height:1.2; text-transform:uppercase; letter-spacing:0.04em; color:#6b7280;">Pressure</div>
<div data-weather-pressure style="margin-top:2px; font-size:1rem; line-height:1.25; font-weight:700; color:#111827;">--</div>
<div title="Rain" style="font-family:'BlexMono Nerd Font Mono', monospace; font-size:0.86rem; line-height:1.2; color:#6b7280;"></div>
<div data-weather-rain style="margin-top:2px; font-size:1rem; line-height:1.25; font-weight:700; color:#111827;">--</div>
</div>
<div>
<div style="font-size:0.74rem; line-height:1.2; text-transform:uppercase; letter-spacing:0.04em; color:#6b7280;">Updated</div>
<div data-weather-updated style="margin-top:2px; font-size:0.94rem; line-height:1.25; font-weight:700; color:#374151;">--</div>
<div title="UV" style="font-family:'BlexMono Nerd Font Mono', monospace; font-size:0.86rem; line-height:1.2; color:#6b7280;"></div>
<div data-weather-uv style="margin-top:2px; font-size:1rem; line-height:1.25; font-weight:700; color:#111827;">--</div>
</div>
</div>
</div>
@ -40,22 +47,20 @@
const subtitleEl = root.querySelector('[data-weather-subtitle]');
const tempEl = root.querySelector('[data-weather-temp]');
const unitEl = root.querySelector('[data-weather-unit]');
const condEl = root.querySelector('[data-weather-condition]');
const humidityEl = root.querySelector('[data-weather-humidity]');
const windEl = root.querySelector('[data-weather-wind]');
const pressureEl = root.querySelector('[data-weather-pressure]');
const updatedEl = root.querySelector('[data-weather-updated]');
const rainEl = root.querySelector('[data-weather-rain]');
const uvEl = root.querySelector('[data-weather-uv]');
const statusEl = root.querySelector('[data-weather-status]');
if (!(subtitleEl instanceof HTMLElement) || !(tempEl instanceof HTMLElement) || !(unitEl instanceof HTMLElement) || !(condEl instanceof HTMLElement) || !(humidityEl instanceof HTMLElement) || !(windEl instanceof HTMLElement) || !(pressureEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement)) return;
if (!(subtitleEl instanceof HTMLElement) || !(tempEl instanceof HTMLElement) || !(unitEl instanceof HTMLElement) || !(humidityEl instanceof HTMLElement) || !(windEl instanceof HTMLElement) || !(rainEl instanceof HTMLElement) || !(uvEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement)) return;
const subtitle = typeof state.subtitle === 'string' ? state.subtitle : '';
const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
const configuredForecastToolName = typeof state.forecast_tool_name === 'string' ? state.forecast_tool_name.trim() : 'exec';
const forecastCommand = typeof state.forecast_command === 'string' ? state.forecast_command.trim() : '';
const providerPrefix = typeof state.provider_prefix === 'string' ? state.provider_prefix.trim() : '';
const temperatureName = typeof state.temperature_name === 'string' ? state.temperature_name.trim() : '';
const humidityName = typeof state.humidity_name === 'string' ? state.humidity_name.trim() : '';
const pressureName = typeof state.pressure_name === 'string' ? state.pressure_name.trim() : '';
const windName = typeof state.wind_name === 'string' ? state.wind_name.trim() : '';
const conditionLabel = typeof state.condition_label === 'string' ? state.condition_label.trim() : '';
const refreshMsRaw = Number(state.refresh_ms);
const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 24 * 60 * 60 * 1000;
@ -162,6 +167,18 @@
return '';
};
const stripExecFooter = (value) => String(value || '').replace(/\n+\s*Exit code:\s*\d+\s*$/i, '').trim();
const extractExecJson = (toolResult) => {
const parsedText = stripExecFooter(toolResult?.content);
if (!parsedText) return null;
try {
return JSON.parse(parsedText);
} catch {
return null;
}
};
const resolveToolName = async () => {
if (configuredToolName) return configuredToolName;
if (!window.__nanobotListTools) return 'mcp_home_assistant_GetLiveContext';
@ -187,24 +204,41 @@
}) || null;
};
const resolveForecastBundle = async () => {
if (!forecastCommand) return null;
const toolResult = await window.__nanobotCallTool?.(configuredForecastToolName || 'exec', {
command: forecastCommand,
max_output_chars: 200000,
});
const payload = extractExecJson(toolResult);
return payload && typeof payload === 'object' ? payload : null;
};
const firstForecastEntry = (bundle, key, metricKey = '') => {
const source = bundle && typeof bundle === 'object' ? bundle[key] : null;
const forecast = source && typeof source === 'object' && Array.isArray(source.forecast) ? source.forecast : [];
if (!metricKey) {
return forecast.length > 0 && forecast[0] && typeof forecast[0] === 'object' ? forecast[0] : null;
}
return forecast.find((entry) => entry && typeof entry === 'object' && entry[metricKey] !== null && entry[metricKey] !== undefined) || null;
};
const refresh = async () => {
const resolvedToolName = await resolveToolName();
if (!resolvedToolName) {
const errorText = 'Missing tool_name';
setStatus('No tool', '#b91c1c');
updatedEl.textContent = errorText;
updateLiveContent({
kind: 'weather',
subtitle: subtitleEl.textContent || null,
tool_name: null,
temperature: null,
temperature_unit: String(state.unit || '°F'),
condition: null,
humidity: null,
wind: null,
pressure: null,
rain: null,
uv: null,
status: 'No tool',
updated_at: errorText,
error: errorText,
});
return;
@ -212,7 +246,10 @@
setStatus('Refreshing', '#6b7280');
try {
const toolResult = await window.__nanobotCallTool?.(resolvedToolName, {});
const [toolResult, forecastBundle] = await Promise.all([
window.__nanobotCallTool?.(resolvedToolName, {}),
resolveForecastBundle(),
]);
const entries = parseLiveContextEntries(extractLiveContextText(toolResult)).filter((entry) => normalizeText(entry.domain) === 'sensor');
const prefix = providerPrefix || 'OpenWeatherMap';
const temperatureEntry = findEntry(entries, [
@ -223,32 +260,29 @@
humidityName,
`${prefix} Humidity`,
]);
const pressureEntry = findEntry(entries, [
pressureName,
`${prefix} Pressure`,
]);
const windEntry = findEntry(entries, [
windName,
`${prefix} Wind speed`,
`${prefix} Wind`,
]);
const temperature = Number(temperatureEntry?.state);
tempEl.textContent = Number.isFinite(temperature) ? String(Math.round(temperature)) : '--';
unitEl.textContent = String(temperatureEntry?.attributes?.unit_of_measurement || state.unit || '°F');
condEl.textContent = conditionLabel || `${prefix || 'Weather'} live context`;
const humidity = Number(humidityEntry?.state);
humidityEl.textContent = Number.isFinite(humidity) ? `${Math.round(humidity)}%` : '--';
const windSpeed = Number(windEntry?.state);
const windUnit = String(windEntry?.attributes?.unit_of_measurement || 'mph');
windEl.textContent = Number.isFinite(windSpeed) ? `${windSpeed} ${windUnit}` : '--';
const pressure = Number(pressureEntry?.state);
pressureEl.textContent = Number.isFinite(pressure)
? `${pressure} ${String(pressureEntry?.attributes?.unit_of_measurement || '').trim()}`.trim()
: '--';
const updatedText = new Date().toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
updatedEl.textContent = updatedText;
subtitleEl.textContent = subtitle || prefix || 'Home Assistant live context';
const nwsEntry = firstForecastEntry(forecastBundle, 'nws');
const uvEntry = firstForecastEntry(forecastBundle, 'uv', 'uv_index');
const nwsSource = forecastBundle && typeof forecastBundle === 'object' && forecastBundle.nws && typeof forecastBundle.nws === 'object' ? forecastBundle.nws : null;
const windSpeed = Number(nwsEntry?.wind_speed);
const windUnit = String(nwsSource?.wind_speed_unit || 'mph');
windEl.textContent = Number.isFinite(windSpeed) ? `${Math.round(windSpeed)} ${windUnit}` : '--';
const rainChance = Number(nwsEntry?.precipitation_probability);
rainEl.textContent = Number.isFinite(rainChance) ? `${Math.round(rainChance)}%` : '--';
const uvValue = Number(uvEntry?.uv_index);
uvEl.textContent = Number.isFinite(uvValue) ? String(Math.round(uvValue)) : '--';
subtitleEl.textContent = subtitle || prefix || 'Weather';
setStatus('Live', '#047857');
updateLiveContent({
kind: 'weather',
@ -256,29 +290,32 @@
tool_name: resolvedToolName,
temperature: Number.isFinite(temperature) ? Math.round(temperature) : null,
temperature_unit: unitEl.textContent || null,
condition: condEl.textContent || null,
humidity: Number.isFinite(humidity) ? Math.round(humidity) : null,
wind: windEl.textContent || null,
pressure: pressureEl.textContent || null,
rain: rainEl.textContent || null,
uv: Number.isFinite(uvValue) ? Math.round(uvValue) : null,
status: 'Live',
updated_at: updatedText,
});
} catch (error) {
const errorText = String(error);
setStatus('Unavailable', '#b91c1c');
updatedEl.textContent = errorText;
tempEl.textContent = '--';
unitEl.textContent = String(state.unit || '°F');
humidityEl.textContent = '--';
windEl.textContent = '--';
rainEl.textContent = '--';
uvEl.textContent = '--';
updateLiveContent({
kind: 'weather',
subtitle: subtitleEl.textContent || null,
tool_name: resolvedToolName,
temperature: null,
temperature_unit: unitEl.textContent || null,
condition: null,
humidity: null,
wind: null,
pressure: null,
rain: null,
uv: null,
status: 'Unavailable',
updated_at: errorText,
error: errorText,
});
}