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

@ -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 = () => {