feat: polish life os cards and voice stack
This commit is contained in:
parent
66362c7176
commit
0edf8c3fef
21 changed files with 3681 additions and 502 deletions
24
examples/cards/templates/list-total-live/manifest.json
Normal file
24
examples/cards/templates/list-total-live/manifest.json
Normal 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"
|
||||
}
|
||||
336
examples/cards/templates/list-total-live/template.html
Normal file
336
examples/cards/templates/list-total-live/template.html
Normal 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>
|
||||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue