Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
adbde11
kanban: mark the offline-queue-overflow framework gap with its issue
Aug 16, 2026
f669801
kanban: resolve per-project RBAC's design fork -- in-model, not IAuth…
Aug 16, 2026
a3216a3
kanban: rung 4 implementation design spec
Aug 16, 2026
8d8ac14
kanban: incorporate Fable 5 spec review -- correct RBAC identity, fil…
Aug 16, 2026
0c4cd11
kanban: backend + testkit implementation plan (20 tasks)
Aug 16, 2026
0bc720a
kanban: rung scaffolding (empty lib/server/tests targets)
Aug 16, 2026
0cc9a11
kanban: fix Task 1 per reviewer feedback (Fix Round 1)
Aug 16, 2026
594c8c5
kanban: strong ids, Role enum, error hierarchy
Aug 16, 2026
c22ccf7
kanban: database setup + 8-table schema migration
Aug 16, 2026
e21a320
kanban: entities (Light::Field records for all 8 tables)
Aug 16, 2026
c0e03ac
kanban: project_dto.hpp -- CreateProject, role management actions
Aug 16, 2026
574fa76
kanban: board_dto.hpp -- GetBoard/MoveTaskPosition/task CRUD actions
Aug 16, 2026
a4e7cb9
kanban: mutex-guard KanbanAuthorizer's TokenIssuer slot; add test_kan…
Aug 16, 2026
6771ee4
kanban: ProjectAdminModel + AuthModel -- project lifecycle, RBAC role…
Aug 16, 2026
db5f87b
kanban: BoardModel -- OpenBoard/GetBoardState/column+swimlane+task CR…
Aug 16, 2026
6ecb6ae
kanban: MoveTaskPosition -- WIP limits, position renumbering, exactly…
Aug 16, 2026
d27db19
kanban: GetEventsSince -- board_events table, per-action event stamping
Aug 16, 2026
103f949
kanban: gate BoardModel's mutating actions on Role::Member (requireRole)
Aug 16, 2026
6addc9f
kanban: GetActivity -- journal-derived activity stream, ledger-hit de…
Aug 16, 2026
43c5200
kanban: shared-instance lifecycle tests (Local/LocalSingleThread/Sock…
Aug 16, 2026
75198bd
kanban: App bootstrap (RemoteServer + action log + limits wrapper)
Aug 16, 2026
3150576
testkit: action_driver.hpp -- SeededScript weighted generator + burst…
Aug 17, 2026
de15a86
testkit: offline_rig.hpp -- scripted connectivity drop/revive
Aug 17, 2026
6dad6a6
testkit: client_pool.hpp + convergence.hpp -- N-client convergence as…
Aug 17, 2026
f7fb9dd
kanban: concurrent-move stress test (N=4, ThreadSanitizer, Local rig …
Aug 17, 2026
cdb8bfa
kanban: offline DoD tests -- exactly-once under dropped reply, reconn…
Aug 17, 2026
ac2c4b6
kanban: fix-round-1 -- real busy-timeout mix in the SQLite-contention…
Aug 17, 2026
81d82e3
kanban: final-review fix round -- close C1/C2 authz holes, stop doubl…
Aug 17, 2026
0c1487a
kanban: GUI design spec (desktop client, drag-and-drop board, minimal…
Aug 17, 2026
73038d7
fix CI: roleToString's exhaustive switch missing -Wswitch-default's r…
Aug 17, 2026
b68c054
fix CI: drop useless static_cast<int64_t> in OpenBoard's ActionKeyTra…
Aug 17, 2026
d29a096
fix: bound SetMemberRole/RemoveMember's principal to auth::kMaxPrinci…
Aug 17, 2026
23748ea
deps: bump Lightweight to master tip (bbb972a), fixing LASTRADA-Softw…
Aug 17, 2026
76d97ab
fix CI: close real codecov/patch gaps in action_driver.hpp/convergenc…
Aug 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
382 changes: 382 additions & 0 deletions docs/superpowers/findings/userver-vs-morph-2026-08-17.md

Large diffs are not rendered by default.

3,323 changes: 3,323 additions & 0 deletions docs/superpowers/plans/2026-08-16-kanban-backend.md

Large diffs are not rendered by default.

537 changes: 537 additions & 0 deletions docs/superpowers/specs/2026-08-16-kanban-rung4-design.md

Large diffs are not rendered by default.

310 changes: 310 additions & 0 deletions docs/superpowers/specs/2026-08-17-kanban-gui-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,310 @@
# Kanban GUI Design

Follow-on to the kanban backend (rung 4 of the [application ladder](../../../examples/LADDER.md),
`docs/superpowers/specs/2026-08-16-kanban-rung4-design.md`). This spec covers the
desktop client only: a native Qt Quick app driving the already-implemented
`kanban::ProjectAdminModel`/`kanban::BoardModel` action surface.

## 1. Scope

**In scope:**
- Login (dev-mode, username-only, `SigningAuthorizer`-backed).
- Project bootstrap: list the caller's own projects, create a new one.
- The board itself: columns, swimlanes, tasks, drag-and-drop moves, comments,
a journal-derived activity stream.
- A minimal member-management view (list/set-role/remove).
- Desktop client only, both `Local` (in-process) and `Remote` (WebSocket)
modes, mirroring `examples/bookmarks/gui/main.cpp`'s `--server` flag.

**Explicitly out of scope** (unchanged from the backend's own out-of-scope
list — `examples/kanban/README.md`'s "Deferred within this rung" and the
backend design spec's §9):
- Automation rules (event → condition → mutation) — no action/DTO exists for
this, so there is nothing for a GUI to drive.
- Task attachments — same reason.
- The offline stack (`SqliteOfflineQueue`/`SyncWorker`/`ReconnectCoordinator`/
`NetworkMonitor`). The backend's own final review found this stack was
implemented but never proven end-to-end; wiring it for real is substantial,
separate work deserving its own design pass, not a corner of this one. This
GUI uses the same always-connected, `GetEventsSince`-polling pattern
`polls`/`bookmarks` already use. A dropped connection surfaces through the
existing `failed(QString)` error-`Label` pattern, exactly like every prior
rung — no "N changes pending sync" indicator, no reconnect UI.
- A WASM build. Desktop only, for this pass.
- Visual/UX design beyond "legible, with smooth drag feedback" — see §2.

## 2. Visual bar: "legible + smooth drag"

`LADDER.md` names kanban as the ladder's one deliberate exception to
`IMPLEMENTATION.md` rule 2's "zero styling effort" — "visually legible" is
the only elaboration given; everything beyond that is this spec's own
decision, made explicit here rather than left implicit:

- Default Qt Quick Controls 2 style (whatever style the other rungs already
build with — no new style module, no custom `Material`/`Universal` theme).
- No custom color palette, no icons, no branding, no transition animations
beyond what Qt Quick's `ListView` gives a `move` for free.
- The **one** deliberate exception: a dragged task card gets real visual
feedback — it visually detaches and follows the cursor, and the column
under the cursor highlights — because that is the one interaction default
controls cannot fake, and a "showcase" board with no drag feedback would
read as broken, not restrained.

No other rung is affected by this decision; every other rung's zero-styling
convention is unchanged.

## 3. New backend action: `GetMyProjects`

None of the surveyed backend actions answer "which projects does the caller
belong to" — `CreateProject` returns exactly the one project it created,
and `GetProjectRoles` needs a project id already in hand. A project-list
view needs this to exist. Small addition to `kanban::ProjectAdminModel`
(same model as `CreateProject`/`SetMemberRole`/`RemoveMember`/
`GetProjectRoles` — project-admin-scoped, not board-scoped):

```cpp
/// @brief Lists every project the calling principal has any role on.
struct GetMyProjects {};

/// @brief One project the caller belongs to, with their own role on it.
struct MyProjectSummary {
ProjectId id;
std::string name;
Role myRole;
};

struct GetMyProjectsResult {
std::vector<MyProjectSummary> projects;
};

GetMyProjectsResult execute(const GetMyProjects& action);
```

`GetMyProjects` takes no parameters — the principal comes from
`session::current()`, exactly like `AddComment`'s `requireOwner()` pattern
in `BoardModel`. This is the one piece of backend work this spec requires;
everything else drives the already-shipped surface as-is. Implementation:
a query joining `project_has_roles` (or whatever the actual role table is
named in `db::ProjectRoleRecord` — confirm exact table/column names against
`examples/kanban/src/db/schema.cpp` at implementation time) filtered by
`principal`, ordered by project name. No pagination for this pass — project
count per user is expected to be small at ladder-example scale, same
reasoning `docs/superpowers/specs/2026-08-16-kanban-rung4-design.md` already
applies to `BoardModel::buildState`'s unpaginated per-project reads.

This addition needs its own task-review cycle (SDD or otherwise) before the
GUI work depends on it, since it changes a model's public action surface on
an already-reviewed, CI-green branch.

## 4. Architecture

Three layers, following `examples/bookmarks`'/`examples/polls`' established
pattern exactly — see `examples/IMPLEMENTATION.md`'s "Presenters translate
and route; they never decide" and `examples/common/gui/presenter.hpp`'s
shared `Presenter` base.

### 4.1 Directory layout

```
examples/kanban/
gui/
main.cpp
qml/
Main.qml
LoginView.qml
ProjectListView.qml
BoardView.qml
TaskDetailPopup.qml
MembersView.qml
gui_lib/
project_admin_presenter.hpp / .cpp
project_admin_qml_bridge.hpp / .cpp
board_presenter.hpp / .cpp
board_qml_bridge.hpp / .cpp
tests/
test_project_admin_presenter.cpp
test_project_admin_qml_bridge.cpp
test_board_presenter.cpp
test_board_qml_bridge.cpp
test_board_concurrent_drag.cpp
test_gui_qml_smoke.cpp
```

### 4.2 Layer split: two bridge/presenter pairs, not one

Split along the backend's own strand boundary (`ProjectAdminModel` vs.
`BoardModel` are already separate models/strands) rather than one unified
bridge — every sibling rung with more than one model keeps its bridges
split by model, and merging them here would be a one-off inconsistency to
save a small amount of boilerplate.

- **`ProjectAdminPresenter`/`ProjectAdminBridge`**: login, `GetMyProjects`,
`CreateProject`, `GetProjectRoles`, `SetMemberRole`, `RemoveMember`.
- **`BoardPresenter`/`BoardBridge`**: `OpenBoard`, `GetBoardState`,
`CreateColumn`, `CreateSwimlane`, `CreateTask`, `MoveTaskPosition`,
`AddComment`, `GetEventsSince`, `GetActivity`.

### 4.3 Responsibilities per layer

- **Presenter** (`QObject`-derived, Qt-Core-only, no QML dependency): owns
one `BridgeHandler<Model>`, translates action results into signals
(`projectCreated`, `boardOpened`, `taskMoved`, `activityUpdated`,
`failed(QString)`), owns the `GetEventsSince` poller as a `QTimer`
(mirrors `examples/polls/gui_lib/poll_presenter.cpp`'s `Poller`).
- **Bridge** (`QObject`, `Q_OBJECT`, the QML-facing surface): owns the
presenter, exposes state via `Q_PROPERTY` (current board as JSON, current
project list as JSON, current role, principal), actions via `Q_INVOKABLE`
(`createColumn(name, wipLimit)`, `moveTask(taskId, columnId, swimlaneId,
position)`, `addComment(taskId, body)`, …), forwards/redacts presenter
signals. `_liveness` (a `shared_ptr<const void>`) declared last, per the
documented async-completion lifetime rule.
- **QML**: bindings only. The one place with real logic is the drag/drop
handler computing a drop target and position (see §6) — kept small and
isolated, not spread through the view.

### 4.4 Bootstrap (`gui/main.cpp`)

Mirrors `examples/bookmarks/gui/main.cpp` exactly: `--server <url>` selects
`Remote` mode (real `QtWebSocketBackend` over the flag's URL); the default
with no flag is `Local` (in-process `LocalBackend`). Bridges wired via
`engine.setInitialProperties({{"projectAdminBridge", ...}, {"boardBridge",
...}})`, then `engine.loadFromModule(uri, "Main")`.

## 5. Auth flow

Identical shape to `examples/bookmarks/gui_lib/bookmark_qml_bridges.cpp`'s
`FormsBridge::onLoginSucceeded` — kanban's `Login{username}` →
`LoginResult{AuthToken token, principal}` is structurally the same as
bookmarks', so the pattern transposes directly:

1. `LoginView.qml`: username field → `projectAdminBridge.login(username)`.
2. `ProjectAdminBridge::login(QString)` (`Q_INVOKABLE`) → presenter executes
`Login` via its `BridgeHandler`.
3. On success, the presenter installs a `session::Context{principal, token}`
onto the shared `Bridge` via `setDefaultSession` — **not** stored as a
bridge/presenter member. Every subsequent action rides this session
automatically.
4. The bridge emits `loggedIn(QString principal)`. **The raw token is never
emitted on any signal.** If a reply payload is ever re-serialized for a
generic `replyReceived`-style signal, construct a redacted copy first
(`LoginResult redacted = *result; redacted.token = AuthToken{};`) before
serializing — the exact defect this session already found and fixed once
in bookmarks' bridge; do not reintroduce it here.
5. `Main.qml`'s `StackView` reacts to `loggedIn` → replaces to the project
list page.

## 6. Board view and drag-and-drop

### 6.1 Layout

`BoardView.qml`: an outer vertical section per swimlane (only rendered as
distinct sections when the board has more than one swimlane — a
single-swimlane board, the common case, renders as a flat column row with
no swimlane chrome), each containing a horizontal `ListView` of columns
(`ListView.Horizontal`). Each column delegate is a `Rectangle` with a
header (name, `"{count}"` when `wipLimit == 0`, `"{count}/{wipLimit}"`
otherwise) and a vertical `ListView` of task-card delegates.

### 6.2 Drag mechanism

Native Qt Quick `Drag` attached property + `DropArea` — no custom
mouse-position tracking, no synthesized events:

1. Each task-card delegate: `Drag.active: dragHandler.active`, a
`DragHandler` (or `MouseArea` with `drag.target`) reparenting the card to
the board's root `Item` for the duration of the drag so it visually
floats above the columns.
2. Each column (or a `DropArea` sized to the column's task list) sets
`Drag.onEntered`/`onExited` to toggle a border highlight
(`border.color: dropArea.containsDrag ? "steelblue" : "transparent"`).
A column already at its WIP limit shows the highlight in a different
color (e.g. red) instead of blue while a card is dragged over it, as an
early visual cue — enforcement is still server-side; this is purely a
hint.
3. On drop: compute the destination column id, destination swimlane id
(from which swimlane section the drop y-coordinate falls under, when
more than one exists), and destination position (index within the
destination list nearest the drop point).
4. `boardBridge.moveTask(taskId, columnId, swimlaneId, position)` is
called. The bridge — not QML — generates the `opId`
(`QUuid::createUuid().toString()`) required for `MoveTaskPosition`'s
exactly-once semantics; QML never sees or manages it.
5. **Optimistic UI**: the card list updates immediately (Qt Quick
`ListView`'s built-in move transition) rather than waiting for the round
trip. The next poll-triggered `GetBoardState`/`GetEventsSince` refresh is
the authoritative source; if the move was rejected server-side (WIP
limit hit between the drag starting and the drop landing, a deleted
column, etc.), that refresh snaps the card back and `failed(QString)`
surfaces the rejection reason via the standard error-`Label` pattern.

## 7. Comments and activity

- **Comments**: tapping (not dragging) a card opens `TaskDetailPopup.qml` —
a `Popup`/`Drawer` overlay, not a full `StackView` page, so the board
stays visible underneath. Shows the task's comment list plus an
add-comment field, driven by `AddComment`.
- **Activity**: a separate, always-visible or toggleable panel rendering
`GetActivity`'s `ActivityEvent{actionType, principal, timestampMs,
summary}` list, refreshed on the same poll tick as the board (one
`GetEventsSince`-driven timer covers both, rather than two independent
pollers).

## 8. Members view

`MembersView.qml`: a flat `ListView` over `GetProjectRoles`' `MemberRole
{principal, role}` rows. Each row: principal text, a `ComboBox`
(Viewer/Member/Manager) calling `setMemberRole(principal, role)` on
selection change, and a remove button calling `removeMember(principal)`.
Adding a member is a text field (principal) + role picker, calling
`setMemberRole` directly — there is no "add member by search," per the
minimal-bootstrap scope decision (§1).

## 9. Testing

Follows the established convention exactly — no new testing pattern
invented:

- **Presenter tests** (`test_project_admin_presenter.cpp`,
`test_board_presenter.cpp`): plain `QCoreApplication`, a `BackendRig`
(Local mode) backing the presenter's `BridgeHandler`, signal emissions
asserted via `QObject::connect` lambdas + `pumpUntil` — no QML.
- **Bridge tests** (`test_project_admin_qml_bridge.cpp`,
`test_board_qml_bridge.cpp`): `QMetaObject` introspection
(`indexOfProperty`, `indexOfMethod`) for the exposed surface, plus
behavioral tests driving `Q_INVOKABLE`s against a `BackendRig` and
asserting property/signal updates.
- **`test_gui_qml_smoke.cpp`**: loads `Main` via `QQmlApplicationEngine`
under the offscreen platform (CI-set `QT_QPA_PLATFORM=offscreen`),
asserts non-empty `rootObjects()` and zero QML warnings.
- **Drag-and-drop**: per `examples/TESTING.md`'s "no synthesized-mouse-event
flows" rule, the visual gesture itself is not tested via simulated mouse
events. `BoardBridge::moveTask()` — the actual dispatch the gesture
triggers — is tested directly with hardcoded arguments in
`test_board_qml_bridge.cpp`. A separate **`test_board_concurrent_drag.cpp`**
mirrors the backend's own `test_kanban_stress.cpp`: multiple
`BoardBridge` instances (not raw `BoardModel`s) call `moveTask()`
concurrently against a shared `BackendRig` server. Pass criteria, mirroring
the backend stress test's own invariant: after every call settles, a fresh
`GetBoardState` read shows dense, unique positions within every
`(columnId, swimlaneId)` pair and no task duplicated or dropped — the same
property the backend already proves at the model level, now exercised
through the GUI's own bridge/presenter code path rather than bypassing it.

## 10. Doxygen / CI

Per `CLAUDE.md`: any new public symbol needs complete `@param`/`@tparam`/
`@return` Doxygen or the Docs workflow fails. `docs/CMakeLists.txt`'s
`DOCS_SOURCES` currently scans only `include/morph` + `ARCHITECTURE.md` —
confirmed during the kanban backend's own final review (`include/morph/`
is scanned, `examples/` is not) — so this rung's GUI classes are not
actually gated by the Doxygen build, matching every sibling rung's GUI
code. Doxygen-style comments should still be written for consistency with
the surrounding codebase's convention, not because CI enforces it here.

## 11. Out-of-scope follow-ups this spec deliberately does not solve

- Wiring the real offline stack into any GUI (§1) — separate design pass.
- A WASM build (§1) — separate design pass if ever pursued.
- Automation rules / attachments UI — no backend surface exists yet.
- `GetMyProjects` pagination — not needed at ladder-example scale; revisit
if a future rung's project count assumption changes.
6 changes: 4 additions & 2 deletions examples/bank/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,10 @@ set(LIGHTWEIGHT_BUILD_BENCHMARK OFF CACHE BOOL "" FORCE)

FetchContent_Declare(Lightweight
GIT_REPOSITORY https://github.com/LASTRADA-Software/Lightweight.git
GIT_TAG v0.20260625.0
GIT_SHALLOW TRUE
# Kept in sync with examples/common/CMakeLists.txt's identical pin --
# see that file's comment for why this is a commit SHA, not a tag.
GIT_TAG bbb972a78e1962b968a2c6ad93f7dade736eaa01
GIT_SHALLOW FALSE
)
FetchContent_MakeAvailable(Lightweight)

Expand Down
13 changes: 11 additions & 2 deletions examples/common/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,13 @@ set(LIGHTWEIGHT_BUILD_BENCHMARK OFF CACHE BOOL "" FORCE)
set(LIGHTWEIGHT_BUILD_SHARED OFF CACHE BOOL "" FORCE)
FetchContent_Declare(Lightweight
GIT_REPOSITORY https://github.com/LASTRADA-Software/Lightweight.git
GIT_TAG v0.20260625.0
GIT_SHALLOW TRUE
# Pinned to master's tip commit, not the latest tag (v0.20260625.0) --
# LASTRADA-Software/Lightweight#551 (BelongsTo's silent-modification-loss
# bug this rung's MoveTaskPosition worked around) merged to master after
# that tag was cut, with no newer tag since. Bump this SHA when a new
# tag lands.
GIT_TAG bbb972a78e1962b968a2c6ad93f7dade736eaa01
GIT_SHALLOW FALSE
)
# Lightweight's own install() rules unconditionally reference
# $<TARGET_PDB_FILE:Lightweight> on WIN32 (its CMakeLists.txt), which CMake
Expand Down Expand Up @@ -172,6 +177,10 @@ add_executable(ladder_common_tests
testkit/test_fault_proxy.cpp
testkit/test_strand_interleaver.cpp
testkit/test_wasm_registration_path_native.cpp
testkit/test_action_driver.cpp
testkit/test_offline_rig.cpp
testkit/test_convergence.cpp
testkit/test_client_pool.cpp
)
target_link_libraries(ladder_common_tests PRIVATE morph::ladder_testkit)
# morph::ladder_testkit links Lightweight::Lightweight PUBLIC (above), and
Expand Down
Loading
Loading