Skip to content

hub-client: collections-based projects home (redo project selection)#394

Open
icarusz wants to merge 48 commits into
quarto-dev:mainfrom
icarusz:feature/85-projects-collections-ui
Open

hub-client: collections-based projects home (redo project selection)#394
icarusz wants to merge 48 commits into
quarto-dev:mainfrom
icarusz:feature/85-projects-collections-ui

Conversation

@icarusz

@icarusz icarusz commented Jul 15, 2026

Copy link
Copy Markdown
Member

Implements #85 — a redo of the project-selection component as a full-page projects home, built from the Figma design and the 2026-07-09 design discussion (Andrew/Carlos/Elliot).

This is a UI exploration. It ships behind a persisted toggle (qh-ui-variant): the new home is shown by default on this branch, and an avatar-menu item switches back to the classic selector. Nothing is deployed to quartohub.com by this change, and the editor/file-selector are untouched.

What #85 asked for, and where it landed

  • Hide the sync-server choice — gone from the common path; surfaced only behind an "advanced" affordance in Connect/Import and the New dialog.
  • More information — cards show file counts, last-opened, and real contributor chips; a hover Peek shows the file list + who's joined without opening the project.
  • Per-user synced project-set document — done, and generalized: each collection is its own ProjectSetDocument, and the browser stores an array of collection pointers instead of a single one, so lists sync across browsers and hosted deployments can manage the pointer server-side.

Highlights

  • Collections = synced project-set docs. Root ("My projects") plus additional/shared collections; sharing a collection is subscribing to its doc id (reuses the link-another-browser mechanism). Invite links carry only the doc id + server.
  • Migration from the old single project set → collection-driven, and from the pre-synced localStorage collections → real docs. Non-destructive; legacy pointer retained. Documented in claude-notes/instructions/hub-client-storage.md (Migration History) and tested (migrations.test.ts).
  • Invite-first onboarding: clicking a collection invite auto-creates a personal root (or silently migrates an existing one) and asks only for a name/color — never the setup/migration screen.
  • Drag-and-drop between collections; Duplicate/fork with a rename+destination dialog; Peek as a hover card; real (non-mock) contributor chips sourced from index-doc identities; per-collection people & invite popover.

Testing

  • npm run build:all and npm run test:ci pass (710 unit + 129 wasm).
  • Manual two-browser round trip on npm run local-prod (local hub + sync server): create/collection/drag/share/join, live cross-browser sync, and the v4→v5 migration on a genuine pre-collections browser (projects preserved). Details in claude-notes/plans/2026-07-10-collections-as-project-sets.md.
  • Diff is TypeScript-only (hub-client/, ts-packages/quarto-automerge-schema/, claude-notes/); no Rust crates touched.

Known limitations / deferred (see plan doc)

  • Contributor chips accumulate via union-on-write; a truly-concurrent first open can briefly show one author and self-heals on next edit. A per-actor contributors map is the zero-window follow-up.
  • Collection history viewer (added/removed + restore), user/org templates, and project-type metadata are deliberately out of scope here.
  • UX placement is rough per the discussion — intended for a design pass before polish.

Closes #85.

🤖 Generated with Claude Code

icarusz and others added 30 commits July 7, 2026 17:30
Implements the "Short term" section of the QH-ProjectManagement-July26
Figma design as an alternate projects view, replacing the modal
ProjectSelector with a full-page home:

- header bar: logo, search (Cmd+K), Connect/Import and + New menus,
  avatar menu holding identity, cursor color, device linking, JSON
  backup, and theme (relocated from the modal)
- personal shelves (localStorage-only for the exploration) with
  project cards, paging at 8+ per shelf, move-to-shelf via the
  per-project menu
- "Everything else" list with sort control, inline Rename/Peek for
  unnamed projects, and plumbing (doc ID, share link) behind the menu
- New project / Connect / Import flows reworked as dialogs

A persisted toggle (qh-ui-variant) switches between this and the
classic UI so both can be UX-tested side by side.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Project cards and rows are draggable; shelf sections and the
"Everything else" list are drop zones with a dashed teal highlight.
Drop zones key off a custom dataTransfer MIME type rather than React
state so dragover recognition doesn't race the state flush. Also makes
the "+ New shelf" button dashed to match the Figma design.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Shelf display order is lastAccessed descending regardless of when a
project was added to the shelf, so paging always walks toward older
projects. Last-opened stands in for "recent edits" until per-project
edit attribution is exposed from automerge history.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…peek

Colored initial disks per the design's shared-shelf and peek sections.
Contributors are spoofed for the exploration: a deterministic fake crew
seeded from each project's doc id (stable across reloads), with the
real user always first in their cursor color. collaboratorsFor() is the
seam where real attribution data plugs in later (automerge history or a
recent-editors summary on the index doc).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ite links

Shelves start personal (no people signal). "Share shelf" in the shelf
menu converts one to shared: a small people glyph + member facepile
appear, and clicking them opens a popover with the member list (Owner
badge, mock Remove, Leave), a copyable invite link, and an honest note
that anyone with the link can join and edit.

The invite drives a real #/join-shelf/ route. The link carries the
shelf's project entries (real doc ids + sync servers), so joining in
another browser delivers projects that genuinely sync; a fresh browser
skips project-set setup entirely (the set is auto-created silently)
and is asked only for a name and cursor color. Shelf membership itself
remains mock/local until shared shelves become synced docs (Phase 2).

Shared-shelf menu adopts the design's leave-vs-remove semantics
(Leave = your list only; Delete for everyone never deletes projects).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Product-language change across the exploration UI: all user-facing
copy, the invite route (#/join-collection/), and code identifiers
(useCollections, Collection, CollectionMember, CSS classes). Existing
data survives via a one-time localStorage fallback that reads the old
qh-shelves-v1 key when qh-collections-v1 is absent. Old join-shelf
invite links are not honored (none in circulation beyond tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…opover

Private collections show a single dimmed chip (you); shared ones keep
the people glyph and full-strength member facepile. Clicking the chips
always opens the members-and-share popover: shared collections list who
has access, private ones read "Private — only you" and copying the
invite link is the act that turns sharing on (seeding mock membership,
as before).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tion

Every move path (drag-and-drop and the Move-to-collection menu) now
routes through a gate: when the source collection is shared with other
people, a dialog explains that the move changes their view of the
collection and asks to confirm, with a persisted "Don't show this
again" opt-out (qh-collection-move-warning-dismissed). Private
collections, same-collection drops, and moves into a collection pass
straight through.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
"Download as ZIP" in the project menu connects to the project in the
background, exports its files via the existing exportProjectAsZip
runtime (previously reachable only inside the editor), triggers the
download, and disconnects. The menu shows "Preparing ZIP…" while the
background sync runs. Also renames the avatar-menu backup items to
"Export/Import project list (JSON)" for findability.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
"New collection" was dead in embedded/preview browsers because
window.prompt() throws there — and window.confirm() can auto-accept,
silently skipping destructive guards. Collection naming (create,
rename) now uses real dialogs, and remove/leave/delete flows share a
generic confirmation dialog with a danger-styled action button.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
"Duplicate" in the project menu background-connects to the source,
reads every file (text and binary, base64 for the latter), and feeds
them through the standard creation path as "<name> (copy)", landing in
the editor like any new project. The copy keeps the source's collection
placement via the pending-assignment mechanism. First leg of the
recurring-documents workflow (user templates are the follow-on).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ProjectSetEntry gains an optional summary { fileCount, topFiles,
contributors, asOf } — a per-user cache of "the project as I last saw
it", written by the client whenever a project is open (kept current as
files and presence identities change; self is always included since
presence can lag). Schema helper updateProjectSummaryInSet with tests.

The projects home reads it everywhere:
- cards and rows show "N files · opened X" per the design
- facepiles show real contributors when cached (mock crew otherwise)
- Peek renders instantly from the summary — real file list, contributor
  line, "as of" stamp — and is now on every project's menu, not just
  unnamed rows; a Refresh/Load-details action fetches via a short
  background connection for projects never opened on this device

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…fork button

Duplicate (fork) now opens a dialog before doing anything: the name is
prefilled with "(copy)" appended and selected for easy replacement, and
an Add-to-collection select defaults to the source's collection but can
target any collection or none. A fork-glyph button appears on hover on
every card (next to the menu) and on every row, opening the same
dialog — per the team discussion that fork-from-existing is the primary
creation flow to teach. Also drops the dashed border on + New
collection (review feedback).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Groundwork for collections-as-project-sets (see
claude-notes/plans/2026-07-10-collections-as-project-sets.md):

- ProjectSetDocument gains an optional collection display name with a
  setProjectSetName helper (schema package, tested)
- New CollectionsPointer IDB record: the browser root becomes an array
  of collection pointers, stored under the 'collections' key in the
  existing projectSet store (no DB version bump needed)
- Schema migration v5 (transform-only) converts the legacy singleton
  pointer into a one-element array; the legacy pointer is preserved as
  a safety net, and getCollectionPointers() self-heals lazily for
  robustness against ordering
- Pointer accessors: get/set/add (deduped)/remove, with tests covering
  fresh install, legacy conversion, idempotence, and no-clobber

No user-visible behavior change yet; the UI still runs on the singleton
set until phase 2-4 land.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The service now manages a map of ProjectSetDocument connections (one
per collection) over per-server shared Repos, instead of a single
document on a dedicated Repo. New API: connectCollection(s),
createCollection(name), disconnectCollection, listCollections,
renameCollection, add/remove/move project per collection, and
description/touch/summary updates applied wherever an entry appears.

The legacy singleton API delegates to the first (personal root)
connection, so every existing caller — useProjectSet, the reconciler,
App — works unchanged; all 706 unit tests pass. Also fixes the
init-vs-link connection race class noted in the plan (appending a
collection no longer contends with startup).

Part of claude-notes/plans/2026-07-10-collections-as-project-sets.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n (phases 2b/3)

Full replacement for useProjectSet: reads the collections pointer
array (self-healing from the legacy singleton), connects every
collection document with per-collection failure reporting, names an
unnamed root "My projects", and mirrors the setup/migration actions so
the setup screens can swap over unchanged.

Includes the one-time phase 3 migration: legacy localStorage
collections (qh-collections-v1) become real synced ProjectSetDocuments
named after them, entries copied from the root superset; the original
JSON is preserved under a -migrated key, never re-imported.

Not yet wired into App — phase 4 swaps App/ProjectsHome/JoinCollection
onto this hook in one move (two hooks sharing the service would fight
over connections).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
icarusz and others added 16 commits July 13, 2026 16:01
…phase 4)

ProjectsHome, App, and the join flow now run on useCollectionSets
instead of localStorage collections:

- Collections come from connected ProjectSetDocuments (props), root
  excluded; "Everything else" = root entries not in any collection
- Move across collections uses the service's cross-doc move; new
  "Add to collection" submenu adds without removing (multi-membership)
- People & invite popover derives people from the projects' cached
  index-doc contributor summaries; the invite link now carries only the
  collection doc id + server (no inlined entries)
- JoinCollectionLanding subscribes to the shared collection document
- New/rename/leave collection act on the real docs; leaving just
  unsubscribes (the document is untouched, matching "nothing is
  destructive")
- Fixes a shared-server readiness bug surfaced by the migration: the
  repo 'peer' event fires once, so collections after the first timed
  out; server readiness is now a latched promise

Removes the dead localStorage useCollections hook. Verified end to end
in the browser: legacy pointer + localStorage collections migrate into
synced docs, survive a fresh reload with no localStorage, and
create/move/share/leave all work; 706 tests pass, build clean.

Closes the implementation in
claude-notes/plans/2026-07-10-collections-as-project-sets.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The projects-home empty-state guard keyed off root entries only, so a
browser that had just joined a shared collection (empty personal root,
populated subscribed collection) rendered "No projects yet" and hid the
collection entirely. Guard now also checks collection entries. Found
during local-prod two-browser join testing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…oject

The join-collection landing was shown only for needs-setup; a browser
with a pre-existing project (needs-migration) fell through to the
"Upgrade: Synced Project List / Migrate Projects" screen, so an invitee
saw a confusing migration prompt with no mention of the collection they
were joining. (Easy to hit: opening a document share link first creates
exactly one such project.)

The invite route now always renders the landing, and the onboarding
effect establishes the personal root for both cases — creating an empty
root (needs-setup) or silently migrating the stray project into a new
root (needs-migration; non-destructive, the legacy store is retained).
A once-per-mount guard prevents a retry loop, since migrateProjects
resets to needs-migration on failure.

Verified on the dev server against the local hub: a needs-migration
browser opening the invite now shows "Quick Hawk invited you to Team
docs", joins, and lands home with the collection's documents; the
migrated project coexists without orphaning or duplication.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Card facepiles fell back to mockCollaborators — a deterministic fake
crew seeded from the doc id — whenever a project had no cached
contributors yet, so a brand-new file appeared to already have other
authors. Now that collections carry real contributor summaries (from
index-doc identities), the mock fallback is misleading.

Facepiles are real-only: show the project's cached contributors, and
when there are none yet, just the current user — never invented
people. Removed the now-dead mockCollaborators module; its face shape
moves to utils/facepile.ts as `Face` (no longer a misnomer).

Verified on the dev server against the local hub: a freshly created
"Todolist" card shows only its creator; existing cards show their real
contributors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author chips on shared collection cards clobbered each other: the peek
summary is stored on a shared collection entry, and updateProjectSummaryInSet
replaced the whole summary object, so whoever edited last became the sole
recorded contributor.

Now the file-shape fields (fileCount, topFiles, asOf) still take the
latest writer's view, but `contributors` are unioned (dedup by name)
with whatever is already stored. Authorship accumulates as collaborators
open a project, matching expectations.

Verified two-browser against the local hub: two users opening the same
shared project both appear as authors. Sequential edits union correctly;
a truly-concurrent first open can momentarily show one author but
self-heals on the next edit by either party (confirmed: reopening as the
first user restored both). Full per-actor-map contributors (zero-window,
mirroring the index-doc identities map) is the follow-up for complete
concurrency safety.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Moved Peek out of the project ⋯ menu (and off the inline row link) onto
a dedicated magnifying-glass icon next to the fork/branch icon on every
card and row. Hovering the icon opens the peek as a floating card and it
stays open while the pointer is over the icon or the popover (short
close delay bridges the gap so the popover's actions stay reachable);
leaving closes it.

The popover anchors to the card/row's left edge so it stays on-screen
in every column, and the card's action row is pinned visible while peek
is open so moving onto the popover doesn't dismiss it.

Verified with real pointer hover on the dev server: hovering the icon
opens the peek (files, contributors, actions), and moving away closes it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Peek's footer duplicated the ⋯ menu (Rename, Open, Remove). Since Peek
is a read-only preview, its footer now keeps only Refresh/Load details
(the one peek-specific action); the footnote points to the ⋯ menu for
acting on the project.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds migrations.test.ts covering the pointer→collections conversion:
v5 is registered as transform-only, a singleton projectSet pointer
becomes a one-element collections array (legacy pointer retained),
re-running is idempotent (no clobber of a grown array), and a fresh
browser with no pointer is a no-op.

Documents the migration in claude-notes/instructions/hub-client-storage.md:
a Migration History table (v2–v5), the projectSet store and its two
pointer keys, why v5 bumps schema-version-only (not DB version) and how
the runner still applies it, plus the app-level localStorage-collections
migration.

Verified live on the dev server: a genuine v4 pre-collections browser
(singleton pointer + a project set with three projects, no collections)
upgrades on reload to a one-entry collections pointer with all projects
preserved in "Everything else"; _meta bumps to v5, migration recorded,
legacy pointer retained.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@posit-snyk-bot

posit-snyk-bot commented Jul 15, 2026

Copy link
Copy Markdown

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

icarusz and others added 2 commits July 15, 2026 18:29
…ctions

Local testing of the collections UI surfaced several issues; this batch
addresses them (all TDD):

- Debug auth gate: on a loopback host, treat /auth/me 401 as "proceed
  read-only" instead of the sign-in dead-end, so the inspector opens
  against an auth-less local hub. Real deployments stay gated.
- Auth-less identity stamping: derive a stable Automerge actor id from the
  local userId when auth is disabled, so setIdentity fires and a document's
  `identities` populate (also fixes local editor attribution). The server
  actor still wins when auth is enabled.
- Root-identity hardening: getCollectionPointers pins the legacy-singleton
  root to index 0, so root is no longer identified by accidental array
  position; self-heals scrambled pointer orders.
- Debug view: list every collection ProjectSetDocument (not just the legacy
  singleton), each labeled with its live name, so all synced collection
  docs are inspectable.
- ProjectsHome polish: fade card hover-actions beneath long titles so the
  peek/fork/menu icons no longer collide with the title; fix "Everything is
  in a collection" copy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Documents 3992e45.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

hub-client: redo the project selection component

2 participants