Skip to content

feat(app)!: tabless — one sidebar, no tab strip (0353) - #564

Merged
crs48 merged 14 commits into
mainfrom
claude/0353-tabless-unified-nav-impl
Jul 18, 2026
Merged

feat(app)!: tabless — one sidebar, no tab strip (0353)#564
crs48 merged 14 commits into
mainfrom
claude/0353-tabless-unified-nav-impl

Conversation

@crs48

@crs48 crs48 commented Jul 18, 2026

Copy link
Copy Markdown
Owner

Implements exploration 0353 — Tabless: removing the tab strip and unifying the left nav (30/30 checklist items).

What changes

The tab strip is gone; a working set replaces it. Navigation was already route-authoritative, so the strip was a windowing system layered over routing that already worked. In its place: Pinned (durable) above Recents (decaying) in the sidebar, Ctrl-Tab to toggle the last two routes, ⌘[ / ⌘] for history, and View: Split with current for side-by-side without tab groups. Durable side-by-side belongs on a page as 0346 frames.

Nine left navs collapse into one tree plus lenses. Channels, people and saved views now live beside documents in a single tree projected through lenses (All / Docs / Chats / People / Views), with type-scoped sort defaults — chats sort by recency with an unread bump, docs keep manual sortKey order so editing never reshuffles them. Muting is one flag that suppresses badge and bump together.

Tabless is the default, and reversible. The v5 migration adopts it for profiles that never chose, but deliberately keeps groups so View: Turn on tabs restores the session it left. The doc records a dogfood tripwire (>3 OS-window duplications/person/week) with that command as the documented fallback.

Defects the validation pass caught

Five real bugs, each found by running the checklist rather than reading it:

  • Wrong title on navigation. useNode keeps the previous node's data while the next id loads, so usePublishTitle published the old title against the new route — wrong header, and a recents entry minted under the new id with the old node's name. Views now pass the loaded record's own id and the publish waits for the data to catch up.
  • Ctrl-Tab opened the wrong document. The remembered pathname is URL-encoded and node ids contain slashes, so navigate({ to }) — which expects a path template — never round-tripped the param. History navigation now resolves through the same descriptor path a click uses.
  • CRM, finance and experiments navigated nowhere. Three TabNodeTypes had no case in navigateToNode's switch — invisible while tabs carried navigation, load-bearing once the route is authoritative. Added, plus a never-typed default so the next type can't be added without one.
  • The split never rendered. It put a Hairline (a resizable-panels Separator) in a plain flex row; Separator throws without a Group ancestor, so the whole editor subtree hit the router's catch boundary silently. Now a real Group/Panel pair, which also makes the divider draggable. It also split with recents[0] — always the node already on screen — so it would have shown a document beside itself.
  • Mobile would have shipped a blank header. MobileShell read title, icon and share target from selectActiveTab, empty when tabless. It now derives the same route-based descriptor the desktop header uses, so mobile adopts tabless rather than deferring it.

Verification

  • pnpm vitest run apps/web/src — 845 tests, 118 files, all green
  • pnpm --filter xnet-web typecheck clean (this is what proves the navigateToNode switch exhaustive)
  • New coverage: route round-trip smoke test over every TabNodeType, the stale-title guard, the v5 migration, and the sidebar sort/mute contracts
  • Verified live in the browser: v4→v5 migration lands in tabless, Ctrl-Tab lands on the right doc, pins survive reload, the split shows two documents side by side and Esc closes it, and the mobile header shows the right title and Share

🤖 Generated with Claude Code

xNet Test and others added 12 commits July 18, 2026 14:14
…e tab strip (0353 P1)

Phase 1 of exploration 0353. Tabs stay ON by default; the tabless path
is complete and switchable via ⌘K ('View: Turn off tabs').

- `tabsEnabled` store preference. When off, EditorArea renders the
  router outlet directly — no strip, no editor groups, no
  preview/promote — and useTabCommands/useTablessCommands swap so the
  two modes never contend for the same chords.
- Route-derived titles: views publish through usePublishTitle (one call
  replacing the 8 setTabTitle sites) into routeTitles; EditorHeader
  resolves 'what am I looking at' from the route when tabless, from the
  active tab when not. Share/Open-with/breadcrumb all follow.
- Chords: ⌘[ / ⌘] walk router history, Ctrl-Tab toggles the recent two
  routes (routeHistory, capped at 2 — a toggle, not a stack), ⌘W
  returns home, ⌘T still creates a page.
- Recents decouple from tab opening: trackRouteVisit runs on every
  route change in BOTH modes; syncRouteToTabs keeps refining entries
  with tab titles (touchRecent dedupes by node id).
- WorkingSet: Pinned (durable, user-curated) above Recents (automatic,
  decaying) always visible in the sidebar — the tab strip's replacement,
  non-destructive by construction so nothing is ever lost by navigating.
- SplitPane: side-by-side as a LAYOUT concern — the secondary pane is a
  live 0346 frame over one node, not a tab group; closing it orphans
  nothing. splitTarget is ephemeral (never persisted/restored).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: xNet Test <test@xnet.dev>
…rt (0353 P2)

Phase 2 of exploration 0353: nine hand-rolled navs get one shape.

- Contracts (sidebar/contracts.ts): SidebarRowModel / SidebarRowSource /
  SidebarLens. Rows carry their own sort policy and ONE mute flag;
  effectiveBadge + shouldBump make mute suppress badge AND unread bump
  together (shipping those independently is the recurring bug class
  Telegram/Teams document).
- Registry (sidebar/registry.ts): sources and lenses register like slot
  views, so adding a content type to the sidebar is a registration, not
  a tenth bespoke nav. sourcesForLens bounds what a lens may mount.
- Built-in sources (sidebar/sources.tsx): documents (pages, databases,
  canvases, dashboards, maps — manual order), channels/DMs (recency +
  unread bump, reusing ChatsPanel's exact isUnread rule for badge
  parity), people, saved views. Lenses: All, Docs, Chats, People, Views.
- UnifiedTree: lens chips replace the Explorer's type filters AND the
  surface switcher — picking Chats is the same gesture as picking Docs.
  Keyed by lens so only the active lens's sources mount (an unselected
  lens costs zero queries — the per-schema fan-out is real, 0317) and
  the hook loop stays stable across renders.
- Registered as the 'tree' slot view alongside Explorer during
  migration; store gains activeLensId + mutedRowIds.

Tests pin the semantics that make mixed types coexist: docs never
reshuffle on edit, unread chats float, muted unread does NOT jump the
queue, and manual order compares CODE UNITS (never localeCompare — the
fractional sortKey invariant).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: xNet Test <test@xnet.dev>
Phase 3 of exploration 0353: nine navs collapse into one.

- User sections (sidebar/sections.ts) replace SURFACES + navPinned +
  the More roll-out. A section points at a lens, a route, or a pinned
  node — one grammar, no panel/route fork, so a feature area can no
  longer mint its own nav (the Teams stacked-rail anti-pattern the
  exploration names). Per-user and reorderable, following Slack custom
  sections / Linear personalized sidebar.
- The bottom island renders the UnifiedTree under the active lens
  instead of switching between seven bespoke panels: Chats, Tasks,
  Today and Data panels retire into lenses/routes; AI stays a dock
  (a companion, never a nav surface).
- SectionsMenu replaces SurfacesMenu behind the same preference, so
  pinning still curates what stays visible.
- Unknown persisted section ids resolve away rather than crashing a
  shell shared across builds (the 0280 migration lesson).

Live-verification fix: the registry's getSources/getLenses returned a
fresh array per call, which made useSyncExternalStore loop forever and
blanked the shell. Snapshots are now rebuilt once per change and
returned by identity.

Verified in the browser: section rows + lens chips render, the Chats
lens shows channels/DMs where documents were, navigation works with no
tab strip, 0346 frames still render inside the opened doc, and the
console is clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: xNet Test <test@xnet.dev>
…353 P4)

Phase 4 of exploration 0353: the stragglers that would have made 'one
sidebar' hollow.

- CRM's internal CrmTab bar was a second, unrelated tab system inside a
  route. Its views are now addressed by the route's ?view= search param
  and rendered with the shared LensChips primitive — linkable,
  palette-reachable, and no longer a tab bar. Falls back to local state
  where the surface doesn't route (tests, embeds).
- NavRow: the one primary-row primitive. Section rows and the Settings
  section list were byte-identical markup in two files; both now render
  through it. Divergent row markup is how nine navs happened, so the
  primitive is the mechanical half of keeping one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: xNet Test <test@xnet.dev>
Phase 5 of exploration 0353 — the default flips.

- tabsEnabled defaults to false: the workbench opens as one surface
  with the working set in the sidebar. Store migration v5 adopts it for
  profiles that never chose and seeds the unified-nav defaults.
- Deleted Sidebar.tsx and Rail.tsx (the shipping floating shell never
  mounted either) plus their slot registrations, and removed the
  now-dangling placements from the @xnetjs/plugins preset trees; the
  default tree's left dock leads with the unified 'tree' view. The rail
  REGION stays as a placement target for user-moved views.
- frame routes documented as plain routes when tabless.

TWO DELIBERATE DEVIATIONS from the doc's Phase 5, both flagged for
review:

1. The tab code path is KEPT behind the preference rather than deleted.
   The doc's own validation checklist records a dogfood tripwire (the
   Notion failure signature — users escaping to OS windows), and a
   tripwire needs something to fall back to. 'View: Turn on tabs'
   restores the old shell; deleting TabBar/groups now would make the
   rollout a one-way door. Removal should follow a dogfooding period.
2. calm/CalmSurface.tsx is NOT deleted despite being listed as a
   dormant remnant: ShellFrame still renders it on the focus/zen path.
   The survey's 'dormant' label held for Sidebar/Rail, not for it.

Migration design note: v5 deliberately KEEPS persisted groups. Wiping
them would mean turning tabs back on lost your session — the opposite
of a reversible rollout. Tested.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: xNet Test <test@xnet.dev>
Two defects the 0353 validation pass turned up, both only visible with
tabs off:

- `useNode` keeps the previous node's data while the next id loads, so
  `usePublishTitle` published the old title against the new route — the
  header showed the node we just left and recents minted an entry under
  the new id with the wrong name. Views now pass the loaded record's own
  id and the publish waits for the data to catch up.
- The recent-two toggle handed an already-encoded pathname to
  `navigate({ to })`, which expects a path template; node ids contain
  slashes, so the param never round-tripped and Ctrl-Tab left the
  previous document on screen. History navigation now parses the
  pathname back into a descriptor and reuses `navigateToNode`.

Signed-off-by: xNet Test <test@xnet.dev>
Three TabNodeTypes had no case in the switch, so navigating to them did
nothing at all — invisible while tabs carried navigation, load-bearing
now that the route is authoritative and the tabless history chords
resolve a remembered route through this same switch. Adds the missing
cases and a never-typed default so the next tab type can't be added
without one.

Signed-off-by: xNet Test <test@xnet.dev>
Two defects, both caught running the 0353 split validation:

- The split pane put a `Hairline` — a resizable-panels Separator — in a
  plain flex row. Separator throws without a Group ancestor, so the whole
  editor subtree hit the router's catch boundary and the split silently
  never appeared. The panes are now a real Group/Panel pair, which also
  makes the divider draggable like every other workbench split.
- The command split with `recents[0]`, which is always the node already
  on screen, so it would have shown a document beside itself. It now
  picks the most recent entry that isn't the current route.

Signed-off-by: xNet Test <test@xnet.dev>
The mobile header read its title, icon and share target from
`selectActiveTab`, which is empty once tabs are off — a tabless phone
would show a bare "xNet" and a share button wired to nothing. It now
derives the same route-based `current` descriptor the desktop header
uses, so mobile adopts tabless rather than deferring it.

Signed-off-by: xNet Test <test@xnet.dev>
The 0273 disclosure ladder walks Esc down bottom → right → left dock. The
split pane is the newest surface and was not on it, so the only way out
was the close button or the palette. It is now the first rung, matching
the ladder's most-recent-first shape.

Also adds the route smoke test the 0353 checklist asks for: every
TabNodeType round-trips routeForTab → tabFromPathname, which is what
makes tabless navigation and the history chords safe.

Signed-off-by: xNet Test <test@xnet.dev>
…ing the left nav

Signed-off-by: xNet Test <test@xnet.dev>
LabView publishes its title against the route (0353), so it now reads
router state — which the standalone test harness doesn't provide. Stubs
`useLocation` rather than standing up a router the test doesn't need.

Signed-off-by: xNet Test <test@xnet.dev>
@crs48
crs48 temporarily deployed to pr-564 July 18, 2026 22:05 — with GitHub Actions Inactive
github-actions Bot added a commit that referenced this pull request Jul 18, 2026
@github-actions

github-actions Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

🖼️ UI changes in this PR

Screens

✏️ CRM _(SSIM 0.936)_
before after diff
before after diff
✏️ Home _(SSIM 0.865)_
before after diff
before after diff

Interactions

🎬 Build a CRM quote (product + line item)

Build a CRM quote (product + line item)

▶ Watch MP4

Auto-captured by CI · run. Informational — not a blocking check.

github-actions Bot added a commit that referenced this pull request Jul 18, 2026
github-actions Bot added a commit that referenced this pull request Jul 18, 2026
@github-actions

github-actions Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Preview removed for PR #564.

The surfaces roll-out put the pin toggle inside the row button, so the
row's accessible name became "DiscoverPin" — one control announcing two
actions, with the pin unreachable on its own by keyboard. It broke the
safety-ui E2E, which locates the row by its exact name, and it would have
read the same way to a screen reader. The pin is now a sibling button
with its own label; the row is just its label again.

Also formats two files the tabless work left unformatted.

Signed-off-by: xNet Test <test@xnet.dev>
@crs48
crs48 temporarily deployed to pr-564 July 18, 2026 22:18 — with GitHub Actions Inactive
github-actions Bot added a commit that referenced this pull request Jul 18, 2026
github-actions Bot added a commit that referenced this pull request Jul 18, 2026
Eight views stopped calling `useWorkbench`/`useEffect` directly when they
moved to `usePublishTitle` (0353 P1), but kept the imports — which fails
lint.

Signed-off-by: xNet Test <test@xnet.dev>
@crs48
crs48 temporarily deployed to pr-564 July 18, 2026 22:32 — with GitHub Actions Inactive
github-actions Bot added a commit that referenced this pull request Jul 18, 2026
@crs48
crs48 merged commit 1dd3f03 into main Jul 18, 2026
21 checks passed
@crs48
crs48 deleted the claude/0353-tabless-unified-nav-impl branch July 18, 2026 22:50
github-actions Bot added a commit that referenced this pull request Jul 18, 2026
crs48 added a commit that referenced this pull request Jul 19, 2026
…icon (#582)

Exploration doc only — no code changes, no changeset needed.

## What this explores

What xNet looks like as an OS-level facility rather than an
application-layer
library: a Rust systems crate, a system daemon, a filesystem, or
silicon.

## Three findings that reframed it

**1. The Rust kernel already exists and is unwired.** `rust/xnet-core/`
is a
414-line crate with four dependencies that already implements identity,
canonical JSON, change hashing, Ed25519, LWW, negotiation, and authz —
and
passes the same golden vectors as the TypeScript, Swift, and Python
kernels.
Its README claims it is the only implementation that reproduces a
TypeScript
signature byte-for-byte. The blocker is a `crate-type = ["lib"]` line
and an
unavailable uniffi toolchain. `0200` (the spec boundary) is `[x]`; the
binding
never landed.

**2. The filesystem is where this idea has died for thirty years**, and
not for
the usual reasons. Optimistic replication works — Coda proved it in
1992. What
killed FS-level sync: Ficus dispatched merge logic by *regex on the
filename*
because Unix has no types; Coda's entire vocabulary for "a human must
decide"
was to make the file look like a broken symlink; and Microsoft owned the
Windows
FS virtualization driver and abandoned it for their own flagship use
case
(Scalar matched VFS-for-Git in one-tenth the code once the real,
application-level
bottleneck was fixed). The mismatch is at the *interface*, not storage —
POSIX
has nowhere to put two concurrent heads.

**3. "Bake it into the processor" is a clean no.** No Ed25519
instruction on any
shipping CPU (x86, ARMv9.7, ratified RISC-V all cover symmetric crypto
only).
Verify throughput flat since Skylake 2015. A BlueField-3's entire PKA
subsystem
loses to one x86 core doing Ed25519 in software.

## The actionable one

Chrome's whole-fleet Windows telemetry puts TPM ECDSA at **P50 200ms —
five
signatures/second**, which kills per-change hardware signing (~560x
slower than
our software path). But **0357's `BatchCommit` inverts it**: one
hardware
signature over a 1000-change root amortises to **0.2ms/change, cheaper
than the
357µs software Ed25519 it replaces**. The same arithmetic makes
post-quantum
affordable — ML-DSA's 2,420-byte signature goes to ~2.4 bytes/change
amortised.
This argument is only available because the batch envelope already
shipped.

## Recommendation

A daemon and an attested identity — not a filesystem and not a chip.
Wire the
Rust kernel (L1), design `xnetd` (L2), prototype an FS projection
read-only,
and reject the kernel-FS and silicon rungs in writing.

## Charter §6

Applies the three no-ground-rent tests to the one tempting lane this
opens
("attestation-as-a-service"). It **fails all three** — attestation
binding is a
protocol fact computable by anyone holding the two public keys. Recorded
as a
refusal in writing before anyone builds it. Attestation stays MIT,
self-hostable, verifiable offline.

## Documented but explicitly not actioned

BLAKE3 is likely a small regression vs SHA-256+SHA-NI on our ~554-byte
envelopes (the famous 12x was measured on a CPU without SHA-NI, and
BLAKE3 has
no SIMD parallelism below 4KiB). Changing it is a protocol major across
four
kernels for a sub-millisecond win on a 357µs operation — documented as a
non-action, revisit only if protocol v5 opens the envelope anyway.

## Note for reviewers

Originally numbered 0358; this branch was cut from a stale main (PR #564
era)
so the local number scan missed 0358–0367. Renumbered to **0369** after
scanning all remote branches and worktrees — 0368 is claimed by an
unmerged
food-forests exploration.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
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.

1 participant