feat: snapshot fullscreen todo card editor
Some checks failed
CI / Backend Checks (push) Failing after 28s
CI / Frontend Checks (push) Failing after 43s

This commit is contained in:
kacper 2026-03-20 05:06:25 -04:00
parent 980dfb9e0e
commit 66362c7176

View file

@ -87,57 +87,111 @@
.task-card__topline { .task-card__topline {
display: flex; display: flex;
align-items: flex-start; position: relative;
z-index: 4;
align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 10px; gap: 10px;
} }
.task-card__lane-button {
appearance: none;
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
padding: 0;
border: 0;
background: transparent;
font: inherit;
color: inherit;
cursor: pointer;
}
.task-card__lane { .task-card__lane {
min-width: 0; min-width: 0;
font-size: 0.69rem; font-size: 0.64rem;
line-height: 1.1; line-height: 1.1;
letter-spacing: 0.14em; letter-spacing: 0.11em;
text-transform: uppercase; text-transform: uppercase;
color: var(--task-muted); color: var(--task-muted);
font-weight: 700; font-weight: 700;
white-space: nowrap;
}
.task-card__lane-caret {
flex: 0 0 auto;
font-family: 'M-1m Code', var(--card-font, 'SF Mono', ui-monospace, Menlo, Consolas, monospace);
font-size: 0.66rem;
line-height: 1;
color: var(--task-muted);
transform: translateY(-1px);
transition: transform 0.18s ease;
}
.task-card__lane-button[data-open='1'] .task-card__lane-caret {
transform: translateY(-1px) rotate(180deg);
}
.task-card__lane-button:disabled {
cursor: default;
opacity: 0.55;
} }
.task-card__stamp { .task-card__stamp {
display: none; display: none;
flex: 0 0 auto; flex: 0 0 auto;
align-items: center; align-items: center;
justify-content: center; justify-content: flex-end;
white-space: nowrap; white-space: nowrap;
border-radius: 999px; padding: 0;
padding: 5px 8px; font-size: 0.72rem;
font-size: 0.68rem;
line-height: 1; line-height: 1;
font-weight: 700; font-weight: 700;
border: 1px solid rgba(0, 0, 0, 0.04); letter-spacing: 0.08em;
color: var(--task-muted);
background: transparent;
border: 0;
} }
.task-card__title { .task-card__title {
font-family: 'IBM Plex Sans Condensed', 'Arial Narrow', sans-serif; font-family: 'IBM Plex Sans Condensed', 'Arial Narrow', sans-serif;
font-size: 1.08rem; font-size: 0.96rem;
line-height: 1.1; line-height: 1.06;
font-weight: 700; font-weight: 700;
letter-spacing: -0.01em; letter-spacing: -0.008em;
color: var(--task-ink); color: var(--task-ink);
text-wrap: balance; text-wrap: balance;
word-break: break-word; word-break: break-word;
cursor: pointer;
touch-action: manipulation;
} }
.task-card__tags { .task-card__tags {
display: none; display: none;
flex-wrap: wrap; flex-wrap: nowrap;
gap: 6px; gap: 6px;
position: relative;
z-index: 1;
width: 100%;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
-ms-overflow-style: none;
-webkit-overflow-scrolling: touch;
overscroll-behavior-x: contain;
}
.task-card__tags::-webkit-scrollbar {
display: none;
} }
.task-card__tag { .task-card__tag {
appearance: none;
display: inline-flex; display: inline-flex;
flex: 0 0 auto;
align-items: center; align-items: center;
min-height: 24px; min-height: 24px;
max-width: 100%;
border-radius: 999px; border-radius: 999px;
padding: 4px 9px; padding: 4px 9px;
background: var(--task-accent-soft); background: var(--task-accent-soft);
@ -148,6 +202,28 @@
border: 1px solid rgba(0, 0, 0, 0.035); border: 1px solid rgba(0, 0, 0, 0.035);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap;
cursor: default;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
touch-action: manipulation;
}
.task-card__tag:disabled {
cursor: default;
opacity: 0.6;
}
.task-card__tag--action {
border-style: dashed;
background: rgba(255, 248, 239, 0.74);
cursor: pointer;
}
.task-card__tag--holding {
background: rgba(165, 95, 75, 0.18);
color: #7b2f20;
} }
.task-card__body { .task-card__body {
@ -159,6 +235,13 @@
letter-spacing: 0.005em; letter-spacing: 0.005em;
color: #624d40; color: #624d40;
opacity: 0.95; opacity: 0.95;
cursor: pointer;
touch-action: manipulation;
}
.task-card__body--placeholder {
opacity: 0.62;
font-style: italic;
} }
.task-card__meta { .task-card__meta {
@ -213,28 +296,221 @@
.task-card__move { .task-card__move {
display: none; display: none;
flex-wrap: wrap; position: fixed;
top: 0;
left: 0;
z-index: 9999;
flex-direction: column;
gap: 6px; gap: 6px;
min-width: 150px;
padding: 6px;
border-radius: 14px;
background: rgba(255, 248, 239, 0.96);
box-shadow:
0 10px 24px rgba(79, 56, 43, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.72);
} }
.task-card__move-button { .task-card__move-button {
appearance: none; appearance: none;
border: 0; border: 0;
border-radius: 999px; border-radius: 10px;
padding: 7px 10px; padding: 8px 10px;
background: rgba(255, 248, 239, 0.78); background: rgba(255, 248, 239, 0.78);
color: var(--task-button-ink); color: var(--task-button-ink);
font: 700 0.7rem/1 'M-1m Code', var(--card-font, 'SF Mono', ui-monospace, Menlo, Consolas, monospace); font: 700 0.7rem/1 'M-1m Code', var(--card-font, 'SF Mono', ui-monospace, Menlo, Consolas, monospace);
cursor: pointer; cursor: pointer;
border: 1px solid rgba(0, 0, 0, 0.05); border: 1px solid rgba(0, 0, 0, 0.05);
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__editor-sheet {
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;
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;
}
.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__editor-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
min-width: 0;
flex-wrap: wrap;
}
.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;
} }
</style> </style>
<div data-task-item-card class="task-card"> <div data-task-item-card class="task-card">
<div class="task-card__inner"> <div class="task-card__inner">
<div class="task-card__topline"> <div class="task-card__topline">
<div data-task-subtitle class="task-card__lane">Task</div> <button
data-task-lane-toggle
type="button"
class="task-card__lane-button"
aria-expanded="false"
data-open="0"
>
<span data-task-subtitle class="task-card__lane">Task</span>
<span class="task-card__lane-caret"></span>
</button>
<span data-task-status class="task-card__stamp"></span> <span data-task-status class="task-card__stamp"></span>
<div data-task-move-menu class="task-card__move"></div>
</div> </div>
<div data-task-summary class="task-card__title">Loading...</div> <div data-task-summary class="task-card__title">Loading...</div>
@ -243,15 +519,8 @@
<div class="task-card__meta"> <div class="task-card__meta">
<div data-task-due class="task-card__meta-chip"></div> <div data-task-due class="task-card__meta-chip"></div>
<div data-task-age class="task-card__meta-chip"></div>
</div> </div>
<div class="task-card__actions">
<button data-task-primary type="button" class="task-card__button" style="display:none;">Start</button>
<button data-task-move-toggle type="button" class="task-card__button task-card__button--secondary" style="display:none;">Move</button>
</div>
<div data-task-move-menu class="task-card__move"></div>
</div> </div>
</div> </div>
@ -261,29 +530,27 @@
const root = script?.closest('[data-nanobot-card-root]'); const root = script?.closest('[data-nanobot-card-root]');
const state = window.__nanobotGetCardState?.(script) || {}; const state = window.__nanobotGetCardState?.(script) || {};
if (!(root instanceof HTMLElement)) return; if (!(root instanceof HTMLElement)) return;
const doc = root.ownerDocument || document;
const view = doc.defaultView || window;
const cardEl = root.querySelector('[data-task-item-card]'); const cardEl = root.querySelector('[data-task-item-card]');
const laneToggleEl = root.querySelector('[data-task-lane-toggle]');
const subtitleEl = root.querySelector('[data-task-subtitle]'); const subtitleEl = root.querySelector('[data-task-subtitle]');
const statusEl = root.querySelector('[data-task-status]'); const statusEl = root.querySelector('[data-task-status]');
const summaryEl = root.querySelector('[data-task-summary]'); const summaryEl = root.querySelector('[data-task-summary]');
const tagsEl = root.querySelector('[data-task-tags]'); const tagsEl = root.querySelector('[data-task-tags]');
const dueEl = root.querySelector('[data-task-due]'); const dueEl = root.querySelector('[data-task-due]');
const ageEl = root.querySelector('[data-task-age]');
const descriptionEl = root.querySelector('[data-task-description]'); const descriptionEl = root.querySelector('[data-task-description]');
const primaryEl = root.querySelector('[data-task-primary]');
const moveToggleEl = root.querySelector('[data-task-move-toggle]');
const moveMenuEl = root.querySelector('[data-task-move-menu]'); const moveMenuEl = root.querySelector('[data-task-move-menu]');
if ( if (
!(cardEl instanceof HTMLElement) || !(cardEl instanceof HTMLElement) ||
!(laneToggleEl instanceof HTMLButtonElement) ||
!(subtitleEl instanceof HTMLElement) || !(subtitleEl instanceof HTMLElement) ||
!(statusEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement) ||
!(summaryEl instanceof HTMLElement) || !(summaryEl instanceof HTMLElement) ||
!(tagsEl instanceof HTMLElement) || !(tagsEl instanceof HTMLElement) ||
!(dueEl instanceof HTMLElement) || !(dueEl instanceof HTMLElement) ||
!(ageEl instanceof HTMLElement) ||
!(descriptionEl instanceof HTMLElement) || !(descriptionEl instanceof HTMLElement) ||
!(primaryEl instanceof HTMLButtonElement) ||
!(moveToggleEl instanceof HTMLButtonElement) ||
!(moveMenuEl instanceof HTMLElement) !(moveMenuEl instanceof HTMLElement)
) return; ) return;
@ -351,18 +618,9 @@
canceled: 'Cancel', canceled: 'Cancel',
}; };
const primaryActionForLane = (value) => {
if (value === 'backlog') return { lane: 'in-progress', label: 'Start' };
if (value === 'in-progress') return { lane: 'done', label: 'Done' };
if (value === 'blocked') return { lane: 'in-progress', label: 'Resume' };
return null;
};
const moveOptionsForLane = (value) => { const moveOptionsForLane = (value) => {
const primary = primaryActionForLane(value);
return ['backlog', 'in-progress', 'blocked', 'done', 'canceled'] return ['backlog', 'in-progress', 'blocked', 'done', 'canceled']
.filter((targetLane) => targetLane !== value) .filter((targetLane) => targetLane !== value)
.filter((targetLane) => !primary || targetLane !== primary.lane)
.map((targetLane) => ({ .map((targetLane) => ({
lane: targetLane, lane: targetLane,
label: actionLabels[targetLane] || targetLane, label: actionLabels[targetLane] || targetLane,
@ -379,8 +637,10 @@
const setStatus = (label, fg, bg) => { const setStatus = (label, fg, bg) => {
statusEl.textContent = label; statusEl.textContent = label;
statusEl.style.color = fg; statusEl.style.color = fg || 'var(--task-muted)';
statusEl.style.background = bg; statusEl.style.background = bg && bg !== 'transparent' ? bg : 'transparent';
statusEl.style.border = bg && bg !== 'transparent' ? '1px solid rgba(0, 0, 0, 0.04)' : '0';
statusEl.style.padding = bg && bg !== 'transparent' ? '5px 8px' : '0';
statusEl.style.display = label ? 'inline-flex' : 'none'; statusEl.style.display = label ? 'inline-flex' : 'none';
}; };
@ -400,7 +660,7 @@
const computeScore = () => { const computeScore = () => {
const now = Date.now(); const now = Date.now();
const dueMs = parseDueTimeMs(); const dueMs = parseDueTimeMs();
let score = 70; let score = 54;
if (Number.isFinite(dueMs)) { if (Number.isFinite(dueMs)) {
const hoursUntilDue = (dueMs - now) / (60 * 60 * 1000); const hoursUntilDue = (dueMs - now) / (60 * 60 * 1000);
if (hoursUntilDue <= 0) score = 100; if (hoursUntilDue <= 0) score = 100;
@ -413,12 +673,12 @@
const createdMs = parseCreatedTimeMs(); const createdMs = parseCreatedTimeMs();
if (Number.isFinite(createdMs)) { if (Number.isFinite(createdMs)) {
const ageDays = Math.max(0, (now - createdMs) / (24 * 60 * 60 * 1000)); const ageDays = Math.max(0, (now - createdMs) / (24 * 60 * 60 * 1000));
if (ageDays >= 30) score = 94; if (ageDays >= 30) score = 80;
else if (ageDays >= 21) score = 90; else if (ageDays >= 21) score = 76;
else if (ageDays >= 14) score = 86; else if (ageDays >= 14) score = 72;
else if (ageDays >= 7) score = 82; else if (ageDays >= 7) score = 68;
else if (ageDays >= 3) score = 78; else if (ageDays >= 3) score = 62;
else if (ageDays >= 1) score = 74; else if (ageDays >= 1) score = 58;
} }
} }
if (lane === 'blocked') return Math.min(100, score + 4); if (lane === 'blocked') return Math.min(100, score + 4);
@ -437,16 +697,6 @@
return `Due ${parsed.toLocaleDateString([], { month: 'short', day: 'numeric' })}`; return `Due ${parsed.toLocaleDateString([], { month: 'short', day: 'numeric' })}`;
}; };
const formatAge = () => {
const createdMs = parseCreatedTimeMs();
if (!Number.isFinite(createdMs)) return '';
const days = Math.floor((Date.now() - createdMs) / (24 * 60 * 60 * 1000));
if (days <= 0) return 'New today';
if (days < 7) return `${days}d old`;
if (days < 30) return `${Math.floor(days / 7)}w old`;
return `${Math.floor(days / 30)}mo old`;
};
const summarizeBody = () => { const summarizeBody = () => {
if (!body) return ''; if (!body) return '';
const trimmed = body.trim(); const trimmed = body.trim();
@ -496,21 +746,141 @@
} }
}; };
const setBusy = (busy) => { const parseToolPayload = (result) => {
primaryEl.disabled = busy; if (result && typeof result === 'object' && result.parsed && typeof result.parsed === 'object') {
moveToggleEl.disabled = busy; return result.parsed;
for (const button of moveMenuEl.querySelectorAll('button')) { }
if (button instanceof HTMLButtonElement) button.disabled = busy; const raw = typeof result?.content === 'string' ? result.content : '';
if (!raw.trim()) return null;
try {
return JSON.parse(raw);
} catch {
return null;
} }
}; };
const refreshCards = () => { 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;
const setBusy = (busy) => {
laneToggleEl.disabled = busy || !taskPath;
for (const button of moveMenuEl.querySelectorAll('button')) {
if (button instanceof HTMLButtonElement) button.disabled = busy;
}
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;
};
const closeMoveMenu = () => {
moveMenuEl.style.display = 'none'; moveMenuEl.style.display = 'none';
moveToggleEl.textContent = 'Move'; laneToggleEl.setAttribute('aria-expanded', 'false');
moveToggleEl.setAttribute('aria-expanded', 'false'); laneToggleEl.dataset.open = '0';
};
const positionMoveMenu = () => {
if (moveMenuEl.style.display !== 'flex') return;
const rect = laneToggleEl.getBoundingClientRect();
const menuRect = moveMenuEl.getBoundingClientRect();
const gutter = 12;
let left = rect.left;
let top = rect.bottom + 6;
if (left + menuRect.width > view.innerWidth - gutter) {
left = Math.max(gutter, view.innerWidth - gutter - menuRect.width);
}
if (top + menuRect.height > view.innerHeight - gutter) {
top = Math.max(gutter, rect.top - menuRect.height - 6);
}
moveMenuEl.style.left = `${Math.round(left)}px`;
moveMenuEl.style.top = `${Math.round(top)}px`;
};
const openMoveMenu = () => {
if (laneToggleEl.disabled) return;
moveMenuEl.style.display = 'flex';
moveMenuEl.style.visibility = 'hidden';
moveMenuEl.style.left = '0px';
moveMenuEl.style.top = '0px';
positionMoveMenu();
moveMenuEl.style.visibility = 'visible';
laneToggleEl.setAttribute('aria-expanded', 'true');
laneToggleEl.dataset.open = '1';
};
const refreshCards = () => {
closeMoveMenu();
window.dispatchEvent(new Event('nanobot:cards-refresh')); window.dispatchEvent(new Event('nanobot:cards-refresh'));
}; };
const closeEditor = () => {
editorOverlayEl.style.display = 'none';
};
const openEditor = (focusField = 'title') => {
if (!taskPath) 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 runTransition = async (targetLane) => { const runTransition = async (targetLane) => {
if (!taskPath) return; if (!taskPath) return;
setBusy(true); setBusy(true);
@ -549,25 +919,132 @@
} }
}; };
const runTagMutation = async (action, tagValue) => {
if (!taskPath) return;
const cleanedTag = String(tagValue || '').trim();
if (!cleanedTag) return;
setBusy(true);
setStatus('Saving', '#6e5b4d', 'rgba(255, 244, 227, 0.9)');
try {
const result = await window.__nanobotCallTool?.('task_board', {
action,
task: taskPath,
tags: [cleanedTag],
});
const payload = parseToolPayload(result);
if (payload && typeof payload === 'object' && payload.error) {
throw new Error(String(payload.error));
}
refreshCards();
} catch (error) {
console.error(`Task tag mutation failed (${action})`, error);
setBusy(false);
setStatus('Unavailable', '#8e3023', '#f3d3cc');
publishLiveContent(lane, true, String(error));
}
};
const runTaskEdit = async (changes) => {
if (!taskPath) return;
setBusy(true);
setStatus('Saving', '#6e5b4d', 'rgba(255, 244, 227, 0.9)');
try {
const result = await window.__nanobotCallTool?.('task_board', {
action: 'edit',
task: taskPath,
...changes,
});
const payload = parseToolPayload(result);
if (payload && typeof payload === 'object' && payload.error) {
throw new Error(String(payload.error));
}
closeEditor();
refreshCards();
} catch (error) {
console.error('Task edit failed', error);
setBusy(false);
setStatus('Unavailable', '#8e3023', '#f3d3cc');
publishLiveContent(lane, true, String(error));
}
};
const promptForTag = async () => {
if (!taskPath) return;
const value = window.prompt('Add tag to task', '');
if (value == null) return;
const cleaned = value.trim();
if (!cleaned) return;
await runTagMutation('add_tag', cleaned);
};
const bindTagRemoval = (button, tagValue) => {
let holdTimer = null;
let holdTriggered = false;
const clearHold = () => {
if (holdTimer !== null) {
window.clearTimeout(holdTimer);
holdTimer = null;
}
button.classList.remove('task-card__tag--holding');
};
button.addEventListener('pointerdown', (event) => {
if (!taskPath || button.disabled) return;
if (event.pointerType === 'mouse' && event.button !== 0) return;
holdTriggered = false;
button.classList.add('task-card__tag--holding');
holdTimer = window.setTimeout(async () => {
holdTimer = null;
holdTriggered = true;
button.classList.remove('task-card__tag--holding');
const confirmed = window.confirm(`Remove ${tagValue} from this task?`);
if (!confirmed) return;
await runTagMutation('remove_tag', tagValue);
}, 650);
});
for (const eventName of ['pointerup', 'pointerleave', 'pointercancel']) {
button.addEventListener(eventName, clearHold);
}
button.addEventListener('contextmenu', (event) => {
event.preventDefault();
});
button.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
if (holdTriggered) {
holdTriggered = false;
}
});
};
const renderTags = () => { const renderTags = () => {
tagsEl.innerHTML = ''; tagsEl.innerHTML = '';
if (!tags.length) { for (const tag of tags) {
tagsEl.style.display = 'none'; const chip = document.createElement('button');
return; chip.type = 'button';
}
const visibleTags = tags.slice(0, 4);
for (const tag of visibleTags) {
const chip = document.createElement('span');
chip.className = 'task-card__tag'; chip.className = 'task-card__tag';
chip.textContent = tag; chip.textContent = tag;
chip.title = `Hold to remove ${tag}`;
bindTagRemoval(chip, tag);
tagsEl.appendChild(chip); tagsEl.appendChild(chip);
} }
if (tags.length > visibleTags.length) {
const overflow = document.createElement('span'); const addTagButton = document.createElement('button');
overflow.className = 'task-card__tag'; addTagButton.type = 'button';
overflow.textContent = `+${tags.length - visibleTags.length}`; addTagButton.className = 'task-card__tag task-card__tag--action';
tagsEl.appendChild(overflow); addTagButton.textContent = '+';
} addTagButton.title = 'Add tag';
addTagButton.disabled = !taskPath;
addTagButton.addEventListener('click', async (event) => {
event.preventDefault();
event.stopPropagation();
await promptForTag();
});
tagsEl.appendChild(addTagButton);
tagsEl.style.display = 'flex'; tagsEl.style.display = 'flex';
}; };
@ -575,10 +1052,11 @@
moveMenuEl.innerHTML = ''; moveMenuEl.innerHTML = '';
const options = moveOptionsForLane(lane); const options = moveOptionsForLane(lane);
if (!options.length || !taskPath) { if (!options.length || !taskPath) {
moveToggleEl.style.display = 'none'; laneToggleEl.disabled = true;
closeMoveMenu();
return; return;
} }
moveToggleEl.style.display = 'inline-flex'; laneToggleEl.disabled = false;
for (const option of options) { for (const option of options) {
const button = document.createElement('button'); const button = document.createElement('button');
button.type = 'button'; button.type = 'button';
@ -593,56 +1071,94 @@
} }
}; };
const renderPrimaryAction = () => { laneToggleEl.addEventListener('click', (event) => {
const primary = primaryActionForLane(lane);
if (!primary || !taskPath) {
primaryEl.style.display = 'none';
primaryEl.disabled = true;
return;
}
primaryEl.textContent = primary.label;
primaryEl.style.display = 'inline-flex';
primaryEl.disabled = false;
primaryEl.onclick = (event) => {
event.preventDefault();
event.stopPropagation();
runTransition(primary.lane);
};
};
moveToggleEl.addEventListener('click', (event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
const open = moveMenuEl.style.display === 'flex'; const open = moveMenuEl.style.display === 'flex';
moveMenuEl.style.display = open ? 'none' : 'flex'; if (open) closeMoveMenu();
moveToggleEl.textContent = open ? 'Move' : 'Close'; else openMoveMenu();
moveToggleEl.setAttribute('aria-expanded', open ? 'false' : 'true'); });
doc.addEventListener('pointerdown', (event) => {
const target = event.target;
if (!(target instanceof Node)) return;
if (moveMenuEl.style.display !== 'flex') return;
if (moveMenuEl.contains(target) || laneToggleEl.contains(target)) return;
closeMoveMenu();
});
view.addEventListener('resize', closeMoveMenu);
view.addEventListener('scroll', closeMoveMenu, true);
if (moveMenuEl.parentElement !== doc.body) {
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');
});
descriptionEl.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
openEditor('description');
}); });
const render = () => { const render = () => {
applyTheme(); applyTheme();
subtitleEl.textContent = laneLabels[lane] || 'Task'; subtitleEl.textContent = laneLabels[lane] || 'Task';
summaryEl.textContent = title || '(Untitled task)'; summaryEl.textContent = title || '(Untitled task)';
summaryEl.title = taskPath ? 'Tap to edit title' : '';
const dueText = formatDue(); const dueText = formatDue();
dueEl.textContent = dueText; dueEl.textContent = dueText;
dueEl.style.display = dueText ? 'inline-flex' : 'none'; dueEl.style.display = dueText ? 'inline-flex' : 'none';
const ageText = formatAge(); setStatus('', 'var(--task-muted)', 'transparent');
ageEl.textContent = ageText;
ageEl.style.display = ageText ? 'inline-flex' : 'none';
const bodySummary = summarizeBody(); const bodySummary = summarizeBody();
descriptionEl.textContent = bodySummary; descriptionEl.textContent = bodySummary || 'Add description';
descriptionEl.style.display = bodySummary ? 'block' : 'none'; descriptionEl.title = taskPath ? 'Tap to edit description' : '';
descriptionEl.style.display = taskPath ? 'block' : 'none';
descriptionEl.classList.toggle('task-card__body--placeholder', !bodySummary);
renderTags(); renderTags();
setStatus('', '', 'transparent');
renderPrimaryAction();
renderMoveMenu(); renderMoveMenu();
moveMenuEl.style.display = 'none'; closeMoveMenu();
moveToggleEl.textContent = 'Move';
moveToggleEl.setAttribute('aria-expanded', 'false');
publishLiveContent(lane, true, ''); publishLiveContent(lane, true, '');
}; };