Skip to content

feat(pulse): alternate mobile interface at /m, sharing the desktop's components - #1697

Open
elhoim wants to merge 3 commits into
danielmiessler:mainfrom
elhoim:feat/pulse-mobile-interface
Open

feat(pulse): alternate mobile interface at /m, sharing the desktop's components#1697
elhoim wants to merge 3 commits into
danielmiessler:mainfrom
elhoim:feat/pulse-mobile-interface

Conversation

@elhoim

@elhoim elhoim commented Jul 29, 2026

Copy link
Copy Markdown

Pulse's dashboard is built for a 1600–1920px canvas. On a phone the header collapses to a hamburger but the content keeps desktop density, and the only navigation is one long drop-down. This adds a phone interface at /m that shows the same information with the same structure — because it renders the same components.

Noting up front that the public repo is generated from a private source tree and community PRs get ported rather than merged — this is written to be easy to port: the mobile layer is almost entirely additive, and the four desktop files it touches take one guarded change each.

The idea

/m/health isn't a mirror of /health. It is /health:

// src/app/m/health/page.tsx — generated
export { default } from "@/app/health/page";

There is no markup on the mobile side that could drift from the desktop, because there is no markup. scripts/gen-mobile-routes.ts discovers desktop routes from the filesystem, so an install with more pages generates more routes automatically.

What's actually mobile is the layer around it:

  • Density is a React context, not a media query. The shared chrome primitives (PageShell/Panel/PanelHeader/StatTile/TabBar/Pill/EmptyState) read it and tighten themselves. Desktop routes render with no provider above them, so they take the default branch and are unchanged. A media-query approach would have meant editing all 33 route pages; this touches the primitives once.
  • Phone shell — compact top bar, thumb-reachable bottom tab bar, slide-up sheet with every page.
  • Navigation derives from the existing nav-manifest. Add a page there and it appears in the mobile menu with no mobile-side edit.

TELOS is the one exception, and it's bounded

The desktop stacks twelve sections into a single scroll. That's the wrong structure on a phone rather than a wrong amount of detail — so /m/telos is a section index and each section gets its own route. Both surfaces render from one shared section manifest + registry, so neither can hold a section the other lacks. A route earns a hand-written mobile treatment only by being named in HAND_WRITTEN in the generator.

Rollback

Scope How
One visitor Tap the monitor icon — pins the desktop UI for that browser. The desktop header carries the reverse toggle.
Whole layer bun LIFEOS/PULSE/Tools/MobileUi.ts disable — no phone is redirected, and /m sends visitors to the desktop route.
Total removal MobileUi.ts status prints the exact paths to delete and files to revert.

Desktop is untouched: zero route pages under src/app/<page>/page.tsx are modified. The five touched shared files are app/layout.tsx (redirect + CSS import + viewport), AppHeader.tsx (early-return on /m, one affordance), ui/chrome.tsx (density branches), telos/_v7/app.tsx (the twelve-section stack extracted to the shared registry, same order, same props), and wiki/WikiMeta.tsx (the metadata rail became collapsible — purely additive, and desktop keeps its expanded default). One further file, modules/wiki.ts, is a backend fix that is not mobile-specific and may be worth porting separately; see the commit and comments below.

Cost

  • Hand-written mobile layer ≈ 1,000 lines presenting ≈ 11,300 lines of desktop pages.
  • Each mobile page costs ≈ 10 kB more JS than its desktop twin (the shell) rather than bundling every page. An earlier catch-all design pulled 2.1 MB of chunks for /m/health against 605 kB for /health — per-route files fixed it, and a test now pins the shape.
  • No new dependency.

Testing evidence

Built clean from a fresh checkout of this repo's tree plus this commit: ✓ Generating static pages (77/77), exit 0.

tests/mobile-registry.test.ts — 17 assertions, all passing — covering route coverage, re-export purity (every generated route file must match export { default } from "@/app/…"; and nothing else), manifest-derived navigation, section/renderer parity, layer size, and a regression guard described below.

Verified in real Chrome at a 390×844 viewport:

  • No horizontal page scroll, and no element wider than the viewport outside a scroll container, across the tier-1 routes.
  • Thumb bar position: fixed with its bottom edge exactly at the viewport bottom.
  • All twelve TELOS sections rendering real content.
  • Burger sheet listing every page (12 TELOS sections + 12 sections + meta + system); taps navigate to the right routes.
  • Redirect and both toggle directions driven by real clicks and surviving reload.
  • Density measured at an identical 390px viewport for both interfaces, so the delta can only come from the context: panel padding 15px → 11.25px, shell gap 22.5px → 13.125px, stat value 28px → 21px.
  • Desktop re-checked at 1600px: no mobile shell, header present, and computed padding/gap/type equal to the untouched source literals.

Three bugs were caught by that browser pass and are fixed here — worth flagging because two of them passed every non-browser check:

  1. A dynamic app/m/telos/[section] route with generateStaticParams compiled, exported twelve HTML files and served 200s, then threw a client-side exception on hydration on every one. Error boundaries around the page body and the whole route both failed to catch it; the neighbouring static route was fine. Static generated routes are the fix, and a test guards against the dynamic variant returning.
  2. AppHeader's title observer kept running on /m — the early return stops rendering, but hooks still run.
  3. Two flex-sizing bugs clipped the top-bar title and every thumb-bar label. DOM queries returned the full strings because the text was intact and only the rendering was clipped; only a viewed screenshot showed it.

Not run: a fresh-system install verification. This was developed and verified against a working install and a clean checkout build, not a from-scratch install.sh run.

Note for reviewers

This branch is built against this repo's page set. nav-manifest lists /projects and /security, but neither src/app/projects/page.tsx nor src/app/security/page.tsx is present here — those are already dead links on desktop in this repo. The mobile tree correctly declines to fabricate pages that don't exist, and the coverage test is gated on the desktop page existing, so on an install that has them the generator produces their mobile routes automatically. Possibly a public-release packaging gap worth a separate look; out of scope here.

Pulse's dashboard is built for a 1600-1920px canvas. On a phone the header
collapses to a hamburger but the content keeps desktop density, and the only
navigation is one long drop-down. This adds a phone interface at /m that shows
the same information with the same structure — because it renders the same
components.

How it avoids becoming a second copy of Pulse:

- Every mobile route is a generated file that re-exports the desktop page
  (`export { default } from "@/app/health/page"`). There is no markup on the
  mobile side that could drift from the desktop, because there is no markup.
  `scripts/gen-mobile-routes.ts` discovers desktop routes from the filesystem,
  so an install with more pages simply generates more routes.

- Density is a React context, not a media query. The shared chrome primitives
  (PageShell/Panel/PanelHeader/StatTile/TabBar/Pill/EmptyState) read it and
  tighten themselves. Desktop routes render with no provider above them, so
  they take the default branch and are unchanged.

- Navigation derives from the existing nav-manifest. Adding a page there puts
  it in the mobile menu with no mobile-side edit.

TELOS is the one exception, and it is bounded. The desktop stacks twelve
sections into a single scroll, which is the wrong structure on a phone rather
than a wrong amount of detail — so /m/telos is a section index and each section
gets its own route. Both surfaces render from one shared section manifest +
registry, so neither can hold a section the other lacks. Routes that earn a
hand-written mobile treatment must be named in HAND_WRITTEN in the generator.

Rollback is first-class:

- Any visitor: tap the monitor icon to pin the desktop UI for that browser.
  The desktop header carries the reverse toggle.
- Whole layer: `bun LIFEOS/PULSE/Tools/MobileUi.ts disable` — no phone is ever
  redirected, and /m sends visitors to the desktop route. `status` prints the
  exact paths to delete for total removal.
- Desktop is untouched: zero route pages under src/app/<page>/page.tsx are
  modified. The four touched files each take a guarded early-return, a single
  added element, or a mechanical extraction.

Sizes: the hand-written mobile layer is ~1000 lines presenting ~11,300 lines of
desktop pages, and each mobile page costs ~10 kB more JS than its desktop twin
(the shell) rather than bundling every page.

Verified in real Chrome at a 390x844 viewport: no horizontal page scroll and no
element wider than the viewport outside a scroll container across the tier-1
routes; thumb bar fixed at the viewport bottom; all twelve TELOS sections
rendering; the burger sheet listing every page; the redirect and both toggle
directions surviving reload. Desktop re-checked at 1600px — computed padding,
gap, and type sizes equal the untouched source literals.

Includes tests/mobile-registry.test.ts (17 assertions) covering route coverage,
re-export purity, manifest-derived navigation, section/renderer parity, layer
size, and a regression guard against the dynamic [section] route that built
fine and died on hydration.
…ngside

Reported as "when viewing knowledge in the mobile view, I cannot see an entry".
Reproduced, and it turned out to be three separate faults, one of which is not
mobile-specific at all.

1. RESEARCH NOTES WERE UNREACHABLE ON BOTH INTERFACES (modules/wiki.ts)

`handleKnowledgeNote` validated the domain against its own hardcoded map of
people/companies/ideas/blogs/books. `research` was missing from it. So all 669
Research notes indexed correctly, appeared in the tree, and were counted in the
stats — while opening any one returned `404 Invalid knowledge domain
"research"`. Desktop `/knowledge?category=research&slug=…` showed a permanent
"Loading..." exactly like mobile did. Research dominates Recent Changes, which
is why it looked like "entries don't open".

The domain table existed in three hardcoded copies in this one file
(indexKnowledgeArchive, the tree builder, handleKnowledgeNote) and only the
third had drifted. Fixed by making one KNOWLEDGE_DOMAIN_TABLE the source and
deriving all three, so a domain cannot be half-added again. The lookup now also
accepts the directory name and any casing, since links carry the category
("research") while bookmarks tend to carry the directory ("Research").

2. PLANE SWITCHING DROPPED THE QUERY STRING (lib/mobile/config.ts)

`toMobilePath`/`toDesktopPath` operated on the path only. Knowledge, Docs and
TELOS all identify what you are looking at with `?slug=` / `?id=`, so a
redirect from `/knowledge?category=research&slug=x` rebuilt it as
`/m/knowledge` and returned you to the index. Both helpers now carry query and
hash through, and MobileRedirect passes `search` + `hash` rather than just
`pathname`.

3. IN-PAGE LINKS LEFT THE MOBILE PLANE (components/mobile/MobileShell.tsx)

Because the mobile interface renders the desktop's own components, their links
are desktop-absolute. Tapping an entry navigated to `/knowledge?…`, which then
bounced back through the viewport redirect — a full page load out of and back
into /m. The shell now rewrites same-origin clicks to their `/m` equivalent,
but only when that route exists, so anything without a mobile route still
reaches a working desktop page instead of a 404. The route list is emitted by
gen-mobile-routes.ts (`routes.generated.ts`) so it cannot disagree with the
tree that was just written.

ALSO: the browser tab read "Pulse | Home" on every /m route. On a full page
load Next commits its streamed metadata after the shell's title effect and
resets it; a client-side navigation wins that race, which is why an earlier
check passed. AppHeader already carries a short-lived MutationObserver for this
exact reason — the shell now does too.

Tests: +2 covering query/hash preservation through both helpers and the
generated route list including the HAND_WRITTEN routes (19 total, all passing).
@elhoim

elhoim commented Jul 29, 2026

Copy link
Copy Markdown
Author

Pushed a follow-up commit (5e2d55b). A bug report on this branch turned out to be three faults, and one of them is not mobile-specific — flagging that explicitly since it puts modules/wiki.ts in a mobile PR, and it may be worth porting separately.

1. All 669 Research notes were unreachable, on desktop too. handleKnowledgeNote validated the domain against its own hardcoded map of people/companies/ideas/blogs/books; research was missing. Research notes indexed fine, appeared in the tree, and were counted in the stats, but opening one returned 404 Invalid knowledge domain "research". Desktop /knowledge?category=research&slug=… sat on "Loading..." exactly like mobile.

The domain table existed in three hardcoded copies in that one file and only the third had drifted. Now one KNOWLEDGE_DOMAIN_TABLE with the other two derived from it, so a domain can't be half-added again. Verified: research and Research both 200 with content; desktop and mobile both render the note.

2. Plane switching dropped the query string. toMobilePath/toDesktopPath were path-only, so /knowledge?category=research&slug=x became /m/knowledge and you landed back on the index. Both helpers now carry query and hash.

3. In-page links left the mobile plane. Reusing desktop components means their links are desktop-absolute. The shell now rewrites same-origin clicks to their /m equivalent, but only when that route exists — anything else still reaches a working desktop page rather than a 404. The route list is emitted by the generator so it can't disagree with the tree.

Also fixed: the tab read "Pulse | Home" on every /m route. Next commits streamed metadata after the shell's title effect on a full page load; client-side navigation wins that race, which is why my earlier check passed. AppHeader already carries a MutationObserver for this — the shell now does too.

Tests 17 → 19, build ✓ 77/77.

@elhoim

elhoim commented Jul 30, 2026

Copy link
Copy Markdown
Author

Follow-up on 5e2d55b — I'd only verified the dropped-query fix against Knowledge, which was the reported surface. Seven pages identify their content by query param, so all of them were affected by the same bug. Swept the class in real Chrome at 390×844:

route result
/m/knowledge?category=research&slug=… ✓ renders
/m/knowledge?category=idea&slug=… ✓ renders
/m/docs?doc=ARCHITECTURE_SUMMARY ✓ renders
/m/telos/item?id=G0 ✓ renders
/m/system?doc=ARCHITECTURE_SUMMARY ✓ renders
/m/skills?name=Interceptor ✓ renders
/m/hooks?name=AlgorithmNudge ✓ renders
/m/arbol?name=worker ✓ renders

Query survives the plane switch on every one, none stuck on Loading, none threw. No further code changes needed — posting because the original comment understated the blast radius of that one-line path helper.

One honest limit on the KNOWLEDGE_DOMAIN_TABLE change: Blogs and Books are in the table but hold zero notes on this install, so those two paths are exercised only by the derivation, not against real data.

WikiMeta is a fixed 224px right rail. That is a fair trade on a wide screen and
a bad one on a phone: measured at a 500px viewport it took 210px, leaving 282px
for the note itself. Collapsed, the note body gets 458px.

It now collapses to a 34px strip carrying an expand button and a vertical
"METADATA" label, so the panel stays discoverable rather than simply vanishing.
The choice persists per browser under `pulse.wikiMetaRail`.

Built into WikiMeta rather than into the Knowledge page because /knowledge,
/docs and /system all render this same component — one change, three surfaces.

The default follows DensityContext rather than a media query: expanded at
desktop density, exactly as before for anyone already using it, and collapsed
inside the mobile shell where the space actually matters. A stored preference
always wins over the default. Storage failures fall back to the density
default rather than throwing.

Purely additive: 82 insertions, 0 deletions.
@elhoim

elhoim commented Jul 30, 2026

Copy link
Copy Markdown
Author

Added 920edb8: the wiki metadata rail is now collapsible.

WikiMeta is a fixed 224px right rail. Fair on a wide screen, bad on a phone — measured at a 500px viewport it took 210px, leaving 282px for the note. Collapsed, the note body gets 458px. It now collapses to a 34px strip with an expand button and a vertical "METADATA" label so the panel stays discoverable instead of just disappearing, and the choice persists per browser.

Built into WikiMeta rather than the Knowledge page because /knowledge, /docs and /system all render that same component — one change, three surfaces. Verified the toggle appears on all three.

The default follows DensityContext, not a media query: expanded at desktop density, unchanged for existing use, collapsed inside the mobile shell where the space matters. A stored preference always wins.

Verified in real Chrome:

check result
mobile, no preference collapsed strip 34px · note body 458px (was 282px)
expand w-56 at 210px, pref open, metadata visible
collapse again pref closed, back to 34px
reload in either state preference holds
desktop, no preference expanded 210px — unchanged
/docs and /system toggle present on both

Purely additive (82 insertions, 0 deletions). Build ✓ 77/77, 19 tests passing.

I've also corrected the PR description: it said four touched shared files, and with this it is five. The description now also flags that modules/wiki.ts from 5e2d55b is a non-mobile backend fix, in case you would rather port that one on its own.

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