feat: unify card runtime and event-driven web ui
Some checks failed
CI / Backend Checks (push) Failing after 36s
CI / Frontend Checks (push) Failing after 40s

This commit is contained in:
kacper 2026-04-06 15:42:53 -04:00
parent 0edf8c3fef
commit 4dfb7ca3cc
105 changed files with 17382 additions and 8505 deletions

View file

@ -0,0 +1,258 @@
function clampDigits(raw) {
const parsed = Number(raw);
if (!Number.isFinite(parsed)) return 4;
return Math.max(1, Math.min(4, Math.round(parsed)));
}
function sanitizeValue(raw, maxDigits) {
return String(raw || "")
.replace(/\D+/g, "")
.slice(0, maxDigits);
}
function sanitizeName(raw) {
return String(raw || "")
.replace(/\s+/g, " ")
.trimStart();
}
function isBlankRow(row) {
return !row || (!String(row.value || "").trim() && !String(row.name || "").trim());
}
function normalizeRows(raw, maxDigits) {
if (!Array.isArray(raw)) return [];
return raw
.filter((row) => row && typeof row === "object" && !Array.isArray(row))
.map((row) => ({
value: sanitizeValue(row.value, maxDigits),
name: sanitizeName(row.name),
}));
}
function ensureTrailingBlankRow(rows) {
const next = rows.map((row) => ({
value: String(row.value || ""),
name: String(row.name || ""),
}));
if (!next.length || !isBlankRow(next[next.length - 1])) {
next.push({ value: "", name: "" });
}
return next;
}
function persistedRows(rows) {
return rows
.filter((row) => !isBlankRow(row))
.map((row) => ({
value: String(row.value || ""),
name: String(row.name || ""),
}));
}
function totalValue(rows) {
return persistedRows(rows).reduce((sum, row) => sum + (Number.parseInt(row.value, 10) || 0), 0);
}
function normalizeConfig(state) {
const maxDigits = clampDigits(state.max_digits);
return {
leftLabel: String(state.left_label || "Value").trim() || "Value",
rightLabel: String(state.right_label || "Item").trim() || "Item",
totalLabel: String(state.total_label || "Total").trim() || "Total",
totalSuffix: String(state.total_suffix || "").trim(),
maxDigits,
score:
typeof state.score === "number" && Number.isFinite(state.score)
? Math.max(0, Math.min(100, state.score))
: 24,
rows: ensureTrailingBlankRow(normalizeRows(state.rows, maxDigits)),
};
}
function configState(config) {
return {
left_label: config.leftLabel,
right_label: config.rightLabel,
total_label: config.totalLabel,
total_suffix: config.totalSuffix,
max_digits: config.maxDigits,
score: config.score,
rows: persistedRows(config.rows),
};
}
function autoscore(config) {
if (typeof config.score === "number" && Number.isFinite(config.score) && config.score > 0) {
return config.score;
}
return persistedRows(config.rows).length ? 24 : 16;
}
export function mount({ root, state, host }) {
const labelsEl = root.querySelector(".list-total-card-ui__labels");
const rowsEl = root.querySelector(".list-total-card-ui__rows");
const statusEl = root.querySelector(".list-total-card-ui__status");
const totalLabelEl = root.querySelector(".list-total-card-ui__total-label");
const totalEl = root.querySelector(".list-total-card-ui__total-value");
if (
!(labelsEl instanceof HTMLElement) ||
!(rowsEl instanceof HTMLElement) ||
!(statusEl instanceof HTMLElement) ||
!(totalLabelEl instanceof HTMLElement) ||
!(totalEl instanceof HTMLElement)
) {
return;
}
const leftLabelEl = labelsEl.children.item(0);
const rightLabelEl = labelsEl.children.item(1);
if (!(leftLabelEl instanceof HTMLElement) || !(rightLabelEl instanceof HTMLElement)) return;
let config = normalizeConfig(state);
let saveTimer = null;
let busy = false;
const clearSaveTimer = () => {
if (saveTimer !== null) {
window.clearTimeout(saveTimer);
saveTimer = null;
}
};
const setStatus = (text, kind) => {
statusEl.textContent = text || "";
statusEl.dataset.kind = kind || "";
};
const publishLiveContent = () => {
host.setLiveContent({
kind: "list_total",
item_count: persistedRows(config.rows).length,
total: totalValue(config.rows),
total_suffix: config.totalSuffix || null,
score: autoscore(config),
});
};
const renderTotal = () => {
const total = totalValue(config.rows);
totalEl.textContent = `${total.toLocaleString()}${config.totalSuffix || ""}`;
publishLiveContent();
};
const persist = async () => {
clearSaveTimer();
busy = true;
setStatus("Saving", "ok");
try {
const nextState = configState(config);
await host.replaceState(nextState);
config = normalizeConfig(nextState);
setStatus("", "");
} catch (error) {
console.error("List total card save failed", error);
setStatus("Unavailable", "error");
} finally {
busy = false;
render();
}
};
const schedulePersist = () => {
clearSaveTimer();
saveTimer = window.setTimeout(() => {
void persist();
}, 280);
};
const normalizeRowsAfterBlur = () => {
config.rows = ensureTrailingBlankRow(persistedRows(config.rows));
render();
schedulePersist();
};
const renderRows = () => {
rowsEl.innerHTML = "";
config.rows.forEach((row, index) => {
const rowEl = document.createElement("div");
rowEl.className = "list-total-card-ui__row";
const valueInput = document.createElement("input");
valueInput.className = "list-total-card-ui__input list-total-card-ui__value";
valueInput.type = "text";
valueInput.inputMode = "numeric";
valueInput.maxLength = config.maxDigits;
valueInput.placeholder = "0";
valueInput.value = row.value;
valueInput.disabled = busy;
const nameInput = document.createElement("input");
nameInput.className = "list-total-card-ui__input list-total-card-ui__name";
nameInput.type = "text";
nameInput.placeholder = "Item";
nameInput.value = row.name;
nameInput.disabled = busy;
valueInput.addEventListener("input", () => {
config.rows[index].value = sanitizeValue(valueInput.value, config.maxDigits);
valueInput.value = config.rows[index].value;
if (index === config.rows.length - 1 && !isBlankRow(config.rows[index])) {
config.rows = ensureTrailingBlankRow(config.rows);
render();
schedulePersist();
return;
}
renderTotal();
schedulePersist();
});
nameInput.addEventListener("input", () => {
config.rows[index].name = sanitizeName(nameInput.value);
nameInput.value = config.rows[index].name;
if (index === config.rows.length - 1 && !isBlankRow(config.rows[index])) {
config.rows = ensureTrailingBlankRow(config.rows);
render();
schedulePersist();
return;
}
renderTotal();
schedulePersist();
});
valueInput.addEventListener("blur", normalizeRowsAfterBlur);
nameInput.addEventListener("blur", normalizeRowsAfterBlur);
rowEl.append(valueInput, nameInput);
rowsEl.appendChild(rowEl);
});
};
const render = () => {
leftLabelEl.textContent = config.leftLabel;
rightLabelEl.textContent = config.rightLabel;
totalLabelEl.textContent = config.totalLabel;
renderRows();
renderTotal();
};
host.setRefreshHandler(() => {
config.rows = ensureTrailingBlankRow(config.rows);
render();
});
render();
return {
update({ state: nextState }) {
config = normalizeConfig(nextState);
render();
},
destroy() {
clearSaveTimer();
host.setRefreshHandler(null);
host.setLiveContent(null);
},
};
}

View file

@ -1,336 +1,12 @@
<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 class="list-total-card-ui">
<div class="list-total-card-ui__labels">
<div>Value</div>
<div>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 class="list-total-card-ui__rows"></div>
<div class="list-total-card-ui__status"></div>
<div class="list-total-card-ui__total">
<div class="list-total-card-ui__total-label">Total</div>
<div class="list-total-card-ui__total-value">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>