docs: tighten wording, drop the planning scratch files

Removes docs/superpowers, which held a design note and an implementation
plan. Those were working notes rather than anything the project needs, and
they don't belong in the repo.

Everything else here is wording: em dashes swapped for ordinary
punctuation across the README and the code comments, and a few sentences
straightened out.
This commit is contained in:
Aculix Technologies
2026-08-16 01:50:59 +05:30
parent 7e030722a9
commit 6b01e4c246
9 changed files with 22 additions and 1584 deletions
+3 -3
View File
@@ -87,7 +87,7 @@ The date, storage, rollover, task and undo logic lives in `src/lib/` as plain mo
- **Add a task**: Type in the input field and press Enter - **Add a task**: Type in the input field and press Enter
- **Complete a task**: Click the checkbox next to the task - **Complete a task**: Click the checkbox next to the task
- **Delete a task**: Hover over a task and click the delete icon, or press `Delete` with the task focused - **Delete a task**: Hover over a task and click the delete icon, or press `Delete` with the task focused
- **Undo a delete**: Press `Cmd/Ctrl+Z` — the task returns to where it was - **Undo a delete**: Press `Cmd/Ctrl+Z`. The task returns to where it was
- **Reorder tasks**: Drag with a mouse, long-press then drag on touch, or focus a task and press `Alt+↑`/`Alt+↓` - **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 - **Clear input**: Press Escape while the input is focused
@@ -95,7 +95,7 @@ The date, storage, rollover, task and undo logic lives in `src/lib/` as plain mo
- **Switch between Today and Tomorrow**: Click the date button in the header - **Switch between Today and Tomorrow**: Click the date button in the header
- **Plan ahead**: Add tasks to Tomorrow's list before you need them - **Plan ahead**: Add tasks to Tomorrow's list before you need them
- **Separate lists**: Today and Tomorrow maintain independent task lists - **Separate lists**: Today and Tomorrow maintain independent task lists
- **Carry-over**: When a new day begins, whatever you didn't finish moves into Today. Completed tasks are cleared away with the day they belonged to. This works across gaps too — if you don't open Negotium for a week, everything still outstanding is waiting for you. - **Carry-over**: When a new day begins, whatever you didn't finish moves into Today. Completed tasks are cleared away with the day they belonged to. It works across gaps too. If you don't open Negotium for a week, everything still outstanding is waiting for you.
- **While it's open**: The app notices the day change on its own, so a tab left open overnight rolls over without a reload. - **While it's open**: The app notices the day change on its own, so a tab left open overnight rolls over without a reload.
### Theme Toggle ### Theme Toggle
@@ -122,7 +122,7 @@ All data is stored locally in your browser using localStorage:
- **Privacy first**: Your data never leaves your device - **Privacy first**: Your data never leaves your device
- **Self-pruning**: Past days are removed as their unfinished tasks carry forward, so storage doesn't grow without bound - **Self-pruning**: Past days are removed as their unfinished tasks carry forward, so storage doesn't grow without bound
Earlier versions keyed tasks by a different date format (`negotium-tasks-Sat Aug 15 2026`). Those are converted automatically the first time you open this version — nothing to do, and nothing is lost. Earlier versions keyed tasks by a different date format (`negotium-tasks-Sat Aug 15 2026`). Those convert automatically the first time you open this version. Nothing to do, and nothing is lost.
## 📄 License ## 📄 License
File diff suppressed because it is too large Load Diff
@@ -1,260 +0,0 @@
# Negotium: Fix and Harden — Design
**Date:** 2026-08-15
**Status:** Approved, not yet implemented
## Context
Negotium is a minimalist Svelte to-do app scoped to exactly two days, Today and Tomorrow. Tasks are stored client-side in `localStorage`, one key per date. It ships as a static nginx image published to GHCR.
The app has not been updated in roughly ten months. An audit found that its headline feature — automatic carry-over of tasks across the day boundary — has never worked, alongside several correctness, accessibility, and payload problems.
This design covers correctness and foundations only. No new user-facing features, no changes to the two-day model, and no visual redesign.
## Findings this design addresses
### Correctness
1. **Carry-over is dead code.** `checkAndMigrateTasks()` (`src/App.svelte:118`) guards on `currentDate !== today`. `currentDate` is initialized from `new Date().toDateString()` at `src/App.svelte:14` and compared against a fresh `new Date().toDateString()` milliseconds later in `onMount`. The condition is never true.
"Tomorrow becomes Today" does work, but incidentally — tomorrow's tasks are written under tomorrow's date key, which is simply what gets read as today after midnight. What is genuinely broken is carry-over of *unfinished* work: those tasks remain under the previous day's key, are never displayed again, and are never cleaned up.
2. **No midnight rollover while open.** There is no timer or `visibilitychange` listener. A tab left open overnight keeps `selectedDate` pinned to the previous day, still labels it "Today" (`buttonText` at `src/App.svelte:153` only recomputes when `selectedDate` changes; the `new Date()` inside it is not a reactive dependency), and writes all subsequent edits into the previous day's key.
3. **Only one day back.** Even had the guard been correct, the logic inspects yesterday alone. A three-day absence would still drop work.
4. **Unbounded storage growth.** Every date key ever written persists indefinitely.
5. **Unguarded `JSON.parse`.** `src/App.svelte:108` will throw on any corrupt value and white-screen the app. `localStorage` access is also unguarded against Safari private mode and quota exhaustion.
### Accessibility
6. **Delete button invisible to keyboard users.** `.delete-btn` is `opacity: 0`, revealed only by `.task-item:hover` (`src/style.css:360`). The `:focus-visible` outline (`src/style.css:509`) renders on a zero-opacity element and is therefore also invisible.
7. **Invalid nested interactives.** The task row is `role="button"` with `tabindex="0"` (`src/App.svelte:336`) and contains two real `<button>` elements.
8. **Backspace destroys a task with no recovery.** `src/App.svelte:55`, on a focusable row.
9. **Reordering is desktop-mouse-only.** HTML5 drag events do not fire on touch, and there is no keyboard alternative.
### Payload and startup
10. **`lottie-web` is 305 KB of a 337 KB bundle** (~91%), statically imported at `src/App.svelte:5` to play one decorative empty-state animation. It ships to every visitor regardless of whether the empty state renders.
11. **The 200 KB README screenshot is served to users.** `publicDir: 'assets'` (`vite.config.mjs:6`) copies `assets/screenshot.png` into `dist/`.
12. **A 500 ms artificial loading screen** on every launch (`src/App.svelte:212`) for an app with no async work.
13. **Dark-mode flash.** `index.html:16` hardcodes the light background; `darkMode` is only read from `localStorage` after mount.
### Build and infrastructure
14. **Non-reproducible Docker builds.** `package-lock.json` is gitignored and untracked, so the `npm install` in `Dockerfile:14` resolves fresh versions on every build. Combined with the daily cron in `.github/workflows/docker-publish.yml:9`, the published `:main` image drifts silently.
15. `dist/` is not gitignored.
16. Svelte 4.2 → current 5.56; Vite 5.0 → current 8.2.
17. No tests, no linting.
## Goals
- Carry-over works, across arbitrary multi-day gaps, and is covered by tests.
- Rollover is detected while the app is open.
- Storage is bounded, resilient to corruption, and survives an unavailable `localStorage`.
- Reordering works on touch and by keyboard.
- Initial payload drops by roughly 90%.
- Dependencies are current and builds are reproducible.
## Non-goals
- The two-day model. No projects, tags, due dates, or recurrence.
- Visual redesign. Layout, palette, and type stay as they are.
- Sync, accounts, or any server component.
- PWA/offline and data export/import — deliberately deferred to a later pass.
- TypeScript.
## Approach
Extract non-visual logic from `App.svelte` into small, individually testable plain-JS modules, then fix the defects inside those modules where they can be verified. `App.svelte` is reduced to a view layer and keeps its existing markup and CSS.
The extraction is a precondition, not incidental cleanup: the defects that matter most are time-dependent, and `new Date()` is currently called inline in five places inside a component that needs a DOM to instantiate. Injecting the clock is what makes "what happens at midnight" an ordinary unit test.
Alternatives considered and rejected:
- **Surgical in-place patches.** Lowest risk, but the rollover fix would ship verified only by hand — which is how it shipped broken originally — and the dependency drift would remain.
- **Full rewrite in Svelte 5 + TypeScript with stores.** Cleanest end state, but a ground-up rebuild of a working UI carries a materially higher chance of silently altering UX, and TypeScript is heavy ceremony for an app with three data shapes.
## Module design
```
src/
lib/
dates.js date-key formatting and display labels
storage.js localStorage persistence, enumeration, pruning
rollover.js day-boundary rules
tasks.js pure task operations
App.svelte view only
main.js
style.css
```
Every function needing the current time receives it as an argument. No module below calls `new Date()` internally.
### `dates.js`
| Function | Purpose |
|---|---|
| `toKey(date)` | `Date``YYYY-MM-DD` in local time |
| `fromKey(key)` | `YYYY-MM-DD``Date` at local midnight |
| `addDays(date, n)` | Date arithmetic |
| `labelFor(dateKey, todayKey)` | `"Today"` / `"Tomorrow"` / formatted long date |
### `storage.js`
| Function | Purpose |
|---|---|
| `loadTasks(dateKey)` | Returns `[]` on missing, unparseable, or non-array values |
| `saveTasks(dateKey, tasks)` | Persists; never throws to the caller |
| `listTaskKeys()` | All `negotium-tasks-*` keys currently present |
| `removeTasks(dateKey)` | Deletes one date's entry |
| `loadTheme()` / `saveTheme(mode)` | Theme preference |
| `migrateLegacyKeys()` | One-time old-format → ISO conversion |
All access is wrapped. If `localStorage` throws on access, the module falls back to an in-memory `Map` for the session so the app still runs.
### `rollover.js`
| Function | Purpose |
|---|---|
| `rollover(now)` | Applies carry-over for all past dates; returns the resulting Today list |
| `nextMidnight(now)` | Milliseconds until the next local midnight, for scheduling |
### `tasks.js`
Pure functions over task arrays, returning new arrays: `addTask`, `toggleTask`, `deleteTask`, `reorderTask`, `clearCompleted`. Plus a bounded undo stack (last 10 operations) recording enough state to restore a deleted task at its original index.
Task shape is unchanged except that `id` becomes `crypto.randomUUID()` rather than `Date.now()`.
## Storage format and migration
Keys move from `negotium-tasks-Mon Aug 15 2026` to `negotium-tasks-2026-08-15`.
The multi-day carry-over rule requires enumerating stored dates and comparing them against today. ISO keys make that a string comparison; `toDateString()` keys would require re-parsing every key. Sortable keys keep the rollover logic simple enough to be visibly correct.
Migration runs once, before any other storage read:
1. Enumerate `negotium-tasks-*` keys.
2. For each key not already matching `YYYY-MM-DD`, parse the legacy suffix via `new Date(suffix)`.
3. On a valid parse, rewrite the value under the ISO key. If the ISO key already holds tasks, concatenate legacy first, then existing.
4. Delete the legacy key.
5. On an unparseable suffix, leave the key untouched and skip it — never destroy data that cannot be interpreted.
The live deployment at `negotium.aculix.org` holds real data in the legacy format. This path is written and tested before anything else.
## Rollover behaviour
On rollover, for every stored date key strictly earlier than today:
1. Unfinished tasks are collected, oldest date first, preserving within-day order.
2. They are placed **above** any tasks already present in Today.
3. Completed tasks from those dates are discarded.
4. The old date key is deleted.
Pruning is therefore a side effect of carry-over; there is no separate retention policy.
Future-dated keys (Tomorrow) are never touched.
Rollover is triggered from four places:
- On mount.
- On `visibilitychange` when the document becomes visible.
- On window `focus`.
- From a `setTimeout` scheduled to the next local midnight, which reschedules itself on fire.
The timer covers a pinned tab crossing midnight unattended. `visibilitychange` and `focus` cover machine sleep, where timers do not fire reliably.
## Error handling
| Condition | Behaviour |
|---|---|
| Corrupt JSON in a task entry | Treated as empty; app continues |
| Value present but not an array | Treated as empty |
| `localStorage` throws on access | In-memory fallback for the session |
| Quota exceeded on write | Write dropped; in-memory state stays authoritative |
| Legacy key with unparseable date | Left in place, skipped |
Nothing in this table is permitted to prevent the app from starting.
## Testing
Vitest against `src/lib/`. No component tests in this pass — that requires jsdom and testing-library, and by then all consequential logic lives in `lib/`.
**Migration:** legacy → ISO conversion; collision with an existing ISO key merges in the correct order; unparseable suffix is left alone; migration is idempotent across repeated runs.
**Rollover:** same day is a no-op; one-day gap; multi-day gap ordering; completed tasks dropped; within-day order preserved; carried tasks precede existing Today tasks; future keys untouched; empty prior days; DST spring-forward and fall-back boundaries; `nextMidnight` correctness across a DST change.
**Storage:** corrupt JSON returns `[]`; non-array returns `[]`; throwing `localStorage` falls back to memory.
**Tasks:** whitespace-only input rejected; text trimmed; toggle; delete; reorder to first, last, and middle positions; `clearCompleted`; undo restores a deleted task at its original index; undo stack is bounded at 10.
## UX changes
Three deliberate deviations, each approved:
1. **Backspace no longer deletes a focused task.** `Delete` still does, as documented. Backspace is the accidental trigger — pressed meaning "go back", or while typing — and it currently destroys a task irrecoverably.
2. **Undo is `Cmd/Ctrl+Z`, with no new on-screen UI.** A toast would be more discoverable but introduces a UI element to an app that has none. Documented in the README instead.
3. **The 500 ms splash screen is removed.** It fronts no async work.
Everything else — the two-day toggle, the input, the checkbox and delete affordances, the stats line, the empty state, the palette, spacing, and type — is unchanged.
## Accessibility changes
- Task row loses `role="button"` and `tabindex="0"`; it becomes a plain container. The checkbox button carries the accessible name.
- Delete button revealed on `:focus-within` as well as `:hover`.
- `Alt+↑` / `Alt+↓` reorders a task when its row has focus.
## Reordering rewrite
HTML5 drag-and-drop is replaced with Pointer Events, which unifies mouse, touch, and pen through one code path. Roughly 80 lines, no new dependency. `event.dataTransfer.setData('text/html', event.target)` at `src/App.svelte:63` — which passes a DOM node where a string is required — disappears with it.
Keyboard reordering via `Alt+Arrow` is included, so reordering is no longer pointer-exclusive.
## Payload and build
- **Lottie removed**, empty-state animation redrawn as an inline animated SVG in the existing accent colour. Expected bundle: 337 KB → ~30 KB.
- **Theme applied pre-paint** by an inline script in `<head>` that reads the stored preference and sets a class on `<html>` before first paint.
- **`publicDir`** switched to a dedicated `public/` holding only runtime assets. `screenshot.png` moves to `docs/` and is referenced from the README by its new path.
- **`.gitignore`**: add `dist/`, remove `/package-lock.json`, change `/.DS_Store` to `.DS_Store` so nested copies are covered.
- **`package-lock.json` committed**; `Dockerfile` switched from `npm install` to `npm ci`.
- **Vite 5 → 8**, **Svelte 4 → 5**. The Svelte upgrade rewrites reactive declarations (`$:``$derived` / `$effect`) and the mount API at `src/main.js:3`. Mechanical at this size, but it touches nearly every reactive line.
## Sequencing
1. Tooling: Vitest, `.gitignore`, commit the lockfile, `npm ci` in the Dockerfile.
2. `dates.js` and `storage.js` with the legacy migration, fully tested, before any behaviour changes.
3. `rollover.js` with the full carry-over test matrix.
4. `tasks.js` including undo.
5. Rewire `App.svelte` to the modules, still on Svelte 4, verifying no visible change.
6. Svelte 4 → 5 and Vite 5 → 8.
7. Drop Lottie; inline SVG empty state; remove the splash; pre-paint theme.
8. Accessibility fixes.
9. Pointer Events reordering plus `Alt+Arrow`.
10. README corrections: the carry-over rule as actually implemented, revised keyboard shortcuts, the new storage key format.
Steps 24 are independently verifiable before anything user-visible moves. Step 5 is the riskiest single step and should be reviewed against the running app.
## Risks
| Risk | Mitigation |
|---|---|
| Migration loses real user data | Written and tested first; unparseable keys are never deleted; merge order defined |
| Svelte 5 upgrade alters behaviour subtly | Sequenced after the rewire, so `lib/` tests are already green and isolate the cause |
| Redrawn empty-state animation is not liked | Self-contained and easily iterated; the lazy dynamic-import route remains available as a fallback |
| Pointer Events regress desktop dragging | Largest and most isolated step, sequenced last so it can be dropped without affecting the rest |
## Deferred
- PWA manifest, service worker, offline support, installability.
- Data export/import for backup and portability.
- Component-level tests.
- Linting and formatting configuration.
These were considered and consciously postponed. PWA and export/import are the natural next pass — for a local-first, privacy-oriented app they are the largest genuine capability gaps.
+10 -10
View File
@@ -17,7 +17,7 @@
// Storage is synchronous, so the first render can already have the real list. // Storage is synchronous, so the first render can already have the real list.
// Loading in onMount instead meant one frame of the empty state on every // Loading in onMount instead meant one frame of the empty state on every
// launch — previously hidden behind the splash screen, and very visible once // launch. That was hidden behind the splash screen before, and very visible once
// that went away. // that went away.
storage.migrateLegacyKeys(); storage.migrateLegacyKeys();
const bootNow = new Date(); const bootNow = new Date();
@@ -38,7 +38,7 @@
/** Single write path, so persistence cannot drift out of step with the list. /** Single write path, so persistence cannot drift out of step with the list.
* Loading a different day assigns `tasks` directly and deliberately skips * Loading a different day assigns `tasks` directly and deliberately skips
* this there is nothing new to save. */ * this, because there is nothing new to save. */
function setTasks(next) { function setTasks(next) {
tasks = next; tasks = next;
if (isInitialized) storage.saveTasks(selectedKey, tasks); if (isInitialized) storage.saveTasks(selectedKey, tasks);
@@ -116,7 +116,7 @@
} }
// Reordering runs on Pointer Events rather than HTML5 drag-and-drop, which // Reordering runs on Pointer Events rather than HTML5 drag-and-drop, which
// never fired on touch at all so a documented feature was desktop-only. // never fired on touch at all, so a documented feature was desktop-only.
// //
// Touch and mouse need different entry conditions. A mouse drag begins once // Touch and mouse need different entry conditions. A mouse drag begins once
// the pointer has moved past a small threshold. A touch drag cannot, because // the pointer has moved past a small threshold. A touch drag cannot, because
@@ -181,9 +181,9 @@
* Inline styling for the one row the pointer is carrying. * Inline styling for the one row the pointer is carrying.
* *
* Only the lifted card is styled here. Everything else is moved by * Only the lifted card is styled here. Everything else is moved by
* animate:flip, so exactly one mechanism owns `transform` per element * animate:flip, so one mechanism owns `transform` per element. When both
* when both did, flip measured a "before" rect that already included a * did, flip measured a "before" rect that already had a manual offset in
* manual offset, computed a bogus delta, and slid the whole list on drop. * it, computed a bogus delta, and slid the whole list on drop.
* *
* The lifted card takes no transition, so it stays welded to the pointer. * The lifted card takes no transition, so it stays welded to the pointer.
* On release it keeps its transform but gains one via the settling class, * On release it keeps its transform but gains one via the settling class,
@@ -245,7 +245,7 @@
// Reorder as the pointer crosses each boundary rather than waiting for the // Reorder as the pointer crosses each boundary rather than waiting for the
// drop. animate:flip then eases the displaced card across, one swap at a // drop. animate:flip then eases the displaced card across, one swap at a
// time, and by release the list is already in its final order so letting // time. By release the list is already in its final order, so letting
// go changes nothing but the lifted card settling into place. // go changes nothing but the lifted card settling into place.
const from = tasks.findIndex(task => task.id === drag.id); const from = tasks.findIndex(task => task.id === drag.id);
const to = targetIndexFor(event.clientY); const to = targetIndexFor(event.clientY);
@@ -268,8 +268,8 @@
const landedAt = tasks.findIndex(task => task.id === drag.id); const landedAt = tasks.findIndex(task => task.id === drag.id);
const settling = drag.id; const settling = drag.id;
// The order is already correct — it was applied swap by swap during the // The order is already correct, applied swap by swap during the
// drag so this only writes it through to storage. // drag, so this only writes it through to storage.
if (wasActive && landedAt !== drag.originIndex) setTasks(tasks); if (wasActive && landedAt !== drag.originIndex) setTasks(tasks);
endDrag(); endDrag();
@@ -311,7 +311,7 @@
/** /**
* Re-evaluates the day boundary and reloads the visible list. Safe to call * Re-evaluates the day boundary and reloads the visible list. Safe to call
* repeatedly rollover is idempotent within a day. * repeatedly, since rollover is idempotent within a day.
* *
* If the day changed while the user was looking at Today, they stay on the * If the day changed while the user was looking at Today, they stay on the
* new Today. If they were looking at Tomorrow, that key has become Today and * new Today. If they were looking at Tomorrow, that key has become Today and
+2 -2
View File
@@ -7,7 +7,7 @@ import { toKey } from './dates.js'
* today (oldest day first, within-day order preserved, ahead of anything * today (oldest day first, within-day order preserved, ahead of anything
* already in today), completed tasks are discarded, and the old key is * already in today), completed tasks are discarded, and the old key is
* deleted. Pruning is therefore a side effect of carrying, so there is no * deleted. Pruning is therefore a side effect of carrying, so there is no
* separate retention policy. Future keys Tomorrow are never touched. * separate retention policy. Future keys, meaning Tomorrow, are never touched.
* *
* `now` is a parameter rather than a `new Date()` call so the boundary is * `now` is a parameter rather than a `new Date()` call so the boundary is
* testable. This replaces `checkAndMigrateTasks`, whose guard compared a * testable. This replaces `checkAndMigrateTasks`, whose guard compared a
@@ -17,7 +17,7 @@ export function rollover(storage, now) {
const todayKey = toKey(now) const todayKey = toKey(now)
// listTaskKeys yields only well-formed ISO keys, which is what makes this // listTaskKeys yields only well-formed ISO keys, which is what makes this
// string comparison safe and is why the key format changed. // string comparison safe, and is why the key format changed.
const pastKeys = storage const pastKeys = storage
.listTaskKeys() .listTaskKeys()
.filter((key) => key < todayKey) .filter((key) => key < todayKey)
+2 -2
View File
@@ -4,10 +4,10 @@ const TEXT_FIELDS = new Set(['INPUT', 'TEXTAREA'])
* Whether a keydown should trigger task undo. * Whether a keydown should trigger task undo.
* *
* The subtlety is the text field. Deferring to a focused field's own undo * The subtlety is the text field. Deferring to a focused field's own undo
* sounds right, but the add-task input is where focus normally sits — you * sounds right, but the add-task input is where focus normally sits. You
* click it to add a task and focus stays there, and on macOS clicking a * click it to add a task and focus stays there, and on macOS clicking a
* button does not move focus. Guarding on focus alone therefore disables * button does not move focus. Guarding on focus alone therefore disables
* undo in precisely the situation it is needed: right after deleting a task. * undo in the one situation it is needed: right after deleting a task.
* *
* So it defers only when the field actually holds text worth undoing. An * So it defers only when the field actually holds text worth undoing. An
* empty input has nothing for the browser to restore, and task undo wins. * empty input has nothing for the browser to restore, and task undo wins.
+1 -1
View File
@@ -1,5 +1,5 @@
/** crypto.randomUUID requires a secure context, and Negotium over plain HTTP /** crypto.randomUUID requires a secure context, and Negotium over plain HTTP
* on a LAN is a real self-hosted deployment shape hence the fallback. */ * on a LAN is a real deployment shape for this app, hence the fallback. */
function newId() { function newId() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID() return crypto.randomUUID()
+1 -1
View File
@@ -24,7 +24,7 @@ export function createUndoStack(limit = 10) {
* Reverses one entry against the current task list. * Reverses one entry against the current task list.
* *
* Indices are clamped because the list may have changed since the entry was * Indices are clamped because the list may have changed since the entry was
* recorded — a task deleted from position 5 can be restored into a list that * recorded. A task deleted from position 5 can be restored into a list that
* has since shrunk to two items, and landing at the end beats throwing. * has since shrunk to two items, and landing at the end beats throwing.
* *
* `clearCompleted` entries must record `removed` in ascending index order, so * `clearCompleted` entries must record `removed` in ascending index order, so
+3 -3
View File
@@ -274,7 +274,7 @@ body {
cursor: grabbing; cursor: grabbing;
} }
/* The lifted card. It stays fully opaque and sits above the list — the old /* The lifted card. It stays fully opaque and sits above the list. The old
ghosted 0.4 opacity read as "this card is disabled" rather than "you are ghosted 0.4 opacity read as "this card is disabled" rather than "you are
holding this card". The gap that opens beneath it is the drop indicator. */ holding this card". The gap that opens beneath it is the drop indicator. */
.task-item.dragging { .task-item.dragging {
@@ -390,7 +390,7 @@ html.dark .task-item.dragging {
} }
/* focus-within matters as much as hover here: the button is opacity 0 by /* focus-within matters as much as hover here: the button is opacity 0 by
default, so a keyboard user tabbing to it previously saw nothing at all default, so a keyboard user tabbing to it previously saw nothing at all:
the focus outline was drawn on an invisible element. */ the focus outline was drawn on an invisible element. */
.task-item:hover .delete-btn, .task-item:hover .delete-btn,
.task-item:focus-within .delete-btn { .task-item:focus-within .delete-btn {
@@ -458,7 +458,7 @@ html.dark .task-item.dragging {
50% { opacity: 0.6; transform: translateY(-6px); } 50% { opacity: 0.6; transform: translateY(-6px); }
} }
/* Decorative only respect a reduced-motion preference. */ /* Decorative only, so respect a reduced-motion preference. */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.empty-art-box, .empty-art-box,
.empty-art-motes path { .empty-art-motes path {