polats Claude Opus 4.8 (1M context) commited on
Commit
7232e98
·
1 Parent(s): d77de70

Tour: draggable callout with a grab handle (tap to flip up/down)

Browse files

The tour callout was a fixed banner that could still cover controls on mobile.
It now has a grab handle at the top:
- DRAG it anywhere (clamped to the viewport).
- TAP it (no real movement) to flip between two snap presets — DOWN (above the
on-screen controls) and UP (just below the top UI).
The chosen position persists across steps and is saved, so it stays put as the
tour advances. Pointer events cover touch + mouse; a 5px dead zone keeps a tap
from registering as a drag.

Replaces the per-anchor REGION map (every step mapped to one banner anyway) with
the two presets + free placement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files changed (1) hide show
  1. web/tutorial.js +68 -11
web/tutorial.js CHANGED
@@ -19,18 +19,16 @@ function elc(tag, css, text) { const n = document.createElement(tag); if (css) n
19
  } catch { /* ignore */ }
20
  })()
21
 
22
- // Place a fixed-position callout in a screen region, offset so it doesn't cover the control.
23
- // All anchors sit in the LOWER part of the screen so the callout never covers the map, the create
24
- // modal, the picker or the top UI and you can still read + click through. 'bottom' is the default
25
- // banner (centred, just above the on-screen controls); the others nudge left/right toward a control.
26
- // One consistent banner low on the screen (centred, above the on-screen controls + picker) so the
27
- // callout never covers the map, the create modal, the picker or the top UI — you can read it and
28
- // still click through everywhere else. Every step maps here.
29
- const BANNER = 'left:50%;bottom:170px;transform:translateX(-50%)'
30
- const REGION = { center: BANNER, bottom: BANNER, 'bottom-center': BANNER, 'bottom-left': BANNER, 'bottom-right': BANNER, 'top-left': BANNER, 'top-right': BANNER, right: BANNER }
31
 
32
  export function createTutorial(steps) {
33
- let state = { step: 0, done: false, dismissed: false, ...load() }
34
  let idx = -1, box = null
35
  let glowEl = null, glowTimer = null
36
  const persist = () => save(state)
@@ -54,6 +52,63 @@ export function createTutorial(steps) {
54
  tick(); glowTimer = setInterval(tick, 400)
55
  }
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  const shouldAutoStart = () => !state.done && !state.dismissed
58
  function startOrResume() { if (state.done) return; idx = Math.min(state.step || 0, steps.length - 1); show() }
59
  function emit(event) { const s = steps[idx]; if (idx >= 0 && s && s.advanceOn === event) advance() }
@@ -68,7 +123,8 @@ export function createTutorial(steps) {
68
  if (!s) return finish()
69
  state.step = idx; persist()
70
  teardown()
71
- box = elc('div', `position:fixed;z-index:40;max-width:300px;background:#11151c;border:1px solid #c9a227;border-radius:12px;padding:13px 15px;color:#e8e8e8;font:13px var(--tac-font,system-ui);box-shadow:0 8px 30px rgba(0,0,0,.55);line-height:1.45;${REGION[s.anchor] || REGION.center}`)
 
72
  const t = elc('div', 'margin-bottom:11px', typeof s.text === 'function' ? s.text() : s.text)
73
  box.appendChild(t)
74
  const row = elc('div', 'display:flex;gap:8px;justify-content:flex-end;align-items:center')
@@ -82,6 +138,7 @@ export function createTutorial(steps) {
82
  nx.type = 'button'; nx.addEventListener('click', advance); row.appendChild(nx)
83
  box.appendChild(row)
84
  document.body.appendChild(box)
 
85
  trackGlow(s.target) // pulse the control this step is pointing at (if any)
86
  }
87
 
 
19
  } catch { /* ignore */ }
20
  })()
21
 
22
+ // The callout is a single draggable banner. Two snap presets keep it out of the way: DOWN (above the
23
+ // on-screen controls) and UP (just below the top UI). The handle lets you drag it anywhere, or tap to
24
+ // flip between the presetsso on mobile it never has to block whatever you need to reach.
25
+ const PRESETS = {
26
+ down: { left: '50%', bottom: '150px', transform: 'translateX(-50%)' },
27
+ up: { left: '50%', top: '64px', transform: 'translateX(-50%)' },
28
+ }
 
 
29
 
30
  export function createTutorial(steps) {
31
+ let state = { step: 0, done: false, dismissed: false, placement: { mode: 'preset', preset: 'down' }, ...load() }
32
  let idx = -1, box = null
33
  let glowEl = null, glowTimer = null
34
  const persist = () => save(state)
 
52
  tick(); glowTimer = setInterval(tick, 400)
53
  }
54
 
55
+ // Position the callout from state.placement: a free x/y (after dragging) or one of the snap presets.
56
+ function applyPlacement(b) {
57
+ if (!b) return
58
+ b.style.left = b.style.top = b.style.bottom = b.style.right = b.style.transform = ''
59
+ const pl = state.placement || (state.placement = { mode: 'preset', preset: 'down' })
60
+ if (pl.mode === 'free' && pl.left != null) {
61
+ b.style.left = pl.left + 'px'; b.style.top = pl.top + 'px'
62
+ } else {
63
+ const p = PRESETS[pl.preset] || PRESETS.down
64
+ for (const k in p) b.style[k] = p[k]
65
+ }
66
+ }
67
+
68
+ // A grab handle at the top of the callout: DRAG to place it anywhere (clamped to the viewport), or
69
+ // TAP (no real movement) to flip between the up/down snap presets. Pointer events cover touch + mouse.
70
+ function buildHandle() {
71
+ const h = elc('div', 'cursor:grab;touch-action:none;user-select:none;-webkit-user-select:none;-webkit-touch-callout:none;margin:-13px -15px 10px;padding:9px 15px 8px;border-bottom:1px solid #2a2a17;display:flex;align-items:center;justify-content:center;gap:8px')
72
+ const grip = elc('div', 'display:flex;gap:3px;align-items:center')
73
+ for (let i = 0; i < 3; i++) grip.appendChild(elc('span', 'width:4px;height:4px;border-radius:50%;background:#7a8294'))
74
+ h.append(grip, elc('span', 'font-size:10px;color:#6b7686', 'drag to move · tap to flip'))
75
+ let start = null, moved = false
76
+ const onDown = (e) => {
77
+ const r = box.getBoundingClientRect()
78
+ start = { px: e.clientX, py: e.clientY, bx: r.left, by: r.top }
79
+ moved = false; h.style.cursor = 'grabbing'
80
+ try { h.setPointerCapture(e.pointerId) } catch { /* ignore */ }
81
+ e.preventDefault()
82
+ }
83
+ const onMove = (e) => {
84
+ if (!start) return
85
+ const dx = e.clientX - start.px, dy = e.clientY - start.py
86
+ if (!moved && Math.hypot(dx, dy) > 5) moved = true // small dead zone so a tap isn't read as a drag
87
+ if (!moved) return
88
+ const w = box.offsetWidth, ht = box.offsetHeight
89
+ const nx = Math.max(6, Math.min(window.innerWidth - w - 6, start.bx + dx))
90
+ const ny = Math.max(6, Math.min(window.innerHeight - ht - 6, start.by + dy))
91
+ state.placement = { mode: 'free', preset: (state.placement && state.placement.preset) || 'down', left: nx, top: ny }
92
+ applyPlacement(box)
93
+ }
94
+ const onUp = (e) => {
95
+ if (!start) return
96
+ start = null; h.style.cursor = 'grab'
97
+ try { h.releasePointerCapture(e.pointerId) } catch { /* ignore */ }
98
+ if (!moved) { // a tap → flip to the other snap preset
99
+ const cur = state.placement && state.placement.preset === 'up' ? 'up' : 'down'
100
+ state.placement = { mode: 'preset', preset: cur === 'down' ? 'up' : 'down' }
101
+ applyPlacement(box)
102
+ }
103
+ persist()
104
+ }
105
+ h.addEventListener('pointerdown', onDown)
106
+ h.addEventListener('pointermove', onMove)
107
+ h.addEventListener('pointerup', onUp)
108
+ h.addEventListener('pointercancel', onUp)
109
+ return h
110
+ }
111
+
112
  const shouldAutoStart = () => !state.done && !state.dismissed
113
  function startOrResume() { if (state.done) return; idx = Math.min(state.step || 0, steps.length - 1); show() }
114
  function emit(event) { const s = steps[idx]; if (idx >= 0 && s && s.advanceOn === event) advance() }
 
123
  if (!s) return finish()
124
  state.step = idx; persist()
125
  teardown()
126
+ box = elc('div', 'position:fixed;z-index:40;max-width:300px;background:#11151c;border:1px solid #c9a227;border-radius:12px;padding:13px 15px;color:#e8e8e8;font:13px var(--tac-font,system-ui);box-shadow:0 8px 30px rgba(0,0,0,.55);line-height:1.45')
127
+ box.appendChild(buildHandle())
128
  const t = elc('div', 'margin-bottom:11px', typeof s.text === 'function' ? s.text() : s.text)
129
  box.appendChild(t)
130
  const row = elc('div', 'display:flex;gap:8px;justify-content:flex-end;align-items:center')
 
138
  nx.type = 'button'; nx.addEventListener('click', advance); row.appendChild(nx)
139
  box.appendChild(row)
140
  document.body.appendChild(box)
141
+ applyPlacement(box) // restore the player's chosen position (snap preset or dragged spot)
142
  trackGlow(s.target) // pulse the control this step is pointing at (if any)
143
  }
144