Mobile follow-up: dock, tab state, crypto.randomUUID fix #74

Closed
opened 2026-04-08 11:25:39 +00:00 by maxtkc · 0 comments
Owner

Summary

Three follow-up items from the initial mobile support (#67): (1) crypto.randomUUID crashes on non-HTTPS mobile browsers — needs a Math.random() fallback; (2) the current "peek = tab labels visible" approach should be replaced with DaisyUI's proper dock component as a fixed bottom nav, with the bottom sheet sliding above it; (3) switching to the Files or Changes tab should clear the active object selection (pageState → home) on both desktop and mobile, giving a cleaner state model where the active tab and the focused object are always in sync.

The dock serves as the primary mobile nav entry point. In the default "closed" state only the dock is visible. Tapping a dock item opens the sheet. Swiping the sheet down dismisses it and clears selection.

Relevant Context

  • src/modules/tab-lock.ts:8crypto.randomUUID() crashes on HTTP (non-HTTPS) contexts on mobile
  • src/modules/interaction-handler.ts:188 — same crash
  • src/modules/bottom-sheet.tsBottomSheetController; currently has peek/half/full; needs closed state replacing peek, dock integration, dismiss callback; PEEK_PX=56 was sized to match tab-label height which the dock now replaces
  • src/modules/tab-manager.tsswitchToTab dispatches change event; onTabChange listens on all radio inputs
  • src/index.ts:298setupNavigationTabSwitching() — add tab→state clearing and map-click→sheet-open here
  • src/index.html:427–568 — DaisyUI radio tabs inside #right-panel; radio inputs have class="tab" which renders them as visible labels; these must be hidden on mobile while remaining in DOM (CSS :checked sibling selectors drive tab content)
  • src/index.html:5 — viewport meta lacks viewport-fit=cover (needed for iOS safe area with dock)
  • src/styles/main.css — mobile media query block at bottom; needs dock height var and updated sheet positioning
  • DaisyUI Dock: <div class="dock"> + <button class="dock-active"> + <span class="dock-label"> — already position: fixed; bottom: 0

Phase 1: crypto.randomUUID polyfill

Fix crash on non-HTTPS mobile browsers (HTTP dev servers, HTTP deployments).

  • Create src/utils/uuid.ts with generateId():
    export function generateId(): string {
      if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
        return crypto.randomUUID();
      }
      return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
        const r = (Math.random() * 16) | 0;
        return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
      });
    }
    
  • In src/modules/tab-lock.ts:8, replace crypto.randomUUID() with generateId() (import from ../utils/uuid)
  • In src/modules/interaction-handler.ts:188, replace crypto.randomUUID() with generateId()

Phase 2: Tab-aware state clearing (desktop + mobile)

When switching to Files or Changes, clear page state to home. Switching to Browse does not force any state change — whatever was selected remains. This applies on both desktop and mobile.

  • In src/index.ts, in setupNavigationTabSwitching(), add a second tabManager.onTabChange listener alongside the existing navigation handler:
    this.tabManager.onTabChange((tabName) => {
      if (tabName === 'files' || tabName === 'changes') {
        void this.pageStateManager.setPageState({ type: 'home' });
      }
    });
    
    The sheet stays open — it just now shows Files/Changes content with no selected object.

Gotcha: onTabChange fires for every tab switch including programmatic Browse switches triggered by map clicks. The if guard excludes 'browse' so there is no loop.

Phase 3: DaisyUI Dock + BottomSheetController v2

HTML changes (src/index.html)

  • Update viewport meta to add viewport-fit=cover for iOS safe area support: <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
  • Remove #sheet-drag-handle div from #right-panel (dock area replaces it; a new thin drag strip is added instead)
  • Add a <div id="sheet-top-handle"> as first child of #right-panel (replaces sheet-drag-handle; same visual pill, same md:hidden class)
  • Add mobile dock before </body> (DaisyUI dock is position: fixed so DOM position doesn't matter):
    <nav id="mobile-dock" class="dock dock-sm md:hidden z-50 bg-base-200 border-t border-base-300 pb-[env(safe-area-inset-bottom)]">
      <button id="dock-browse" class="dock-active" aria-label="Browse">
        <!-- map pin SVG icon -->
        <span class="dock-label">Browse</span>
      </button>
      <button id="dock-files" aria-label="Files">
        <!-- file SVG icon -->
        <span class="dock-label">Files</span>
      </button>
      <button id="dock-changes" aria-label="Changes">
        <!-- history/clock SVG icon -->
        <span class="dock-label">Changes</span>
      </button>
    </nav>
    
  • On each input.tab radio element (browse, files, changes), add hidden md:flex so labels are invisible on mobile but remain in DOM for CSS tab-content switching

CSS changes (src/styles/main.css)

  • In the @media (max-width: 767px) block:
    • Add --dock-height: 4rem; to :root or at top of the block
    • Update #right-panel: bottom: var(--dock-height) (was bottom: 0), default height: 0 (closed state replaces peek), remove height: var(--sheet-height, 56px)
    • Hide tab border on mobile: input.tab { display: none; } and .tabs-bordered { border-bottom: none; }
    • Keep #right-panel.sheet-full .tab-content { overflow-y: auto; } and add same for .sheet-half

Rewrite src/modules/bottom-sheet.ts

  • Change snap type: type Snap = 'closed' | 'half' | 'full' (remove peek, add closed)
  • Remove PEEK_PX; add CLOSED_PX = 0
  • Default initial snap: 'closed'
  • Add private dismissCallbacks: Array<() => void> = []
  • Add public onDismiss(cb: () => void): void method
  • Add public open(snap: 'half' | 'full' = 'half'): void — calls setSnap(snap, true)
  • Add public close(): void — calls setSnap('closed', true) without firing dismiss callbacks (for programmatic close)
  • In resolveSnap: when velocity is strongly negative OR height is below halfH / 2, return 'closed' and fire dismiss callbacks; otherwise half/full logic unchanged
  • In setSnap('closed'): set height to 0, remove sheet-full/sheet-half classes, add overflow: hidden
  • Wire dock buttons instead of setupTabExpansion:
    • Replace setupTabExpansion(tabManager) with setupDock(tabManager):
      • Query #dock-browse, #dock-files, #dock-changes
      • Each click: tabManager.switchToTab(tabName), this.open('half'), update dock-active class
    • Listen to tabManager.onTabChange to keep dock-active class in sync when tabs switch programmatically
  • Update drag handle setup to use #sheet-top-handle id

Wire in src/index.ts

  • Store BottomSheetController in a local variable const bottomSheet = ...
  • Register dismiss callback: bottomSheet.onDismiss(() => void this.pageStateManager.setPageState({ type: 'home' }))
  • In setupNavigationTabSwitching() navigation handler, when to.type is route, stop, or timetable: call bottomSheet?.open('half') (only fires on mobile since BottomSheetController is a no-op on desktop)

Gotchas:

  • Radio inputs must stay in DOM — CSS tab switching uses :checked ~ .tab-content selectors. Only the visual appearance is suppressed with display: none.
  • DaisyUI dock is already position: fixed; bottom: 0; width: 100% — don't re-declare these.
  • pb-[env(safe-area-inset-bottom)] on the dock requires viewport-fit=cover in the viewport meta (added in this phase).
  • Dismiss callbacks should only fire on user-swipe-to-close, not on programmatic close() calls, to avoid feedback loops.
  • BottomSheetController constructor still exits early on desktop (window.innerWidth >= 768), so open()/close() calls from index.ts are safe to make unconditionally — they just do nothing on desktop.

Original Issue

We have attempted mobile support through CURRENT_PLAN.md

There are many issues, so lets add a follow on plan #67b

First: Uncaught TypeError: crypto.randomUUID is not a function

Next, I want to use the daisyui Dock feature: https://daisyui.com/components/dock/ For the tabs on mobile. The bottom sheet should pop up on top of the dock.

Lets additionally change the ui state so that we do keep track of the tab that you're on, instead of before where we just kept track of the selected object. This means that when you switch to files or changes, you are no longer focussed on the object. This provides a cleaner state for both web and mobile.

One difference on mobile is the ability to be focused on nothing, so you'll just see the dock and no bottom sheet. When you click on browse, it will focus on the feed and open the feed in the bottom sheet. If you click on an object on the map, it will focus on that object and open that object in the bottom sheet. If you close the bottom sheet, the selection goes away. If you click on Files or Changes, they will open in the bottom sheet and the selection goes away.

## Summary Three follow-up items from the initial mobile support (#67): (1) `crypto.randomUUID` crashes on non-HTTPS mobile browsers — needs a `Math.random()` fallback; (2) the current "peek = tab labels visible" approach should be replaced with DaisyUI's proper `dock` component as a fixed bottom nav, with the bottom sheet sliding above it; (3) switching to the Files or Changes tab should clear the active object selection (`pageState → home`) on both desktop and mobile, giving a cleaner state model where the active tab and the focused object are always in sync. The dock serves as the primary mobile nav entry point. In the default "closed" state only the dock is visible. Tapping a dock item opens the sheet. Swiping the sheet down dismisses it and clears selection. ## Relevant Context - `src/modules/tab-lock.ts:8` — `crypto.randomUUID()` crashes on HTTP (non-HTTPS) contexts on mobile - `src/modules/interaction-handler.ts:188` — same crash - `src/modules/bottom-sheet.ts` — `BottomSheetController`; currently has `peek/half/full`; needs `closed` state replacing `peek`, dock integration, dismiss callback; `PEEK_PX=56` was sized to match tab-label height which the dock now replaces - `src/modules/tab-manager.ts` — `switchToTab` dispatches `change` event; `onTabChange` listens on all radio inputs - `src/index.ts:298` — `setupNavigationTabSwitching()` — add tab→state clearing and map-click→sheet-open here - `src/index.html:427–568` — DaisyUI radio tabs inside `#right-panel`; radio inputs have `class="tab"` which renders them as visible labels; these must be hidden on mobile while remaining in DOM (CSS `:checked` sibling selectors drive tab content) - `src/index.html:5` — viewport meta lacks `viewport-fit=cover` (needed for iOS safe area with dock) - `src/styles/main.css` — mobile media query block at bottom; needs dock height var and updated sheet positioning - DaisyUI Dock: `<div class="dock">` + `<button class="dock-active">` + `<span class="dock-label">` — already `position: fixed; bottom: 0` ## Phase 1: crypto.randomUUID polyfill Fix crash on non-HTTPS mobile browsers (HTTP dev servers, HTTP deployments). - [x] Create `src/utils/uuid.ts` with `generateId()`: ```typescript export function generateId(): string { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); } return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { const r = (Math.random() * 16) | 0; return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16); }); } ``` - [x] In `src/modules/tab-lock.ts:8`, replace `crypto.randomUUID()` with `generateId()` (import from `../utils/uuid`) - [x] In `src/modules/interaction-handler.ts:188`, replace `crypto.randomUUID()` with `generateId()` ## Phase 2: Tab-aware state clearing (desktop + mobile) When switching to Files or Changes, clear page state to `home`. Switching to Browse does not force any state change — whatever was selected remains. This applies on both desktop and mobile. - [x] In `src/index.ts`, in `setupNavigationTabSwitching()`, add a second `tabManager.onTabChange` listener alongside the existing navigation handler: ```typescript this.tabManager.onTabChange((tabName) => { if (tabName === 'files' || tabName === 'changes') { void this.pageStateManager.setPageState({ type: 'home' }); } }); ``` The sheet stays open — it just now shows Files/Changes content with no selected object. **Gotcha:** `onTabChange` fires for every tab switch including programmatic Browse switches triggered by map clicks. The `if` guard excludes `'browse'` so there is no loop. ## Phase 3: DaisyUI Dock + BottomSheetController v2 ### HTML changes (`src/index.html`) - [x] Update viewport meta to add `viewport-fit=cover` for iOS safe area support: `<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />` - [x] Remove `#sheet-drag-handle` div from `#right-panel` (dock area replaces it; a new thin drag strip is added instead) - [x] Add a `<div id="sheet-top-handle">` as first child of `#right-panel` (replaces `sheet-drag-handle`; same visual pill, same `md:hidden` class) - [x] Add mobile dock before `</body>` (DaisyUI dock is `position: fixed` so DOM position doesn't matter): ```html <nav id="mobile-dock" class="dock dock-sm md:hidden z-50 bg-base-200 border-t border-base-300 pb-[env(safe-area-inset-bottom)]"> <button id="dock-browse" class="dock-active" aria-label="Browse"> <!-- map pin SVG icon --> <span class="dock-label">Browse</span> </button> <button id="dock-files" aria-label="Files"> <!-- file SVG icon --> <span class="dock-label">Files</span> </button> <button id="dock-changes" aria-label="Changes"> <!-- history/clock SVG icon --> <span class="dock-label">Changes</span> </button> </nav> ``` - [x] On each `input.tab` radio element (browse, files, changes), add `hidden md:flex` so labels are invisible on mobile but remain in DOM for CSS tab-content switching ### CSS changes (`src/styles/main.css`) - [x] In the `@media (max-width: 767px)` block: - Add `--dock-height: 4rem;` to `:root` or at top of the block - Update `#right-panel`: `bottom: var(--dock-height)` (was `bottom: 0`), default `height: 0` (closed state replaces peek), remove `height: var(--sheet-height, 56px)` - Hide tab border on mobile: `input.tab { display: none; }` and `.tabs-bordered { border-bottom: none; }` - Keep `#right-panel.sheet-full .tab-content { overflow-y: auto; }` and add same for `.sheet-half` ### Rewrite `src/modules/bottom-sheet.ts` - [x] Change snap type: `type Snap = 'closed' | 'half' | 'full'` (remove `peek`, add `closed`) - [x] Remove `PEEK_PX`; add `CLOSED_PX = 0` - [x] Default initial snap: `'closed'` - [x] Add `private dismissCallbacks: Array<() => void> = []` - [x] Add public `onDismiss(cb: () => void): void` method - [x] Add public `open(snap: 'half' | 'full' = 'half'): void` — calls `setSnap(snap, true)` - [x] Add public `close(): void` — calls `setSnap('closed', true)` without firing dismiss callbacks (for programmatic close) - [x] In `resolveSnap`: when velocity is strongly negative OR height is below `halfH / 2`, return `'closed'` and fire dismiss callbacks; otherwise half/full logic unchanged - [x] In `setSnap('closed')`: set height to 0, remove `sheet-full`/`sheet-half` classes, add `overflow: hidden` - [x] Wire dock buttons instead of `setupTabExpansion`: - Replace `setupTabExpansion(tabManager)` with `setupDock(tabManager)`: - Query `#dock-browse`, `#dock-files`, `#dock-changes` - Each click: `tabManager.switchToTab(tabName)`, `this.open('half')`, update `dock-active` class - Listen to `tabManager.onTabChange` to keep `dock-active` class in sync when tabs switch programmatically - [x] Update drag handle setup to use `#sheet-top-handle` id ### Wire in `src/index.ts` - [x] Store `BottomSheetController` in a local variable `const bottomSheet = ...` - [x] Register dismiss callback: `bottomSheet.onDismiss(() => void this.pageStateManager.setPageState({ type: 'home' }))` - [x] In `setupNavigationTabSwitching()` navigation handler, when `to.type` is `route`, `stop`, or `timetable`: call `bottomSheet?.open('half')` (only fires on mobile since `BottomSheetController` is a no-op on desktop) **Gotchas:** - Radio inputs must stay in DOM — CSS tab switching uses `:checked ~ .tab-content` selectors. Only the *visual* appearance is suppressed with `display: none`. - DaisyUI `dock` is already `position: fixed; bottom: 0; width: 100%` — don't re-declare these. - `pb-[env(safe-area-inset-bottom)]` on the dock requires `viewport-fit=cover` in the viewport meta (added in this phase). - Dismiss callbacks should only fire on user-swipe-to-close, not on programmatic `close()` calls, to avoid feedback loops. - `BottomSheetController` constructor still exits early on desktop (`window.innerWidth >= 768`), so `open()`/`close()` calls from `index.ts` are safe to make unconditionally — they just do nothing on desktop. --- ## Original Issue We have attempted mobile support through CURRENT_PLAN.md There are many issues, so lets add a follow on plan #67b First: Uncaught TypeError: crypto.randomUUID is not a function Next, I want to use the daisyui Dock feature: https://daisyui.com/components/dock/ For the tabs on mobile. The bottom sheet should pop up on top of the dock. Lets additionally change the ui state so that we do keep track of the tab that you're on, instead of before where we just kept track of the selected object. This means that when you switch to files or changes, you are no longer focussed on the object. This provides a cleaner state for both web and mobile. One difference on mobile is the ability to be focused on nothing, so you'll just see the dock and no bottom sheet. When you click on browse, it will focus on the feed and open the feed in the bottom sheet. If you click on an object on the map, it will focus on that object and open that object in the bottom sheet. If you close the bottom sheet, the selection goes away. If you click on Files or Changes, they will open in the bottom sheet and the selection goes away.
maxtkc self-assigned this 2026-04-08 11:26:18 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
gtfs.zone/coloring-book#74
No description provided.