6 Commits

Author SHA1 Message Date
Aculix Technologies cd3c44fcdc chore: 2.2.2 2026-08-16 18:58:42 +05:30
Aculix Technologies c17dad6f1f fix: give editing its own button and hand the text back to dragging
Making the task text clickable turned it into a button, and the drag
handler skips anything inside a button. That quietly cost the row its
best drag handle: press and move across the text and nothing picked the
card up, so you got a text selection instead. Verified before changing
anything, dragging from the text did nothing while dragging from the row's
padding worked.

Editing moves to a pencil next to the arrow and the bin, and the text goes
back to being text. Rows also stop being selectable, since the row is a
drag handle and highlighting words across one is never what you meant. The
edit field keeps selection so text can still be copied out of it.

A third button costs width on a phone, where all three stay visible
because there is no hover. Grouping the actions so their gaps stop
multiplying across the row, and drawing them at 26px, keeps most of it
back: 199px of text becomes 187, 24 characters a line becomes 23. The
44px tap targets come from the overlay underneath, so a smaller drawn
button does not shrink them.

Also drops the click-after-drag guard, which only existed because the text
was clickable.
2026-08-16 18:57:59 +05:30
Aculix Technologies e54338f815 chore: 2.2.1 2026-08-16 18:51:21 +05:30
Aculix Technologies 7f1a02ee41 fix: moving a task between days is no longer silent
Deferring was the one action that changed your list and said nothing about
it. The task disappeared, no message, and Cmd/Ctrl+Z would not bring it
back because the undo stack only recorded deletes and clear-completed.

That was worse than it sounds. The app had just taught people, via the bar
that appears when something is deleted, that things which vanish come with
a way back. Deferring quietly broke that promise, and anyone who misread
the arrow would reasonably believe they had destroyed the task.

It now uses the same bar, saying which way the task went, and undo sends
it back to the row it left rather than the bottom of the list. That needed
an insert position on moveTaskToDay, which is tested along with the round
trip, clamping and the default append.

Undo for a move is handled separately from applyUndo, which edits a single
day's array and cannot express something that spans two.
2026-08-16 15:01:21 +05:30
Aculix Technologies 54958d0f42 chore: 2.2.0 2026-08-16 14:54:39 +05:30
Aculix Technologies 6071dccec4 fix: recentre task rows, and give the phone layout its space back
Rows stopped being vertically centred. Aligning them to flex-start so the
controls stay on the first line of a wrapped task also let the 32px row
buttons set the row height, which left the 24px checkbox and text sitting
4px above centre on every single-line row. Everything on the first line
now occupies the same 32px band, so a single-line row centres and a
wrapped one still puts its controls on the first line. A padding shorthand
later in the same rule was quietly resetting the longhand that does it.

The phone layout was spending 372px of an 812px screen, 46% of it, before
the first task appeared. The header stacked into two rows because the
wordmark and the controls could not share one: they need 422px and there
are 343. The wordmark goes below 480px, since the logo carries the
identity and an installed app already has its name under the icon, and it
stays in the accessibility tree so the page keeps its h1. The heading and
date share a line again rather than stacking unconditionally. That is 372
down to 261, 46% down to 32%.

That left the rows themselves too tight, because both actions stay visible
where there is no hover and were taking 44% of the row width, cutting the
text to 21 characters a line. Tighter gaps and padding, and a smaller
drawn button whose 44px target comes from the overlay underneath rather
than its own box, bring that back to 24.
2026-08-16 12:12:03 +05:30
7 changed files with 192 additions and 52 deletions
+4 -4
View File
@@ -14,7 +14,7 @@ Built with Svelte for speed and simplicity. No overwhelming features, no endless
### Core Functionality
-**Add, complete, and delete tasks** with smooth animations
- ↩️ **Undo** - Deleted something by mistake? An Undo button appears, and `Cmd/Ctrl+Z` works too. The task returns to its original position
- ↩️ **Undo** - Deleted or moved something by mistake? An Undo button appears, and `Cmd/Ctrl+Z` works too. The task returns to its original position
- 📅 **Today & Tomorrow lists** - Plan ahead with separate task lists
- 🔄 **Unfinished work carries over** - When a new day begins, tasks you didn't finish move to Today; completed ones are cleared away
- ✏️ **Edit a task** - Fix a typo without deleting and retyping it
@@ -90,9 +90,9 @@ The date, storage, rollover, task and undo logic lives in `src/lib/` as plain mo
### Managing Tasks
- **Add a task**: Type in the input field and press Enter
- **Complete a task**: Click the checkbox next to the task
- **Edit a task**: Click its text. Enter saves, Escape cancels, and clicking away saves too
- **Edit a task**: Click the pencil on the task. Enter saves, Escape cancels, and clicking away saves too
- **Delete a task**: Click the delete icon on the task (it shows on hover, and is always visible on touch), or press `Delete` with the task focused
- **Undo a delete**: An Undo button appears for a few seconds after anything is removed. `Cmd/Ctrl+Z` does the same. Either way the task returns to where it was
- **Undo**: An Undo button appears for a few seconds after a task is deleted, cleared or moved to another day. `Cmd/Ctrl+Z` does the same. Either way the task returns to where it was
- **Move a task to Tomorrow**: Click the arrow on the task, or press `Alt+→`. From Tomorrow, the arrow points back and `Alt+←` returns it to Today
- **Reorder tasks**: Drag with a mouse, long-press then drag on touch, or focus a task and press `Alt+↑`/`Alt+↓`
- **Clear input**: Press Escape while the input is focused
@@ -130,7 +130,7 @@ Import only ever adds. Tasks already present are left alone, so importing the sa
- **Escape**: Clear input field (when input is focused)
- **Space/Enter**: Toggle task completion (when a task's checkbox is focused)
- **Delete**: Delete the focused task
- **Cmd/Ctrl+Z**: Undo the last delete or clear-completed
- **Cmd/Ctrl+Z**: Undo the last delete, clear-completed, or move between days
- **Alt+↑ / Alt+↓**: Move the focused task up or down
- **Alt+→ / Alt+←**: Send the focused task to Tomorrow, or bring it back to Today
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "negotium-todo",
"version": "2.1.1",
"version": "2.2.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "negotium-todo",
"version": "2.1.1",
"version": "2.2.2",
"license": "MIT",
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "negotium-todo",
"version": "2.1.1",
"version": "2.2.2",
"description": "A clean, minimal to-do list application with smooth animations and dark/light mode support",
"type": "module",
"main": "index.html",
+42 -14
View File
@@ -94,12 +94,9 @@
let editingId = $state(null);
let editingText = $state('');
// A drag ends with a click event, which would otherwise drop the row you
// just moved straight into edit mode.
let suppressClick = false;
function startEditing(task) {
if (suppressClick || draggedId) return;
if (draggedId) return;
editingId = task.id;
editingText = task.text;
}
@@ -139,8 +136,21 @@
* which day you are looking at, so one control covers both. */
function deferTask(taskId) {
cancelEdit();
const destination = viewingToday ? tomorrowKey() : todayKey;
tasks = moveTaskToDay(storage, selectedKey, destination, taskId);
const index = tasks.findIndex(task => task.id === taskId);
if (index === -1) return;
const movingToTomorrow = viewingToday;
const from = selectedKey;
const to = movingToTomorrow ? tomorrowKey() : todayKey;
tasks = moveTaskToDay(storage, from, to, taskId);
// A task vanishing from the list with nothing said about it reads as a
// delete, especially right after the app has taught you that deletes come
// with a way back. It gets the same treatment.
undoStack.push({ type: 'move', id: taskId, from, to, index });
showUndo(movingToTomorrow ? 'Moved to Tomorrow' : 'Moved to Today');
}
/**
@@ -170,8 +180,19 @@
function undo() {
const entry = undoStack.pop();
if (entry) setTasks(applyUndo(tasks, entry));
dismissUndo();
if (!entry) return;
// A move spans two days, so it cannot be expressed as an edit to the one
// list applyUndo works on. It is sent back to the row it came from, which
// is why the index was recorded.
if (entry.type === 'move') {
moveTaskToDay(storage, entry.to, entry.from, entry.id, entry.index);
tasks = storage.loadTasks(selectedKey);
return;
}
setTasks(applyUndo(tasks, entry));
}
function toggleTheme() {
@@ -454,10 +475,6 @@
if (!wasActive) return;
// Swallow the click the browser fires after this drag.
suppressClick = true;
setTimeout(() => { suppressClick = false; }, 0);
// Clearing the inline transform while the settling class supplies a
// transition eases the card into its slot. Nothing else on the list moves,
// because nothing else changed.
@@ -745,11 +762,21 @@
aria-label="Edit {task.text}"
/>
{:else}
<button class="task-text" onclick={() => startEditing(task)} title="Click to edit">
{task.text}
</button>
<span class="task-text">{task.text}</span>
{/if}
<div class="row-actions">
<button
class="row-btn edit-btn"
onclick={() => startEditing(task)}
title="Edit"
aria-label="Edit {task.text}"
>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<button
class="row-btn defer-btn"
onclick={() => deferTask(task.id)}
@@ -781,6 +808,7 @@
<line x1="14" y1="11" x2="14" y2="17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
</li>
{/each}
</ul>
+12 -2
View File
@@ -9,8 +9,12 @@
* A task already sitting at the destination is not duplicated. It still leaves
* the source day, which is what someone dragging a stray copy around would
* expect.
*
* `insertAt` places the task at a position rather than at the end. Undoing a
* move needs it: a task sent to Tomorrow should come back to the row it left,
* not to the bottom of the list.
*/
export function moveTaskToDay(storage, fromKey, toKey, taskId) {
export function moveTaskToDay(storage, fromKey, toKey, taskId, insertAt = null) {
const source = storage.loadTasks(fromKey)
if (fromKey === toKey) return source
@@ -22,7 +26,13 @@ export function moveTaskToDay(storage, fromKey, toKey, taskId) {
const alreadyThere = destination.some(task => task.id === taskId)
storage.saveTasks(fromKey, remaining)
if (!alreadyThere) storage.saveTasks(toKey, [...destination, moving])
if (!alreadyThere) {
const next = [...destination]
const at = insertAt === null ? next.length : Math.max(0, Math.min(insertAt, next.length))
next.splice(at, 0, moving)
storage.saveTasks(toKey, next)
}
return remaining
}
+36
View File
@@ -81,3 +81,39 @@ describe('moveTaskToDay', () => {
expect(storage.loadTasks(TODAY)).toEqual([])
})
})
describe('moveTaskToDay with an insert position', () => {
it('inserts at the given index rather than appending', () => {
const storage = setup({ [TOMORROW]: [task('x')], [TODAY]: [task('a'), task('b'), task('c')] })
moveTaskToDay(storage, TOMORROW, TODAY, 'x', 1)
expect(storage.loadTasks(TODAY).map(t => t.id)).toEqual(['a', 'x', 'b', 'c'])
})
it('puts a task back at the front', () => {
const storage = setup({ [TOMORROW]: [task('x')], [TODAY]: [task('a')] })
moveTaskToDay(storage, TOMORROW, TODAY, 'x', 0)
expect(storage.loadTasks(TODAY).map(t => t.id)).toEqual(['x', 'a'])
})
it('clamps an index past the end', () => {
const storage = setup({ [TOMORROW]: [task('x')], [TODAY]: [task('a')] })
moveTaskToDay(storage, TOMORROW, TODAY, 'x', 99)
expect(storage.loadTasks(TODAY).map(t => t.id)).toEqual(['a', 'x'])
})
it('still appends when no index is given', () => {
const storage = setup({ [TOMORROW]: [task('x')], [TODAY]: [task('a')] })
moveTaskToDay(storage, TOMORROW, TODAY, 'x')
expect(storage.loadTasks(TODAY).map(t => t.id)).toEqual(['a', 'x'])
})
it('round-trips a defer back to where it started', () => {
const storage = setup({ [TODAY]: [task('a'), task('b'), task('c')] })
moveTaskToDay(storage, TODAY, TOMORROW, 'b')
expect(storage.loadTasks(TODAY).map(t => t.id)).toEqual(['a', 'c'])
moveTaskToDay(storage, TOMORROW, TODAY, 'b', 1)
expect(storage.loadTasks(TODAY).map(t => t.id)).toEqual(['a', 'b', 'c'])
expect(storage.loadTasks(TOMORROW)).toEqual([])
})
})
+93 -27
View File
@@ -280,6 +280,9 @@ html.dark .day-option.selected {
.task-item {
display: flex;
/* The row is a drag handle, so pressing and moving across it should carry
the card rather than highlight words. */
user-select: none;
align-items: flex-start;
gap: 16px;
padding: 16px 20px;
@@ -345,6 +348,8 @@ html.dark .task-item.dragging {
.checkbox {
width: 24px;
height: 24px;
/* Pads the 24px box out to the 32px first-line band set by the row buttons. */
margin-block: 4px;
border: 2px solid var(--border);
border-radius: 6px;
background: transparent;
@@ -383,12 +388,9 @@ html.dark .task-item.dragging {
.task-text {
flex: 1;
min-width: 0;
text-align: left;
background: transparent;
border: none;
padding: 0;
font-family: inherit;
cursor: text;
/* Pads the 24px line box out to the 32px first-line band the row buttons
set, so the text centres against the checkbox. */
padding-block: 4px;
font-size: 16px;
/* 24px line box, the same height as the checkbox, so the two align on the
first line without nudging either. */
@@ -408,6 +410,8 @@ html.dark .task-item.dragging {
.task-edit {
flex: 1;
min-width: 0;
/* The one place text stays selectable, so it can still be copied out. */
user-select: text;
font-family: inherit;
font-size: 16px;
line-height: 1.5;
@@ -420,6 +424,15 @@ html.dark .task-item.dragging {
outline: none;
}
/* One group rather than three loose children, so the gaps between the actions
do not multiply across the row on a narrow screen. */
.row-actions {
display: flex;
align-items: flex-start;
gap: 2px;
flex-shrink: 0;
}
.row-btn {
width: 32px;
height: 32px;
@@ -437,6 +450,11 @@ html.dark .task-item.dragging {
flex-shrink: 0;
}
.edit-btn:hover {
background-color: var(--hover);
color: var(--accent);
}
.defer-btn:hover {
background-color: var(--hover);
color: var(--accent);
@@ -608,6 +626,18 @@ html.dark .data-status.error {
}
@media (pointer: coarse) {
/* Smaller drawn box, same 44px target from the overlay below, and the
margin keeps it in the 32px first-line band. */
.row-btn {
width: 26px;
height: 26px;
margin-block: 3px;
}
.row-actions {
gap: 0;
}
.checkbox::after,
.row-btn::after {
content: '';
@@ -639,7 +669,7 @@ html.dark .data-status.error {
itself, so it stays aligned with the checkbox on the first line. */
.task-text {
padding-block: 10px;
margin-block: -10px;
margin-block: -6px;
}
.clear-completed,
@@ -664,37 +694,59 @@ html.dark .data-status.error {
@media (max-width: 768px) {
.header {
padding: calc(16px + env(safe-area-inset-top)) 24px 16px;
padding: calc(12px + env(safe-area-inset-top)) 20px 12px;
}
/* One row rather than two. Stacking cost a whole row of height on the
screen with the least of it to spare. */
.header-content {
flex-direction: column;
gap: 16px;
align-items: flex-start;
flex-direction: row;
align-items: center;
gap: 12px;
}
.logo-section {
gap: 10px;
min-width: 0;
}
.header-actions {
width: 100%;
justify-content: space-between;
}
.container {
padding: 0 24px;
}
.content-header {
flex-direction: column;
align-items: flex-start;
margin-left: auto;
gap: 8px;
}
.section-title {
font-size: 28px;
.main {
padding-top: 24px;
}
.container {
padding: 0 20px;
}
/* Wraps rather than stacking unconditionally, so the date only drops to
its own line when it genuinely cannot share one. */
.content-header {
flex-direction: row;
flex-wrap: wrap;
align-items: baseline;
gap: 2px 12px;
margin-bottom: 20px;
}
.section-title {
font-size: 22px;
}
.task-input {
padding: 16px 18px;
}
/* Both row actions stay visible on touch because there is no hover to
reveal them, and at this width they were eating 44% of the row. Tighter
gaps and padding hand that back to the text. */
.task-item {
padding: 20px;
min-height: 48px;
padding: 14px 16px;
gap: 10px;
}
.task-input {
@@ -708,6 +760,21 @@ html.dark .data-status.error {
padding: calc(12px + env(safe-area-inset-top)) 16px 12px;
}
/* The wordmark is what stops the header fitting on one line here: it needs
422px of row and there are 343. The logo carries the identity, and the
installed app already has the name under its icon. Kept in the
accessibility tree so the page still has its h1. */
.app-title {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
}
.container {
padding: 0 16px;
}
@@ -726,7 +793,6 @@ html.dark .data-status.error {
longer exists, while five newer controls had no focus ring at all. */
.checkbox:focus-visible,
.row-btn:focus-visible,
.task-text:focus-visible,
.task-edit:focus-visible,
.theme-toggle:focus-visible,
.day-option:focus-visible,