The row the test writes survives the backup/restore cycle (plugin
tables aren't in the export), so on the second run the GET returned
1 and the "starts at 0" assertion blew up. Explicitly reset the
flag at the start, then flip, then reset again — each run lands
the DB in the same state it started.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Caret and "chat" label were two hit targets with two hover states and
tooltips. Collapse them into a single <button id="chat-header-nav">
with one tooltip ("back to conversations") — the caret becomes a
visibility-only hint inside the button, shown when there's somewhere
to go back to, hidden on the list itself. The .navigable class
controls whether the button reads as interactive, so clicking the
label while already on the list is a cursor-default no-op.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Tests that opened the card modal left hyprcrd-editor-draft:* entries
in localStorage. The pre-run purge cleared them before the baseline
backup, but anything the tests wrote during the run stayed behind —
the user's next card open then fired "unsaved draft from 0 min ago,
restore?" for a non-edit they didn't make. Repeat the purge after
restore so the runner closes with a clean slate.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Was pulling CSRF from a meta tag that doesn't exist; the POST landed
without a valid token and the server dropped it before the plugin
handler could reply. Use the imported csrf() helper like the rest of
the suite does.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extends chat.test.js to cover the new conversation shape (kind/key
fields), the /api/chat/rooms endpoint, the thread-settings
round-trip, and the group-creation pick-view toggle. Room-info view
DOM is structurally checked. Bumps to 1.9.4.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Covers the flyout lifecycle (footer button, toggle, header-back
visibility tracking thread/list view), the pick-view detour, DOM
structural invariants (peer strip inside thread view, form/input/send
present) and the three read-only chat endpoints. Send/receive requires
a real relay and is intentionally skipped.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Tests assume a normal board — no super-view, no bucket filter, manual
sort, no hides. When the runner fired with the app left in super-view
or with filters on, selectors like `.card[data-id]` missed the
`.superview-item` DOM and multi-select / due-date assertions read the
wrong initial values.
Snapshot the relevant bits of window.state before the run, force a
clean baseline, then restore the user's view afterwards so opening the
runner doesn't silently wipe their context.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
caldav.js was carrying the core sync timer, autosync toggle, relay
settings and every poller hook alongside its actual CalDAV/calendar
config concerns. Split out:
- New sync.js owns the single background timer, header Sync button,
autosync pause toggle, configurable interval, and emits `sync.tick`
so plugins can fan out without their own setIntervals.
- collab-buckets + chat now subscribe to sync.tick instead of running
their own pollers, so the manual Sync button refreshes them too and
the Autosync pause actually pauses everything.
- Replace the number input for sync interval with a stepped slider
(5s..1h snap points) and a live label, bumped to 1.9.2.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Collab buckets sync attachments alongside cards (images/PDFs/audio).
New /api/collab-attachments endpoint on relay, encrypted at rest,
bucket-scoped for members.
- CalDAV multi-day events via **Bis:** body field. Calendar renders
event on every day in range with day counter (1/6, 2/6, ...).
- Calendar preserves month across close/reopen (only jumps to today
on first open).
- Calendar title: centered nav group with [MM-DD] Mon Y [KW-KW] —
dimmed brackets, fixed width so nothing wiggles.
- Editor: scratchpad uses custom confirm, autobackup-draft dialog
replaced with custom styled dialog.
- Scanlines theme feature removed (performance issue).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Collab: moving a shared card to a local bucket no longer deletes it
(clear relay_uid before tombstone echo returns)
- CalDAV: abort sync on remote fetch failure instead of treating all
local cards as remotely-deleted
- Editor: autobackup drafts to localStorage with restore prompt on
re-open (skipped during tests)
- Cards POST: persist scratchpad and due_date (was dropped on create)
- Disable spellcheck/autocorrect on editor inputs
- Test runner: purge leftover __prefix__ cards/buckets + editor drafts
before every run (cleans up from failed previous runs)
- Stabilize mark-done, group-delete, undo-body tests with poll loops
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Scratchpad: card-scoped private notes in editor (toggle vs preview),
smaller font, cleared via button, stored in cards.scratchpad
- Zen mode: ESC exits zen before closing detail, Z toggles zen
- Chat: always show absolute time (hh:mm, yesterday, or date+time)
- Backspace/Delete also deletes focused card (not just selected)
- Selected + focused cards use brand accent (not accent2)
- Tooltip: guard against non-element targets
- Collab sync: skip poll while editor is open, 2s push debounce
- Rrule test: only check geburtstag tag, not title format
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Terminal: in-app command line with 22 commands (Shift+T toggle).
Navigation, CRUD, search, view toggles, tab completion, history.
Card history: card_history table tracks field changes on every PATCH.
GET /api/cards/{id}/history endpoint. Terminal `history` command.
bump.sh: standalone version bump for config.php, electron/package.json,
website footer. dist.sh no longer bumps.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Clicking the super-bucket tab now activates the last-shown child
directly. Only the caret arrow on the right opens the dropdown popover.
Caret has its own hit area with accent hover highlight.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Gitea plugin: default_repo field per account (stored in DB, pre-fills
create issue form but stays editable). Editor toolbar: asset picker
flyout with 4-column thumbnail grid and live search filter. Clicking
a tile inserts the matching markdown.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Updates help screen with all undocumented features. Fixes F1 blocked
by input suppression filter (allow function keys). Stabilizes card-join
tests with poll loop instead of fixed sleep.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Moves board-wide find/replace from standalone overlay into the search
panel as a toggleable replace row. Adds editor toolbar find/replace
button. Ctrl+Shift+H now opens search in replace mode.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Editor find/replace (public/js/modules/editor.js, public/index.php):
* New #editor-find-bar inside #editor-pane (above the textarea, pushes
it down via flex-col). Two rows: find input + match count + prev/next
+ close, and replace input + replace-one + replace-all.
* Ctrl+H opens find+replace, Ctrl+F opens find-only (replace row
hidden). Same shortcut toggles off if already open in the same mode.
* runFind(): case-insensitive indexOf scan, all match positions stored.
jumpTo() sets textarea.setSelectionRange for the Nth match.
findNext/findPrev cycle with wrap-around.
* replaceOne(): body.setRangeText at the current match position, re-scan.
replaceAll(): regex-based global replace on body.value.
* Enter in find input = next (Shift+Enter = prev). Enter in replace
input = replace one. Escape closes the bar + refocuses textarea.
* closeModal() calls closeFindBar() to clean up on editor close.
Board-wide find/replace (public/js/app.js):
* Ctrl+Shift+H opens a centered overlay that fetches all cards with
full bodies (api('api/cards'), bypasses lazy-load).
* Live search: typing in the find input scans all card bodies case-
insensitive. Matches shown as a scrollable list with color dot,
card title, occurrence count, and a context snippet with <mark>
highlight around the first match.
* Status line: "12 occurrences in 5 cards" or "no matches".
* Replace All button (danger-styled, disabled until both inputs have
text and matches exist). Click opens a showDialog confirmation:
"Replace 12 occurrences of 'foo' with 'bar' across 5 cards?"
On confirm: regex replace per card body → PATCH → _reloadAll.
* Escape / click-outside / Close button dismiss the overlay.
Shortcuts registered in the central registry for the help screen:
* ⌃ H (editor), ⌃ F (editor), ⌃ ⇧ H (global board-wide).
Tests (public/plugins/tests/board-find-replace.test.js):
* Ctrl+Shift+H opens the overlay (polls for #board-find-replace).
* Typing in find shows matching cards (creates 3 cards, 2 match,
asserts status count + list contains matching titles + excludes
non-matching title).
* Replace all updates card bodies after confirmation (creates 2
cards with "hello", replaces with "goodbye", confirms dialog,
verifies via API that bodies are "goodbye world goodbye" and
"goodbye there").
* Replace all button disabled without replace text.
detail.test.js: "closes on ESC" stabilized — guard that detail is
actually open before dispatching Escape, poll (20×50ms) instead of
fixed sleep(150) for the .open class to be removed.
chore: bump version to 0.9.21
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
runner.js: the Escape handler now checks panel.style.display !== 'none'
before closing. During the test run the panel is hidden (display:none)
but the variable is truthy — synthetic Escape events dispatched by
tests (e.g. "closes on ESC") were triggering close() mid-run, removing
the panel entirely so results never showed.
quick-capture.test.js: the "Enter creates card with #inbox tag" test
was asserting found.tags contains 'inbox', but the #inbox auto-tag
was removed in 0.9.17. Renamed to "Enter creates card in active
bucket" and dropped the stale tags assertion.
chore: bump version to 0.9.20
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The test-shield (#test-shield) was added in loader.js (always fresh
via plugin manager ?v=time()) but the removal lives in runner.js
hideProgress(). runner.js was still cached at ?v=3 in the browser,
so the shield stayed after the run. Bumping to ?v=4 in loader.js +
all 42 test files forces a fresh runner.js load.
chore: bump version to 0.9.15
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Focus restoration:
* editor.js closeModal(): blur activeElement after the close so board
shortcuts (arrows, bare keys) work immediately without clicking.
* detail.js closeDetail(): same blur, skipped when openedFromSearch
(search input should keep focus in that case).
* search.js closeSearch(): blur after removing .open.
* calendar.js closeCalendar(): blur after closeDayPopup.
All four check activeElement !== body before blurring to avoid no-ops.
Test shield (loader.js):
* ensureProgressBar() now also creates #test-shield — a transparent
fixed overlay (z-index one below the progress bar) covering the
viewport below the bar. Blocks all real mouse interaction while
tests run; synthetic events from the test suite still work because
they bypass pointer-events. cursor:not-allowed as visual feedback.
Removed by hideProgress() alongside the progress bar.
ESC closes test results (runner.js):
* The existing keydown listener now also handles Escape when the
test-panel is visible. stopPropagation prevents the app's Escape
chain from firing a second close underneath.
Tests (board.test.js):
* New describe "Board: Focus restoration after overlay close":
- closing the editor returns focus to body
- closing the detail view returns focus to body
- closing search returns focus to body
- arrow keys work after editor round-trip (enter → enter → escape)
— the exact flow the user reported as broken. Dispatches
ArrowDown → Enter (detail) → Enter (editor) → Escape (close) →
asserts body focus → ArrowDown → asserts .card-focused exists.
* Fixed .not.toBe() → (x !== y).toBe(true) since the custom runner
doesn't have a .not chain.
chore: bump version to 0.9.14
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New module public/js/modules/shortcuts.js:
* registerShortcut({key, ctrl, shift, alt, match, guard, priority,
section, label, keys, handler}) — declarative shortcut registration
with priority ordering, custom match predicates, and runtime guards.
* Single document-level keydown dispatcher iterates the registry
(sorted by priority desc), matches key+modifiers or custom match(),
checks guard(), fires handler with preventDefault.
* guards object: board(), editor(), detail(), search(), email(),
canvas(), bodyFocused(), noOverlay(), isOpen(id) — reusable
predicates that replace the repeated blocker-array patterns.
* renderShortcutHelp() auto-generates the #shortcuts overlay content
from the registry, grouped by section. New shortcuts added via
registerShortcut appear in the help screen automatically.
* Shortcuts with guard: () => false are help-screen-only entries for
element-level shortcuts handled by their own modules (editor
textarea, search input, canvas overlay, etc.).
Migration (public/js/app.js):
* The ~200-line monolithic keydown handler is replaced by ~80
declarative registerShortcut() calls.
* Escape chain: 10 separate registrations with priorities 100→20,
each with its own guard (quick-capture, detail/zen, modal, search,
tasks, gallery, calendar, shortcuts, help). A hidden: true flag
suppresses them from the help screen; one visible entry with
guard: () => false shows "Esc — close any overlay".
* Global combos: Ctrl+K (search), Ctrl+N (quick capture), Ctrl+Z
(undo), F1 (help), ? (shortcuts).
* Editor: Ctrl+S (save), Ctrl+Shift+S (save+close), Ctrl+Enter
(save+close). Element-level shortcuts (Tab indent, Alt+arrows,
autocomplete triggers) registered as help-screen-only.
* Board bare keys: a/f/j/n/t/c//, Tab/Shift+Tab, 1-9, arrow nav,
Enter → preview, Delete/Backspace selected.
* Detail: ←/→ group navigation, Enter → editor.
* Help-screen-only: search ↑↓/Enter, board mouse actions, canvas
tool shortcuts (B/E/L/R/O/F/T/⌫), mail ⌫/⇧⌫, m (plugin).
Removed from board.js: the document-level Delete/Backspace listener
(now in the central registry with the same logic).
Help screen (public/index.php):
* The hardcoded 3-column shortcut HTML is replaced by an empty
.shortcut-help-content div that renderShortcutHelp() fills at boot.
* CSS: .shortcut-help-content section gets break-inside:avoid +
margin-bottom for clean column flow.
Test stabilization:
* card-interactions.test.js: due-pill, overdue-border, and
child-hidden-on-board tests now set state.activeBucket='all'
before _reloadAll and poll for the DOM element (40×50ms) instead
of a fixed sleep(200). Eliminates the flaky "card not found" when
activeBucket was leaked by a prior test.
* editor-features.test.js: "save creates card and closes editor"
polls for #modal to lose .open class (40×50ms) instead of a fixed
sleep(400). doSave(true) does 2-3 serial API calls before
closeModal which sometimes exceeded the old budget.
chore: bump version to 0.9.13
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Board arrow-key card navigation (public/js/app.js):
* ↑/↓/←/→ on the board (body focus, no overlay) move a single-card
focus cursor (.card-focused, accent2 outline) through the visible
card grid. Column count is computed live from the first row's
offsetTop so it tracks the CSS grid columns at any viewport width.
* First arrow press focuses the first card; subsequent presses move
in the grid. Focused card scrolls into view (smooth, nearest).
* Enter on a focused card → openDetail (preview). Focus cleared.
* Enter in detail view → closeDetail + openModal (editor). Card ref
is captured before closeDetail nulls state.viewingCard.
* Click anywhere → focus cleared. Re-renders restore focus via
window._restoreCardFocus() called from board.js renderBoard().
CSS (public/css/style.css):
* .card-focused: accent2 outline + glow, visually distinct from
.card-selected (accent, multi-select) and .preview-active.
* .shortcut-table kbd keycaps: JetBrains Mono font (not pixel),
10px, 3D raised look with gradient + bottom border + inner
highlight + shadow.
* Responsive shortcut grid: 3 cols > 900px, 2 cols > 640px, 1 col.
New bare-key shortcuts (public/js/app.js):
* n → openModal(null) (new card, same as the + button)
* a → activateBucket('all')
* f → activateBucket('favorites')
* j → open today's journal card (find by YYYY-MM-DD prefix in
state.cards), or create a new one (switch to journal view, open
empty editor which auto-fills the date title)
Ctrl+N quick capture (public/js/app.js):
* Now sends bucket_id: state.activeBucket on the POST (unless the
active view is a virtual filter like all/current/etc., in which
case bucket_id stays null). Cards captured while viewing a user
bucket land directly in that bucket.
Shortcuts help screen (public/index.php):
* max-w-4xl, 3-column grid layout.
* Complete shortcut inventory across all modules:
- Navigation: a/f/j/n/t/c/m, 1-9, Tab/Shift+Tab, /
- Global: Ctrl+K/N/Z, Esc, F1, ?, Del
- Editor: Ctrl+S, Ctrl+Shift+S, Ctrl+Enter, Tab, Alt+↑↓,
Shift+Alt+↑↓, Enter (list continue), [[ (link ac), # (tag ac),
?snippet
- Search: ↑↓ navigate, Enter open, Esc close
- Board+Detail: arrows focus, Enter→preview, Enter→editor,
←→ group nav, click/dbl/right/ctrl+click, drag edge/center
- Canvas: b/e/l/r/o/f/t tool shortcuts, ⌫ delete
- Mail: ⌫ delete selected, Shift+⌫ skip confirm
* kbd elements use normal font (JetBrains Mono) instead of pixel.
* Help overlay's old shortcuts section replaced with a one-liner
pointing to ? for the full reference.
* #shortcuts added to runner.js closeAll() list.
chore: bump version to 0.9.12
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* card-interactions.test.js: group delete tests used `.dialog-ok`
(class selector) but the button has `id="dialog-ok"`. querySelector
returned null, the click was skipped, the dialog stayed open and
blocked every downstream test. Fixed to getElementById.
* runner.js closeAll(): added #dialog, #bucket-super-popover (remove),
and #gitea-overlay to the cleanup list. A failed test that leaves any
of these open no longer poisons the rest of the suite.
* runner.js copy handler: when there are failures or flaky tests, the
"copy" button now builds the clipboard text from lastResults —
only failed + flaky lines (suite name, test name, timing, error,
original error for retried). When everything passed, copies the
full output as before. lastResults is set in renderResults().
* bucket-context.test.js: move-left test used `[data-bucket]` to find
user bucket tabs, which misses super-bucket tiles (they carry
`data-bucket-super`). Changed to `[data-bucket-drag]` which is on
both regular and super-bucket tiles but not on fixed filters.
* context-menu.test.js: make-ticket pre-fill assertion now polls up
to 2s for the title input to be populated (async openCreateForm
repo-fetch was racing against the immediate assertion).
* card-join.test.js: clickJoin sleep bumped from 400ms → 800ms.
joinSelectedCards does 3+ serial API calls (fetch bodies, PATCH
winner, N×DELETE losers, _reloadAll) which sometimes exceeded the
old 400ms window on slow runs.
* Runner cache: RUNNER_V bumped from ?v=2 → ?v=3 in loader.js + all
42 test files so the browser picks up the new runner.js content
(copy handler, closeAll, retry progress bar) without a hard reload.
super-buckets: remember last selection per group
* buckets.js: module-scope lastSuperSelection Map tracks the last
active member per super-bucket parent. Updated in renderBar()
whenever activeBucket matches a group member. tileFor() uses it
as a fallback when the user is on a different bucket — the tile
shows the last-picked child's name and color instead of reverting
to the parent. The dropdown popover also highlights the last
selection when no member is currently active.
chore: bump version to 0.9.11
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
context-menu: group delete (public/js/modules/context-menu.js):
* When the delete action targets a card that has children (a group
parent), the confirmation dialog is ALWAYS shown regardless of the
hyprcrd-confirm-delete setting — group deletion is destructive
enough that it should never happen accidentally. The dialog lists
every child card by title and the OK button reads
"Delete all N" (parent + children counted).
* Children are deleted first (avoids orphan flicker), then the parent.
* Undo: a single pushUndo entry recreates the parent via POST first,
then all children with parent_id pointing at the new parent id.
due_date is restored via a follow-up PATCH since POST doesn't
accept it. Ctrl+Z fully restores the group hierarchy.
* Non-group cards keep the existing behavior (simple confirm if the
setting is on, single delete, pushCardDelete for undo).
tests (public/plugins/tests/card-interactions.test.js):
* New describe "Card: Group Delete" with two tests:
- "deleting a group removes parent AND all children": creates a
parent + 2 children, right-clicks the parent, clicks delete,
asserts the dialog text contains both child titles, confirms,
verifies all 3 cards are gone from the API.
- "Ctrl+Z after group delete restores parent + children": creates
parent + 1 child, deletes the group, confirms both gone, fires
Ctrl+Z, finds the restored cards by title, asserts
child.parent_id === restoredParent.id.
Both tests save/restore the hyprcrd-confirm-delete localStorage
value and soft-skip if the parent isn't visible on the board.
runner: retry progress bar (public/plugins/tests/runner.js):
* hideProgress() was called inside runAll() at the end of the first
pass, which removed the #test-progress element before the retry
pass started. The retry ran invisibly — "retrying…" never showed.
Moved hideProgress() out of runAll() into run(), after both the
first pass and the retry pass are done. The bar now stays visible
for the whole sequence: first pass fills to 100%, retry resets the
fill to 0 (via updateProgress(0, total, 'retrying…', false)) and
fills again for just the failed tests.
chore: bump version to 0.9.10
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
board hotkeys (public/js/app.js):
* "c" toggles the calendar from the board (no modifier, body focus,
no full-screen overlay in front).
* "t" opens a tag quick-pick popover that lists every unique tag from
state.cards. Filter input + arrow-key/enter pick + ESC/click-outside
close. Picking sets state.activeTags = [tag] and re-renders the
board + tag bar. Toggle: a second "t" press closes the picker.
* "1"-"9" picks the Nth user bucket in the bar. The fixed filters
(all/current/favorites/journal/none) are skipped. Super-buckets
count as one slot and activate via the new activateBucket() helper
in buckets.js — that bypasses the click handler so the dropdown
popover does NOT open; the currently shown member (parent or
active child) is selected directly.
* ESC chain in the existing handler now also closes #calendar (was
missing — calendar.js only handles ESC for the day-popup, not the
whole overlay).
buckets (public/js/modules/buckets.js):
* New exported activateBucket(idOrSpecial) helper: setActiveBucket
+ clear activeGroup + renderBar + window._renderBoard. Used by
the number-key hotkey to select super-buckets without triggering
their dropdown.
board split panel (public/js/modules/board.js):
* closeBoardPreview() now removes the .active class from the
toolbar's #board-split-toggle. The previous code removed
.text-t-accent which was a no-op since toggleBoardSplit() uses
.active — the X button on the panel closed the split but left
the toolbar showing the toggle as still on.
email plugin (public/plugins/email/email.js):
* ESC closes the overlay reliably: the previous handler was on
#email-overlay, which only fires when focus is *inside* the
overlay (rare after open). Moved to a document-level keydown.
* Bare "m" toggles the mail overlay open/closed. Suppressed when
Ctrl/Meta/Alt is held, focus is in an input/textarea/contenteditable,
or any other full-screen overlay is in front.
collab-buckets (public/plugins/collab-buckets/backend.php):
* When a card change is applied in the pull phase from a foreign
author (insert / update / tombstone), bump
collab_buckets.updated_at to NOW for that bucket. has_unread is
computed as updated_at > last_seen_at and was previously never
triggered for incoming cards — the bucket-bar tab now gets the
unread dot + 3-pulse when the user is looking at a different
bucket. Own pushes are skipped (the relay echoes them back too).
gitea plugin: make ticket from card (public/plugins/gitea/gitea.js):
* New window._giteaMakeIssueFromCard(card) helper. Opens the gitea
overlay, switches to the create-issue form, fetches the card body
if it's lazy/null, and pre-fills #gitea-create-title and
#gitea-create-body. Does NOT submit — the user picks the repo and
reviews before clicking submit. Soft-bails on the welcome view
if no gitea account is configured yet.
context-menu (public/js/modules/context-menu.js):
* New "Make ticket" entry in the card right-click menu, only rendered
when window._giteaMakeIssueFromCard is exposed. Action handler
hides the menu and calls the helper. Sits next to "Copy text".
* joinSelectedCards() now dedupes hashtag mentions in the merged body
(first occurrence wins, case-insensitive) so a tag that appeared on
multiple source cards isn't repeated in the resulting body. Followed
by a tab/space/blank-line cleanup pass.
test runner auto-retry (public/plugins/tests/runner.js):
* runAll() takes an optional Set<string> of `${suite}::${test}` keys
and runs only those when set. Progress bar denominator reflects
the filtered count.
* run() collects every failed test after the first pass, restores the
backup so the retry runs against the same baseline state, then
re-runs only those via runAll(failedKeys). Retry results are merged
into the original results array with `retried: true` and the
original error preserved on `originalError`.
* renderResults distinguishes three states: passed first try (green ✓),
pass-on-retry (amber ⚠ "flaky — passed on retry" with the first-run
error shown), failed twice (red ✗ "failed twice"). Summary line
carries a "N flaky" count and goes amber if only flaky tests
appeared in the run.
tests (public/plugins/tests/context-menu.test.js):
* Two new tests for the make-ticket action:
- action exists in the menu when window._giteaMakeIssueFromCard
is exposed (soft-skip otherwise)
- clicking it opens the gitea overlay, surfaces the create-issue
form, and pre-fills title + body without submitting. Soft-skips
the assertion when no gitea account is configured (welcome view).
chore: bump version to 0.9.7
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Right-click on a card that's part of a ≥2-card selection now offers
"Join N cards" at the top of the context menu, and the same action
sits next to "Group" in the bottom select-bar.
Title strategy:
* sequential ctrl-click selection → first selected wins
* bulk gesture (rubber-band) → longest title wins
The bulk vs sequential distinction is tracked via a new
`state.selectionWasBulk` flag set in board.js when the rubber-band
becomes active and cleared in clearSelection() and after a successful
join. ctrl-clicks alone don't touch it, so bulk + ctrl-click hybrid
selections stay bulk.
Body format: list of every original title as `## heading` lines, then
each card's body separated by `---`. The winner keeps its color, tags,
bucket, due_date, parent_id; only the body is rewritten. Losers are
deleted via DELETE /api/cards/{id}.
Undo: a single pushUndo entry restores the winner's original body and
recreates every loser via POST + due_date PATCH (since POST doesn't
take due_date). Ctrl+Z reverts the whole join.
Implementation:
* state.js: new selectionWasBulk boolean in state.
* board.js: rubber-band onMove sets state.selectionWasBulk = true on
first activation; clearSelection clears it. New select-join click
handler dynamic-imports joinSelectedCards from context-menu.js to
keep the dependency lazy.
* context-menu.js: joinSelectedCards is now exported. Inserts
data-action="join" into the menu when the right-clicked card is in
a ≥2-card selection. Action handler dispatches to joinSelectedCards
before falling through to the regular if-chain so it can early-return.
* index.php: new #select-join button in #select-bar with ph-arrows-merge
icon, sits between Group and Bucket.
Tests (public/plugins/tests/card-join.test.js, registered in tests.js):
* visibility: shows for ≥2 selected with right-clicked card in the
selection; hidden for solo selection; hidden when right-click target
isn't selected.
* sequential winner: first ctrl-clicked card survives, others deleted.
* merged body lists every title at the top and contains every body.
* selection cleared after a successful join.
* bulk winner: longest title survives regardless of insertion order.
* undo: Ctrl+Z restores winner body + recreates losers.
undo.test.js race-fix:
* New waitForStateCard(id, predicate, timeout=2000) helper polls
state.cards every 30ms until the in-memory state reflects the
expected change. Replaces fragile fixed `sleep(300)` waits between
chained user actions where the second action reads from state.cards
before the first action's _reloadAll has landed.
* Multi-undo color test: between the two color changes, wait for
state.cards to actually contain the pink color before doing the
second rightClick. Without this the second action's contextmenu
handler reads the stale card and pushes the wrong undo snapshot,
collapsing both undo entries onto the original color.
* Due_date undo test: after PATCHing due_date directly via API, wait
for state.cards to reflect it before opening the editor — the
editor's snapshot for the undo entry reads from state.cards.find()
and would otherwise capture due_date=null.
chore: bump version to 0.9.5
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Super-buckets: a bucket can act as a dropdown of nested children, 1-level
deep, created by dragging one bucket onto the center of another. The
parent shows up as a single tile in the bar with a stack icon and the
active member's name; clicking opens a popover that lists parent + all
children. Selecting an entry sets state.activeBucket. Implementation:
* src/Database.php — ALTER TABLE buckets ADD COLUMN parent_id + index.
* public/index.php — PATCH /api/buckets accepts parent_id with depth /
cycle / collab guards. DELETE on a parent un-nests its children
rather than cascade-deleting. The /api/import handler does a second
pass to remap parent_id through the new bucket id map so backup +
restore round-trips the hierarchy.
* public/js/modules/buckets.js renderBar() — top-level buckets only;
buckets with ≥1 child render as a `.bucket-super` tile with
data-bucket-super=<parent.id> and data-super-shown=<active member>.
Three drop zones per tile: left/right 25% → reorder via insert
cursor, center 50% → nest (PATCH parent_id) with .bucket-nest-target
outline. canNestInto() mirrors the backend rules client-side.
openSuperDropdown() renders a popover under the tile with parent +
children as real .bucket-tab[data-bucket=…] entries (so the collab
plugin's MutationObserver still picks them up); appended inside
#bucket-bar but `position:fixed` so the bar's overflow:auto can't
clip it. showBucketCtx() now scopes move-left/right to siblings
(top-level peers OR same parent_id) so the action works in both
the bar and the dropdown.
Drag-and-drop insertion cursor for cards (matches the bucket fix):
* public/js/modules/board.js bindCardEvents() — single
#card-drop-cursor element inside #board, repositioned by per-card
dragover. Three zones (left/right 25% → cursor in gap, center 50% →
card-drop-group highlight). dragleave does NOT hide the cursor (was
flickering as the mouse moved between adjacent cards); only dragend
clears it. handleCardDrop recomputes the drop zone from e.clientX
since the cursor approach no longer tags the target with
.card-drop-before / .card-drop-after.
* "Vor der ersten Card" works automatically: the first card's left
25% positions the cursor at offsetLeft - 6, which sits inside
#board's px-6 padding and stays visible.
* public/css/style.css — old .card-drop-before::before /
.card-drop-after::after pseudo-elements removed in favour of the
#card-drop-cursor rule.
Drag ghost survives background polls:
* board.js draggingCardIds: Set<string> at module scope. dragstart
fills it; dragend clears. renderBoard() re-applies the
.card-dragging class to every member of the set after every render
so caldav / collab polls calling _reloadAll mid-drag don't
visually pop the source card back to its normal state.
Tests (public/plugins/tests/super-buckets.test.js):
* API parent_id: nest, un-nest, self-parent rejected, level-2
rejected (bucket with own children, target is itself a child),
DELETE un-nests, export → import preserves parent_id.
* Bar rendering: super-tile DOM markers, label = parent name when
no child active, label = active child name when selected, popover
opens with parent + children entries, click child sets activeBucket,
click super-tile twice toggles popover closed, plain bucket stays
plain.
* Drop cursor element exists.
* Both describe blocks reset state.activeBucket = 'all' in afterEach
— the rendering tests set it to a child id and the leak was breaking
downstream Card: Due Date & Done / Card: Grouping tests by filtering
every newly-created card with bucket_id=null off the board.
chore: bump version to 0.9.3
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* version: every span that shows v<APP_VERSION> in the page chrome now
carries data-app-version, and checkAuth() patches them in from the
/api/auth/status response so the GUI version doesn't go stale when
PHP is cached behind anything that revalidates lazily.
* undo: tailwind v4 compiles `-translate-x-1/2` to the *new* CSS
`translate` property, which then stacks with the inline `transform`
the toast set — the box ended up shifted ~ -100% and looked left-
anchored. Drop the tailwind utility, set `translate` directly inline
so there's one source of truth for the offset.
* editor: ctrl/cmd+enter while the editor modal is open now invokes
saveCard() (= save + close), pairing with the existing ctrl+s
(save only) and ctrl+shift+s (save+close) shortcuts.
* collab buckets: drop the inset left edge from .bucket-tab-collab and
the matching pulse-keyframe shadow — the bar tab now reads as a
normal bucket tab, just with the unread dot + 3-cycle pulse on update.
* collab create dialog: drop the box border / background from
.collab-toggle-row and .collab-members so the toggle and the user
list sit directly on the modal background instead of looking like
nested input boxes.
* test progress strip: ensureProgressBar() now also pushes the body
down by 30px AND shrinks it via height: calc(100vh - 30px) so the
fixed-top bar doesn't clip the footer (body is h-screen +
overflow-hidden in the core layout). hideProgress() clears both
inline styles when the run finishes.
chore: bump version to 0.9.2
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* runner.js: shows a 30px fixed-top progress strip during runs (label
"test x/total:", current suite/test name with ellipsis on the left,
fixed 50%-wide progress fill on the right that turns danger-red on
the first failure). The DOM node is created in loader.js so it appears
immediately on shortcut press; runner.js only updates it. The fill
width is fixed at flex:0 0 50% so it doesn't jiggle as the test name
changes length. Suites now live on window._testSuites as a defensive
fallback so two runner instances stay in sync.
* Cache invalidation: every test file's `from './runner.js'` is now
`from './runner.js?v=2'`, and loader.js imports the runner with the
same RUNNER_V suffix. Same URL → one module instance → suite
registration and runAll see the same array. Bumping RUNNER_V (in
loader.js + every test file in lock-step) is the way to force a
re-fetch when runner.js changes.
* loader.js: ensureProgressBar() draws the strip via setProperty(...,
'important') with z-index 2147483647 attached to documentElement so
nothing in user CSS, scanline overlay (z 9999) or modals can occlude
it. Auto-run path (?test=1) also pre-renders the strip.
chore: bump version to 0.9.1
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Plugin loader + runner shortcut moves to Ctrl+Shift+Alt+T (the old
Ctrl+Shift+T conflicts with the browser "reopen closed tab"). Loader
now imports runner.js *without* the cache-busting query so it picks
up the same module instance the test files register their suites
into — previously the shortcut opened a fresh runner with an empty
suites array and reported 0/0 passed.
New / fixed coverage:
- card-interactions: favorite toggle assertion is now case-insensitive
(context menu was capitalized to "Unfavorite"/"Favorite" earlier);
done-badge test guards against state.hideDone / hideTagged that would
filter the test card off the board; new test for the always-on
Mark done / Unmark done toggle on a card without due_date.
- context-menu: copy-text action exists; copy-text strips fenced code
blocks, image markdown and inline #tags. Mocks navigator.clipboard
via Object.defineProperty so the assertion works without a focused
/ secure-context clipboard.
- export: round-trip preserves a custom bucket color (covers the import
handler regression that was wiping bucket colors on every test run).
- theme: design preset selection persists in localStorage across
settings open/close, refreshPresetSelect ignores unknown keys.
afterEach restores both hyprcrd-design-active-preset and
hyprcrd-design so a test run can't permanently swap the user's
design.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>