feat(app): Published tab + Settings → Account + cross-device sync#363
Merged
Conversation
- LATEST_SCHEMA_VERSION bumped to 12 to match the migration shipped in this PR; the smoke tests that compare against the constant would otherwise miss the new table on a fresh install. - Rename `expiresSoon` → `hasExpiry` in SharesPage; the flag is true for any future expiry, not just "soon", and a glance at the call site was misleading. - Replace SettingsAccount's post-claim window.location.reload() with a useShareAuth().refresh() call. The hook now exposes a refresh() that re-fetches /api/me. - Rename usePublishedShares' `mergeRemoteIntoCache(_local, remote)` to `remoteToCacheItems(remote)` — the local arg was unused and the merge implication was misleading; remote is the source of truth. - Drop unused publish-logic comments and tighten the unmount race in usePublishedShares (aliveRef instead of a closure flag, so setItems inside refresh() also respects it).
When the user scheduled deletion on web and opens the desktop app during the 24h grace window, the existing reset-on-user-change effect clobbered `deletionStatus` back to `idle`. Result: no Cancel CTA, no surface to recover from. Seed the status from `user.deletion_pending_until` (now exposed by /api/me) so a cross-device pending deletion lights up the Cancel button on first paint.
Account pane and 'Published' tab signed-out state both reuse the shared ConnectCard (settings) / outline Google button (shares page). Removes the inline duplicate 'Sign in to Spool Share' card on Settings → Account in favour of the same component PublishModal now uses, so the three sign-in entry points read consistently.
…w polish Settings rail: 220px width (was 176), 22px vertical / 14px horizontal padding, h-9 / gap-11 tab buttons, accent-bg active state with dedicated dark accent-bg-dark token. Per the desktop design handoff layout. SharesPage Published row: rounded-7 (was 6), opacity-55 on revoked rows, title size pinned at 14px to match the handoff PubRow visual hierarchy.
- SettingsPanel: pin Account tab to the bottom of the rail (after Labs
+ Security) so toggling the sharePublish flag doesn't shift the
feature tabs vertically — matches the GitHub / Slack settings
convention where identity sits below configuration.
- SharesPage: when sharePublish is off, render a "Drafts" label in the
same visual slot the tab strip would occupy. The prior placeholder
was an empty <div className="h-6" />, so the header collapsed to a
lone + icon, losing the section's identity.
- labsFlags: add 'sharePublish' to the LabsFlag union. The flag was
already referenced by useFeature('sharePublish') in SettingsPanel
+ SharesPage on this branch, but the type definition wasn't moved
down from launch-prep — TS broke from PR 8 through PR 11.
- SharesPage PublishedList: after the empty-state Sign in CTA fires, call refresh() so the list actually populates. The hook ran its initial fetch with no token, got 401, and silently returned empty; without a re-trigger the list stayed blank even after auth. - SharesPage PublishedList: add a window 'focus' listener that calls refresh() when the user re-focuses the app. Picks up remote revocations (user opened spool.pro/me and unpublished there) that the desktop would otherwise miss until restart. - SharesPage PublishedList: replace the blank `return null` loading branch with a 3-row skeleton so network fetches show feedback. - SharesPage PublishedRow: fix the expiresSoon condition — it was triggering on any future expiry (year-from-now included), defeating the badge's intent. Now gates on <7 days remaining. - SettingsAccount: debounce the handle availability check (320ms, matching the web /me convention) + add a sequence counter so out-of-order responses can't stamp a stale 'available' over a fresh 'taken' when the user types fast. Previously fired one IPC + network call per keystroke.
The Account tab in Settings and the Published tab in Shares both
gate on `sharePublish`. This commit moves them off the LabsFlag
system (useFeature('sharePublish')) and onto the dedicated
useSharePublish() build-time helper introduced on PR 7.
Reasoning: sharePublish is a pre-launch build-time flag, not an
end-user opt-in. Labs' tri-state (localStorage "0" overrides env)
created a "stale labs pinned the flag off across dev runs" DX trap
— a contributor flipping the env var couldn't see the surface until
they manually cleared localStorage. The new helper is pure env, no
labs, no localStorage. At GA the helper body flips to `return true`.
Also reverts 'sharePublish' from the LabsFlag union; that addition
existed only to satisfy useFeature on this branch and is no longer
needed.
The Settings → Account panel's delete flow now opens DeleteAccountConfirmModal — the same centered modal pattern the share editor uses for unpublish. Inline click-twice cards inside a settings surface signal too little weight for an irreversible account-level action, and the prior treatment couldn't fit the destructive copy without truncating button text on common widths. The modal carries the full 24-hour cool-off explainer and Esc / outside-click dismiss. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This was referenced Jun 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The renderer side of "what I've already published, and what my account looks like" — the surfaces a signed-in user spends time in after they've made a share.
Drafts← existing |Published← new). The Published tab lists the user's live shares with title, slug, status, last republish time, copy-link, view, unpublish — sourced from the newpublished_shares_cache.published_shares_cache(schema v15): local mirror of/api/me/sharesso the Published tab renders instantly on cold start and stays readable offline. NOT the source of truth — D1 on the backend is. Rows are upserted whenevermyShares()succeeds; entries dropped from the remote response are pruned viaclearAll + upsertMany.usePublishedShareshook (with regression test): SWR shape with optimistic mutation generation counter — a stalemySharesresponse can't clobber an in-flight local revoke/republish.DeleteAccountConfirmModal: centered confirm dialog mirroring the publish-sideUnpublishConfirmModal— destructive actions get the same visual weight.cached-published,cache-published,get-published-by-draft,schedule-delete,cancel-delete.Why
A share's lifecycle doesn't end at publish. The user comes back to read, copy, share with someone else, edit, republish, unpublish. None of that worked before this PR; the editor's Share menu could publish but the renderer had no surface to list what you'd published. Bundling these two surfaces lets one cache (
published_shares_cache) serve both the Published tab and the editor's "is this draft already published?" lookup.Cache-first matters because:
Network errorwhen the user just wants to copy a slug they published last week/api/me/sharesand reconcilesData model
flowchart LR subgraph local["local (better-sqlite3)"] SD[(share_drafts)] PSC[(published_shares_cache<br/>v15)] end subgraph remote["backend D1"] PS[(published_shares)] end SD -- draft_id --> PSC PSC -- draft_id --> PS subgraph renderer PT[Published tab] EM[Editor manage view] SA[Settings Account] end PT --> PSC EM --> PSC SA --> A[GET /api/me] PSC -.SWR.-> MS[GET /api/me/shares] MS -.upsertMany.-> PSCPublished tab — SWR with optimistic guard
sequenceDiagram autonumber participant U as User participant ST as SharesPage / Published participant H as usePublishedShares participant C as published_shares_cache participant API as /api/me/shares ST->>H: mount H->>C: listAll() — instant C-->>H: cached rows H-->>ST: render rows H->>API: fetch (SWR) Note over H: localMutationGen captured at fetch start par concurrent revoke U->>ST: click Unpublish ST->>H: noteLocalMutation() H->>H: mutationGen++ ST->>API: POST /api/revoke/:id ST->>C: markRevoked(slug) end API-->>H: { shares: [...] } (started before mutationGen++) H->>H: localMutationGen != current → drop response Note over H: Without the guard, the stale response would<br/>resurrect the just-revoked row in the cacheAccount deletion lifecycle (renderer side)
stateDiagram-v2 [*] --> Idle: /api/me deletion_pending_until = null Idle --> ConfirmModal: click "Delete account" ConfirmModal --> Idle: cancel ConfirmModal --> Scheduling: confirm Scheduling --> Pending: 200 from POST /api/me/delete Pending --> Cancelling: click "Keep my account" Cancelling --> Idle: 200 from DELETE /api/me/delete Pending --> Pending: render countdown ("Executes in 23h 45m") Pending --> Tombstoned: grace expired, worker ran<br/>(handled in PR #360) Tombstoned --> [*] Idle --> Pending: deletion_pending_until seeded<br/>from /api/me (cross-device)The deletion banner's seed-from-server step matters: if the user scheduled deletion on web at 10:00 and opens the desktop app at 10:15, the desktop surface must show the pending banner and the Cancel CTA. Without this, the desktop UI would look idle and the user couldn't recover within the grace window.
Schema v15
Gating (prod-safe)
Same as PR #361/#362: the Published tab + Settings Account section render through
useSharePublish(), which isfalsein prod (Vite inlinesVITE_FEATURE_SHAREPUBLISHat build time, CI doesn't set it, Terser strips every guarded branch). The schema migration runs on every launch (additive, no-op if v15 already applied), but until the gate flips no UI exposes the new cache.SettingsPanel.tsxonly includes theAccounttab whenuseSharePublish()is true.Verification
pnpm --filter @spool-lab/core test— published-shares-cache.test.ts (insert / upsert / list / clearAll / by-draft lookup) + migration-v15pnpm --filter @spool/app test— usePublishedShares.test.ts (the localMutationGen guard) + SettingsAccount testspnpm --filter @spool/app test:e2e— share-published-tab specs (existing list, revoke triggers row strike-through, copy-link uses the live URL, empty state when never published)VITE_FEATURE_SHAREPUBLISH=1, publish two drafts → Published tab lists both; revoke one → tombstone; restart app offline → both rows render from cache; schedule delete in web → desktop banner appears on next foregroundRisk
Schema migration is additive (
CREATE TABLE IF NOT EXISTS). The cache layer is opt-in via the gate; until enabled, the rows never get written and the surfaces never render. No callers inmainreach into the new IPCs.Submitted by @graydawnc.