@marver-design/marver 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +49 -0
- package/package.json +1 -1
- package/src/client/frame-host/bridge.js +28 -242
- package/src/client/frame-host/inspect.d.ts +6 -0
- package/src/client/frame-host/inspect.js +346 -0
- package/src/client/shell/App.tsx +25 -110
- package/src/client/shell/Comments.tsx +56 -11
- package/src/client/shell/Play.tsx +214 -155
- package/src/client/shell/Toolbar.tsx +189 -0
- package/src/client/shell/canvas/FrameNode.tsx +26 -2
- package/src/client/shell/icons.tsx +1 -0
- package/src/client/shell/styles.css +63 -10
- package/src/client/stage/main.tsx +35 -11
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared top-right toolbar pieces (prototype-review Phase 1). The canvas pill and the
|
|
3
|
+
* prototype pill render the SAME controls so they never drift: laser / comment buttons
|
|
4
|
+
* (store-level, zero coupling), a Hide-UI toggle (one shared body class), and controlled
|
|
5
|
+
* Device / Theme dropdowns whose wiring stays local to each surface (canvas mutates
|
|
6
|
+
* selection/board; play mutates play.device/theme). usePopover + Popover live here too so
|
|
7
|
+
* both App and Play share the popover chrome.
|
|
8
|
+
*/
|
|
9
|
+
import { createPortal } from 'react-dom'
|
|
10
|
+
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
|
11
|
+
import { useStore, CONFIG, cap } from './store.ts'
|
|
12
|
+
import { useComments } from './comments-store.ts'
|
|
13
|
+
import { Tip } from './Tip.tsx'
|
|
14
|
+
import { CaretIcon, CheckIcon, CommentIcon, DevicesIcon, EyeSlashIcon, FrameCornersIcon, LaserIcon, MoonIcon, SunIcon, deviceIcon } from './icons.tsx'
|
|
15
|
+
|
|
16
|
+
const commentsStore = () => useComments.getState()
|
|
17
|
+
|
|
18
|
+
/** Shared popover machinery: trigger position, outside-click close, portal to the app
|
|
19
|
+
* root (glass never nests - a nested backdrop-filter cannot sample the page). */
|
|
20
|
+
export function usePopover() {
|
|
21
|
+
const [open, setOpen] = useState(false)
|
|
22
|
+
const [pos, setPos] = useState({ left: 0, top: 0 })
|
|
23
|
+
const boxRef = useRef<HTMLDivElement>(null)
|
|
24
|
+
const menuRef = useRef<HTMLDivElement>(null)
|
|
25
|
+
const toggle = () => {
|
|
26
|
+
if (!open && boxRef.current) {
|
|
27
|
+
const r = boxRef.current.getBoundingClientRect()
|
|
28
|
+
setPos({ left: r.left, top: r.bottom + 10 })
|
|
29
|
+
}
|
|
30
|
+
setOpen(!open)
|
|
31
|
+
}
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!open) return
|
|
34
|
+
const close = (e: PointerEvent) => {
|
|
35
|
+
const t = e.target as globalThis.Node
|
|
36
|
+
if (!boxRef.current?.contains(t) && !menuRef.current?.contains(t)) setOpen(false)
|
|
37
|
+
}
|
|
38
|
+
window.addEventListener('pointerdown', close)
|
|
39
|
+
return () => window.removeEventListener('pointerdown', close)
|
|
40
|
+
}, [open])
|
|
41
|
+
return { open, setOpen, pos, boxRef, menuRef, toggle }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function Popover({ pop, children, dark }: { pop: ReturnType<typeof usePopover>; children: ReactNode; dark?: boolean }) {
|
|
45
|
+
const app = document.querySelector('.sh-app')
|
|
46
|
+
if (!pop.open || !app) return null
|
|
47
|
+
return createPortal(
|
|
48
|
+
<div className={`sh-menu${dark ? ' sh-menu-dark' : ''}`} ref={pop.menuRef} style={{ left: pop.pos.left, top: pop.pos.top }}>{children}</div>,
|
|
49
|
+
app,
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---- Hide UI: one shared, binary body class (canvas + prototype) ---------------------
|
|
54
|
+
// The state IS the body class - one source of truth for both pills, so they can never
|
|
55
|
+
// desync. Not persisted, so a page refresh always restores the chrome (the safety net
|
|
56
|
+
// for a forgotten shortcut). toggleHideUI is the only writer; useHideUI subscribes.
|
|
57
|
+
|
|
58
|
+
const hideListeners = new Set<() => void>()
|
|
59
|
+
export const isHideUI = () => document.body.classList.contains('sh-hide-ui')
|
|
60
|
+
export function toggleHideUI() {
|
|
61
|
+
document.body.classList.toggle('sh-hide-ui', !isHideUI())
|
|
62
|
+
hideListeners.forEach((f) => f())
|
|
63
|
+
}
|
|
64
|
+
export function useHideUI() {
|
|
65
|
+
const [on, setOn] = useState(isHideUI)
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
const f = () => setOn(isHideUI())
|
|
68
|
+
hideListeners.add(f)
|
|
69
|
+
return () => { hideListeners.delete(f) }
|
|
70
|
+
}, [])
|
|
71
|
+
return on
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---- shared controls ----------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
/** Laser toggle - store-level, identical on canvas and prototype. The per-surface code
|
|
77
|
+
* broadcasts sh:laser to its frame(s); this only flips the flag (and drops comment mode,
|
|
78
|
+
* since the two are one-at-a-time). */
|
|
79
|
+
export function LaserButton() {
|
|
80
|
+
const laser = useStore((s) => s.laser)
|
|
81
|
+
return (
|
|
82
|
+
<Tip side="bottom" label={<><b>Laser mode</b><span>L</span></>}>
|
|
83
|
+
<button className={`sh-pill-btn${laser ? ' on' : ''}`} onClick={() => {
|
|
84
|
+
if (!laser) commentsStore().setMode(false)
|
|
85
|
+
useStore.getState().setLaser(!laser)
|
|
86
|
+
}}><LaserIcon size={16} /></button>
|
|
87
|
+
</Tip>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Comment toggle - store-level, identical on canvas and prototype. */
|
|
92
|
+
export function CommentButton() {
|
|
93
|
+
const commentMode = useComments((s) => s.commentMode)
|
|
94
|
+
return (
|
|
95
|
+
<Tip side="bottom" label={<><b>Comment</b><span>C · ⇧C hides pins</span></>}>
|
|
96
|
+
<button className={`sh-pill-btn${commentMode ? ' on' : ''}`} onClick={() => {
|
|
97
|
+
const c = commentsStore()
|
|
98
|
+
if (!c.commentMode) useStore.getState().setLaser(false)
|
|
99
|
+
c.setMode(!c.commentMode)
|
|
100
|
+
useStore.getState().toast(c.commentMode ? 'comment mode off' : 'comment mode - click an element in a frame')
|
|
101
|
+
}}><CommentIcon size={16} /></button>
|
|
102
|
+
</Tip>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Hide-UI toggle - the dedicated binary control (no auto-fade, no hover-reveal). Its
|
|
107
|
+
* tooltip carries the way back; H is the shortcut, wired per-surface. */
|
|
108
|
+
export function HideUIButton() {
|
|
109
|
+
const on = useHideUI()
|
|
110
|
+
return (
|
|
111
|
+
<Tip side="bottom" label={<><b>Hide all UI</b><span>press H to reveal</span></>}>
|
|
112
|
+
<button className={`sh-pill-btn${on ? ' on' : ''}`} onClick={toggleHideUI}><EyeSlashIcon size={16} /></button>
|
|
113
|
+
</Tip>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Controlled device dropdown. `value` is the active device name ('fill' allowed when
|
|
118
|
+
* includeFill), null = default/mixed. The trigger + menu chrome is shared; the adapter
|
|
119
|
+
* owns what select does. */
|
|
120
|
+
export function DevicePicker({ value, onSelect, includeDefault, includeFill, hint, dark }: {
|
|
121
|
+
value: string | null
|
|
122
|
+
onSelect: (name: string | null) => void
|
|
123
|
+
includeDefault?: boolean
|
|
124
|
+
includeFill?: boolean
|
|
125
|
+
hint?: ReactNode
|
|
126
|
+
dark?: boolean
|
|
127
|
+
}) {
|
|
128
|
+
const pop = usePopover()
|
|
129
|
+
const entries = Object.entries(CONFIG.viewports)
|
|
130
|
+
const pick = (name: string | null) => { onSelect(name); pop.setOpen(false) }
|
|
131
|
+
const triggerIcon = value === 'fill' ? <FrameCornersIcon size={16} /> : deviceIcon(value, 16)
|
|
132
|
+
return (
|
|
133
|
+
<div className="sh-theme" ref={pop.boxRef}>
|
|
134
|
+
<Tip side="bottom" label={<><b>Device view</b>{hint && <span>{hint}</span>}</>}>
|
|
135
|
+
<button className="sh-pill-btn" onClick={pop.toggle}>
|
|
136
|
+
{triggerIcon}
|
|
137
|
+
<CaretIcon size={11} style={{ transform: pop.open ? 'rotate(180deg)' : undefined }} />
|
|
138
|
+
</button>
|
|
139
|
+
</Tip>
|
|
140
|
+
<Popover pop={pop} dark={dark}>
|
|
141
|
+
{includeDefault && <>
|
|
142
|
+
<button onClick={() => pick(null)}>
|
|
143
|
+
<DevicesIcon size={15} /><span>Default</span><kbd>0</kbd>
|
|
144
|
+
{value === null && <CheckIcon size={13} className="chk" />}
|
|
145
|
+
</button>
|
|
146
|
+
<i className="div" />
|
|
147
|
+
</>}
|
|
148
|
+
{entries.map(([name, vp], i) => (
|
|
149
|
+
<button key={name} onClick={() => pick(name)} title={`${vp.width} × ${vp.height}`}>
|
|
150
|
+
{deviceIcon(name)}<span>{cap(name)}</span><kbd>{i < 9 ? i + 1 : ''}</kbd>
|
|
151
|
+
{value === name && <CheckIcon size={13} className="chk" />}
|
|
152
|
+
</button>
|
|
153
|
+
))}
|
|
154
|
+
{includeFill && <button onClick={() => pick('fill')}>
|
|
155
|
+
<FrameCornersIcon size={15} /><span>Fill window</span><kbd>{entries.length + 1}</kbd>
|
|
156
|
+
{value === 'fill' && <CheckIcon size={13} className="chk" />}
|
|
157
|
+
</button>}
|
|
158
|
+
</Popover>
|
|
159
|
+
</div>
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Controlled theme dropdown. `value` drives the trigger (the level the picker acts on -
|
|
164
|
+
* the scope MAJORITY on the canvas); `checked` is the menu tick and can differ (undefined
|
|
165
|
+
* = tick `value`; null = tick nothing, e.g. a mixed selection). onSelect owns the write. */
|
|
166
|
+
export function ThemePicker({ value, checked, onSelect, hint, dark }: { value: string; checked?: string | null; onSelect: (t: string) => void; hint?: ReactNode; dark?: boolean }) {
|
|
167
|
+
const pop = usePopover()
|
|
168
|
+
const pick = (t: string) => { onSelect(t); pop.setOpen(false) }
|
|
169
|
+
const tick = checked === undefined ? value : checked
|
|
170
|
+
return (
|
|
171
|
+
<div className="sh-theme" ref={pop.boxRef}>
|
|
172
|
+
<Tip side="bottom" label={<><b>Theme</b>{hint && <span>{hint}</span>}</>}>
|
|
173
|
+
<button className="sh-pill-btn" onClick={pop.toggle}>
|
|
174
|
+
{value === 'dark' ? <MoonIcon size={16} /> : <SunIcon size={16} />}
|
|
175
|
+
<CaretIcon size={11} style={{ transform: pop.open ? 'rotate(180deg)' : undefined }} />
|
|
176
|
+
</button>
|
|
177
|
+
</Tip>
|
|
178
|
+
<Popover pop={pop} dark={dark}>
|
|
179
|
+
{CONFIG.themes.map((t) => (
|
|
180
|
+
<button key={t} onClick={() => pick(t)}>
|
|
181
|
+
{t === 'dark' ? <MoonIcon size={15} /> : <SunIcon size={15} />}
|
|
182
|
+
<span>{t}</span>
|
|
183
|
+
{tick === t && <CheckIcon size={13} className="chk" />}
|
|
184
|
+
</button>
|
|
185
|
+
))}
|
|
186
|
+
</Popover>
|
|
187
|
+
</div>
|
|
188
|
+
)
|
|
189
|
+
}
|
|
@@ -81,7 +81,16 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
81
81
|
if (node.status !== 'ready' || node.missing) return
|
|
82
82
|
const iframe = iframeRef.current
|
|
83
83
|
if (!iframe) return
|
|
84
|
-
const t = setTimeout(() =>
|
|
84
|
+
const t = setTimeout(() => {
|
|
85
|
+
// never re-admit a cover while this frame hosts an open thread / draft: the live app
|
|
86
|
+
// must stay visible (its highlight updates in real time). A status/theme change would
|
|
87
|
+
// otherwise capture the live DOM WITH the highlight baked in and re-cover it. The
|
|
88
|
+
// hostsCard rail recaptures a clean lean once the card closes.
|
|
89
|
+
const c = useComments.getState()
|
|
90
|
+
const hosting = (!!c.active && c.threads.some((th) => th.id === c.active && th.nodeKey === node.key && !th.resolved)) || c.draft?.nodeKey === node.key
|
|
91
|
+
if (hosting) return
|
|
92
|
+
scheduleCapture(node.key, iframe, { sourceRevision: String(node.nav ?? 0), theme: node.theme })
|
|
93
|
+
}, 450)
|
|
85
94
|
return () => clearTimeout(t)
|
|
86
95
|
// node.theme IS a dep: baked content (mermaid SVG) can't be re-themed by the cover's attribute
|
|
87
96
|
// flip, so a theme change re-captures after the live frame re-renders (key includes theme).
|
|
@@ -111,7 +120,22 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
111
120
|
// neighbors - each node is a stacking context, so an overflowing card would
|
|
112
121
|
// otherwise paint under the next frame
|
|
113
122
|
const hostsCard = useComments((s) =>
|
|
114
|
-
(!!s.active && s.threads.some((t) => t.id === s.active && t.nodeKey === node.key)) || s.draft?.nodeKey === node.key)
|
|
123
|
+
(!!s.active && s.threads.some((t) => t.id === s.active && t.nodeKey === node.key && !t.resolved)) || s.draft?.nodeKey === node.key)
|
|
124
|
+
// a frame hosting an OPEN thread or a draft must show its LIVE app, not the frozen lean
|
|
125
|
+
// cover: the active-element highlight lives in the live DOM and updates in real time
|
|
126
|
+
// (open -> lit, close -> cleared). Without this the cover re-freezes the moment comment
|
|
127
|
+
// mode ends and either bakes a stale highlight or hides the live one. Mirror the interact
|
|
128
|
+
// rail: drop the cover while hosting, rebuild a fresh lean once the card closes (the
|
|
129
|
+
// highlight is cleared by then, so the recapture is clean).
|
|
130
|
+
const prevHostsCard = useRef(hostsCard)
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
if (prevHostsCard.current === hostsCard) return
|
|
133
|
+
const wasHosting = prevHostsCard.current
|
|
134
|
+
prevHostsCard.current = hostsCard
|
|
135
|
+
if (hostsCard) invalidateLean(node.key)
|
|
136
|
+
else if (wasHosting && node.status === 'ready' && iframeRef.current)
|
|
137
|
+
scheduleCapture(node.key, iframeRef.current, { sourceRevision: String(node.nav ?? 0), theme: node.theme }, true)
|
|
138
|
+
}, [hostsCard, node.key, node.nav, node.theme, node.status])
|
|
115
139
|
useEffect(() => {
|
|
116
140
|
if (node.status === 'ready' || !laser)
|
|
117
141
|
iframeRef.current?.contentWindow?.postMessage({ type: 'sh:laser', on: laser }, location.origin)
|
|
@@ -30,6 +30,7 @@ export const CheckIcon = icon(<path d="M229.66,77.66l-128,128a8,8,0,0,1-11.32,0l
|
|
|
30
30
|
export const ColumnsIcon = icon(<path d="M104,32H64A16,16,0,0,0,48,48V208a16,16,0,0,0,16,16h40a16,16,0,0,0,16-16V48A16,16,0,0,0,104,32Zm0,176H64V48h40ZM192,32H152a16,16,0,0,0-16,16V208a16,16,0,0,0,16,16h40a16,16,0,0,0,16-16V48A16,16,0,0,0,192,32Zm0,176H152V48h40Z" />)
|
|
31
31
|
export const ArrowUpIcon = icon(<path d="M205.66,117.66a8,8,0,0,1-11.32,0L136,59.31V216a8,8,0,0,1-16,0V59.31L61.66,117.66a8,8,0,0,1-11.32-11.32l72-72a8,8,0,0,1,11.32,0l72,72A8,8,0,0,1,205.66,117.66Z" />)
|
|
32
32
|
export const CommentIcon = icon(<path d="M132,24A100.11,100.11,0,0,0,32,124v84a16,16,0,0,0,16,16h84a100,100,0,0,0,0-200Zm0,184H48V124a84,84,0,1,1,84,84Z" />)
|
|
33
|
+
export const EyeSlashIcon = icon(<path d="M53.92,34.62A8,8,0,1,0,42.08,45.38L61.32,66.55C25,88.84,9.38,123.2,8.69,124.76a8,8,0,0,0,0,6.5c.35.79,8.82,19.57,27.65,38.4C61.43,194.74,93.12,208,128,208a127.11,127.11,0,0,0,52.07-11.07l22,24.21a8,8,0,1,0,11.84-10.76Zm47.33,75.84,41.67,45.85a32,32,0,0,1-41.67-45.85ZM128,192c-30.78,0-57.67-11.19-79.93-33.25A133.47,133.47,0,0,1,25,128c4.69-8.79,19.66-33.39,47.35-49.38l18,19.75a48,48,0,0,0,63.66,70l14.73,16.2A112,112,0,0,1,128,192Zm6-95.43a8,8,0,0,1,3-15.72,48.16,48.16,0,0,1,38.77,42.64,8,8,0,0,1-7.22,8.71,6.39,6.39,0,0,1-.75,0,8,8,0,0,1-8-7.26A32.09,32.09,0,0,0,134,96.57Zm113.28,34.69c-.42.94-10.55,23.37-33.36,43.8a8,8,0,1,1-10.67-11.92A132.77,132.77,0,0,0,231,128a133.15,133.15,0,0,0-23.12-30.77C185.67,75.19,158.78,64,128,64a118.37,118.37,0,0,0-19.36,1.57A8,8,0,1,1,106,49.79,134,134,0,0,1,128,48c34.88,0,66.57,13.26,91.66,38.35,18.83,18.83,27.3,37.62,27.65,38.41A8,8,0,0,1,247.31,131.26Z" />)
|
|
33
34
|
export const CheckSquareOffsetIcon = icon(<path d="M224,48V208a16,16,0,0,1-16,16H136a8,8,0,0,1,0-16h72V48H48v96a8,8,0,0,1-16,0V48A16,16,0,0,1,48,32H208A16,16,0,0,1,224,48ZM125.66,154.34a8,8,0,0,0-11.32,0L64,204.69,45.66,186.34a8,8,0,0,0-11.32,11.32l24,24a8,8,0,0,0,11.32,0l56-56A8,8,0,0,0,125.66,154.34Z" />)
|
|
34
35
|
export const DevicesIcon = icon(<path d="M224,72H208V64a24,24,0,0,0-24-24H40A24,24,0,0,0,16,64v96a24,24,0,0,0,24,24H152v8a24,24,0,0,0,24,24h48a24,24,0,0,0,24-24V96A24,24,0,0,0,224,72ZM40,168a8,8,0,0,1-8-8V64a8,8,0,0,1,8-8H184a8,8,0,0,1,8,8v8H176a24,24,0,0,0-24,24v72Zm192,24a8,8,0,0,1-8,8H176a8,8,0,0,1-8-8V96a8,8,0,0,1,8-8h48a8,8,0,0,1,8,8Zm-96,16a8,8,0,0,1-8,8H88a8,8,0,0,1,0-16h40A8,8,0,0,1,136,208Zm80-96a8,8,0,0,1-8,8H192a8,8,0,0,1,0-16h16A8,8,0,0,1,216,112Z" />)
|
|
35
36
|
export const DeviceMobileIcon = icon(<path d="M176,16H80A24,24,0,0,0,56,40V216a24,24,0,0,0,24,24h96a24,24,0,0,0,24-24V40A24,24,0,0,0,176,16ZM72,64H184V192H72Zm8-32h96a8,8,0,0,1,8,8v8H72V40A8,8,0,0,1,80,32Zm96,192H80a8,8,0,0,1-8-8v-8H184v8A8,8,0,0,1,176,224Z" />)
|
|
@@ -417,7 +417,9 @@ body.sh-commenting .sh-lean { opacity: 0 }
|
|
|
417
417
|
|
|
418
418
|
/* dropdown menu (theme) - portaled to .sh-app (its own backdrop root), left-aligned to its trigger */
|
|
419
419
|
.sh-theme { position: relative }
|
|
420
|
-
.sh-
|
|
420
|
+
/* z above the play backdrop (.sh-play is z-30): a dropdown portals to .sh-app, so in the
|
|
421
|
+
prototype it must clear the full-window overlay or it renders behind it, invisible */
|
|
422
|
+
.sh-menu { position: absolute; min-width: 136px; z-index: 40;
|
|
421
423
|
border-radius: 16px; padding: 5px; display: flex; flex-direction: column;
|
|
422
424
|
transform-origin: top left; animation: sh-pop .14s ease }
|
|
423
425
|
@keyframes sh-pop { from { opacity: 0; transform: scale(.95) translateY(-4px) } }
|
|
@@ -643,6 +645,57 @@ body.sh-kbd .sh-app :focus-visible { outline: 2px solid var(--accent); outline-o
|
|
|
643
645
|
divider needs a touch more air before the first chip */
|
|
644
646
|
.sh-play-nav .sep + .vchip { margin-left: 5px }
|
|
645
647
|
|
|
648
|
+
/* --- prototype-review: shared toolbar in play, Hide-UI, comment overlay --- */
|
|
649
|
+
|
|
650
|
+
/* Hide-UI: one binary body class (canvas + prototype) drops all operating chrome. Not
|
|
651
|
+
persisted - a page refresh always restores it. Comment pins/threads (the review
|
|
652
|
+
annotations) stay; only the marver chrome hides, for a clean point-and-shoot frame. */
|
|
653
|
+
body.sh-hide-ui .sh-pill,
|
|
654
|
+
body.sh-hide-ui .sh-pill-fab,
|
|
655
|
+
body.sh-hide-ui .sh-panel,
|
|
656
|
+
body.sh-hide-ui .sh-fab,
|
|
657
|
+
body.sh-hide-ui .sh-ctx,
|
|
658
|
+
body.sh-hide-ui .sh-update,
|
|
659
|
+
body.sh-hide-ui .sh-toasts,
|
|
660
|
+
body.sh-hide-ui .sh-banner,
|
|
661
|
+
body.sh-hide-ui .sh-play-nav { display: none !important }
|
|
662
|
+
|
|
663
|
+
/* the prototype pill reuses the shared pill LAYOUT but wears the discreet DARK skin of the
|
|
664
|
+
bottom-left navigator (not the bright canvas glass), so it recedes into the near-black
|
|
665
|
+
stage: dark translucent fill, a faint white hairline, light-on-dark controls. */
|
|
666
|
+
.sh-play-pill, .sh-play .sh-pill-fab { z-index: 32 }
|
|
667
|
+
.sh-play-pill.sh-pill, .sh-play .sh-pill-fab {
|
|
668
|
+
background: rgba(18, 18, 24, .8); border: 1px solid rgba(255, 255, 255, .1);
|
|
669
|
+
backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
|
|
670
|
+
box-shadow: 0 8px 28px rgba(0, 0, 0, .38) }
|
|
671
|
+
.sh-play-pill::after, .sh-play .sh-pill-fab::after { display: none } /* no light edge-light overlay */
|
|
672
|
+
.sh-play-pill .sh-pill-btn, .sh-play .sh-pill-fab { color: rgba(245, 245, 247, .6) }
|
|
673
|
+
.sh-play-pill .sh-pill-btn:hover, .sh-play .sh-pill-fab:hover { background: rgba(255, 255, 255, .1); color: #f5f5f7 }
|
|
674
|
+
.sh-play-pill .sh-pill-btn.on { background: rgba(255, 255, 255, .14); color: #f5f5f7 }
|
|
675
|
+
.sh-play-pill .sep { background: rgba(255, 255, 255, .12) }
|
|
676
|
+
.sh-play-pill .bd { color: rgba(245, 245, 247, .82) }
|
|
677
|
+
.sh-pill-btn.bd { width: auto; gap: 6px; padding: 0 8px 0 11px; white-space: nowrap; font-weight: 600 }
|
|
678
|
+
.sh-play-update { width: auto !important; gap: 5px; padding: 0 11px !important; font-size: 12px; font-weight: 600 }
|
|
679
|
+
/* the picker dropdowns portal to .sh-app; in play they carry .sh-menu-dark for the dark skin */
|
|
680
|
+
.sh-menu-dark { background: rgba(20, 20, 26, .97); border-color: rgba(255, 255, 255, .12); box-shadow: 0 12px 40px rgba(0, 0, 0, .5) }
|
|
681
|
+
.sh-menu-dark::after { display: none }
|
|
682
|
+
.sh-menu-dark button { color: rgba(245, 245, 247, .82) }
|
|
683
|
+
.sh-menu-dark button:hover { background: rgba(255, 255, 255, .1) }
|
|
684
|
+
.sh-menu-dark .chk { color: #6db3ff }
|
|
685
|
+
.sh-menu-dark kbd { color: rgba(245, 245, 247, .5) }
|
|
686
|
+
.sh-menu-dark .div { background: rgba(255, 255, 255, .1) }
|
|
687
|
+
|
|
688
|
+
/* device + comment overlay share one flex-centered box; the overlay is inset:0 over it, a
|
|
689
|
+
SIBLING of the clipping .dev so a thread card is never eaten by the device's overflow.
|
|
690
|
+
overflow:visible lets a card spill past the device edge; only its markers catch the
|
|
691
|
+
pointer, the stage below stays fully interactive. */
|
|
692
|
+
.sh-play-stage { position: relative; flex: none }
|
|
693
|
+
/* the canvas writes --sh-inv (1/zoom) for screen-constant pins; the prototype has no
|
|
694
|
+
zoom, so neutralise it or the leaked value scales every comment pin by ~12x at overview */
|
|
695
|
+
.sh-play { --sh-inv: 1 }
|
|
696
|
+
.sh-play-comments { position: absolute; inset: 0; z-index: 31; overflow: visible; pointer-events: none }
|
|
697
|
+
.sh-play-comments > * { pointer-events: auto }
|
|
698
|
+
|
|
646
699
|
/* the current variant's name trails the chips at natural width - the chips anchor,
|
|
647
700
|
so a name change grows the pill rightward without shifting any control underfoot.
|
|
648
701
|
A max caps runaway titles; the Tip carries the full name. */
|
|
@@ -704,9 +757,9 @@ body.sh-kbd .sh-app :focus-visible { outline: 2px solid var(--accent); outline-o
|
|
|
704
757
|
outline hugging the edge, 4px ring halo - in the comment green (the pin
|
|
705
758
|
is already screen-space via scale(--sh-inv), so plain px match the
|
|
706
759
|
frame's calc'd screen px) */
|
|
707
|
-
.cm-pin.on { border-color: var(--comment);
|
|
708
|
-
outline: 2px solid var(--comment); outline-offset: -1px;
|
|
709
|
-
box-shadow: 0 0 0 4px var(--comment-ring), 0 1px 2px rgba(24,24,27,.18), 0 6px 16px -6px rgba(24,24,27,.35) }
|
|
760
|
+
.cm-pin.on { border-color: var(--cm-line, var(--comment));
|
|
761
|
+
outline: 2px solid var(--cm-line, var(--comment)); outline-offset: -1px;
|
|
762
|
+
box-shadow: 0 0 0 4px var(--cm-line-ring, var(--comment-ring)), 0 1px 2px rgba(24,24,27,.18), 0 6px 16px -6px rgba(24,24,27,.35) }
|
|
710
763
|
.cm-pin.orphan { border-style: dashed; opacity: .75 }
|
|
711
764
|
|
|
712
765
|
/* stack: the frame-corner cluster - everyone across the frame's threads */
|
|
@@ -721,9 +774,9 @@ body.sh-kbd .sh-app :focus-visible { outline: 2px solid var(--accent); outline-o
|
|
|
721
774
|
transform: scale(var(--sh-inv, 1)); transform-origin: top left;
|
|
722
775
|
background: var(--glass); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
|
|
723
776
|
/* an open card is the active selection - the frame's exact geometry, comment green */
|
|
724
|
-
color: var(--glass-ink); border-radius: 20px; border: 1px solid var(--comment);
|
|
725
|
-
outline: 2px solid var(--comment); outline-offset: -1px;
|
|
726
|
-
box-shadow: 0 0 0 4px var(--comment-ring), var(--shadow-glass), 0 16px 40px -12px rgba(24,24,27,.35);
|
|
777
|
+
color: var(--glass-ink); border-radius: 20px; border: 1px solid var(--cm-line, var(--comment));
|
|
778
|
+
outline: 2px solid var(--cm-line, var(--comment)); outline-offset: -1px;
|
|
779
|
+
box-shadow: 0 0 0 4px var(--cm-line-ring, var(--comment-ring)), var(--shadow-glass), 0 16px 40px -12px rgba(24,24,27,.35);
|
|
727
780
|
display: flex; flex-direction: column; gap: 12px;
|
|
728
781
|
font: 400 13px -apple-system, system-ui, sans-serif; letter-spacing: -.005em; text-align: left }
|
|
729
782
|
.cm-card.flip { transform: translateX(-100%) scale(var(--sh-inv, 1)); transform-origin: top right }
|
|
@@ -760,15 +813,15 @@ body.sh-kbd .sh-app :focus-visible { outline: 2px solid var(--accent); outline-o
|
|
|
760
813
|
font: 400 13px -apple-system, system-ui, sans-serif; outline: none;
|
|
761
814
|
transition: border-color .15s, box-shadow .15s }
|
|
762
815
|
.cm-compose input::placeholder { color: var(--glass-ink-3) }
|
|
763
|
-
.cm-compose input:focus { border-color: var(--comment); box-shadow: 0 0 0 3px var(--comment-ring) }
|
|
816
|
+
.cm-compose input:focus { border-color: var(--cm-line, var(--comment)); box-shadow: 0 0 0 3px var(--cm-line-ring, var(--comment-ring)) }
|
|
764
817
|
.cm-send { position: absolute; right: 4px; top: 50%; transform: translateY(-50%);
|
|
765
818
|
width: 28px; height: 28px; border-radius: 50%; border: 0; display: flex; align-items: center; justify-content: center;
|
|
766
|
-
background: var(--comment); color: #fff; cursor: pointer; transition: background .15s, opacity .15s }
|
|
819
|
+
background: var(--cm-solid, var(--comment)); color: #fff; cursor: pointer; transition: background .15s, opacity .15s }
|
|
767
820
|
.cm-send:hover:not(:disabled) { filter: brightness(1.08) }
|
|
768
821
|
.cm-send:disabled { background: var(--glass-ink-3); opacity: .5; cursor: default }
|
|
769
822
|
/* signed-out viewer: one clear button in place of the composer */
|
|
770
823
|
.cm-signin-cta { width: 100%; height: 40px; border: 0; border-radius: 999px;
|
|
771
|
-
background: var(--comment); color: #fff; cursor: pointer;
|
|
824
|
+
background: var(--cm-solid, var(--comment)); color: #fff; cursor: pointer;
|
|
772
825
|
font: 600 13px -apple-system, system-ui, sans-serif; transition: filter .15s }
|
|
773
826
|
.cm-signin-cta:hover { filter: brightness(1.06) }
|
|
774
827
|
/* the draft composer is JUST the pill - no card chrome around a single input */
|
|
@@ -14,6 +14,7 @@ import { Component, createElement, useEffect, useRef, useState, type ComponentTy
|
|
|
14
14
|
import { flushSync } from 'react-dom'
|
|
15
15
|
import { createRoot } from 'react-dom/client'
|
|
16
16
|
import { frameFile, frames, layoutChain, layouts, providers } from '../frame-host/registry.ts'
|
|
17
|
+
import { createInspect } from '../frame-host/inspect.js'
|
|
17
18
|
|
|
18
19
|
const params = new URLSearchParams(location.search)
|
|
19
20
|
// Both signals, always - same pair as the frame host: [data-theme] for attribute-keyed
|
|
@@ -24,7 +25,9 @@ document.documentElement.dataset.theme = bootTheme
|
|
|
24
25
|
document.documentElement.classList.toggle('dark', bootTheme === 'dark')
|
|
25
26
|
const startId = params.get('at') ?? ''
|
|
26
27
|
|
|
27
|
-
|
|
28
|
+
// same-origin parent (the shell): fixed target origin so anchor bundles never leak if a
|
|
29
|
+
// data-goto ever navigates the stage cross-origin
|
|
30
|
+
const post = (msg: Record<string, unknown>) => { if (window.parent !== window) window.parent.postMessage(msg, location.origin) }
|
|
28
31
|
|
|
29
32
|
window.addEventListener('error', (e) => post({ type: 'sh:stage-error', message: String(e.message || e.error) }))
|
|
30
33
|
window.addEventListener('unhandledrejection', (e) => post({ type: 'sh:stage-error', message: `unhandled rejection: ${e.reason}` }))
|
|
@@ -77,6 +80,7 @@ function Stage() {
|
|
|
77
80
|
const [err, setErr] = useState<{ id: string; message: string } | null>(null)
|
|
78
81
|
const current = useRef(startId)
|
|
79
82
|
const swapSeq = useRef(0)
|
|
83
|
+
const readySent = useRef(false)
|
|
80
84
|
|
|
81
85
|
/** Swap to a frame. `announce` posts sh:stage-at (user-driven); history restores stay silent. */
|
|
82
86
|
const goto = async (id: string, announce: boolean) => {
|
|
@@ -85,10 +89,13 @@ function Stage() {
|
|
|
85
89
|
try {
|
|
86
90
|
const next = await resolve(id)
|
|
87
91
|
if (seq !== swapSeq.current) return // a newer swap superseded this one
|
|
88
|
-
current.current = id
|
|
89
92
|
// startViewTransition runs its callback async - recheck the seq there too, or an
|
|
90
|
-
// older pending transition could commit stale state over a newer navigation
|
|
91
|
-
|
|
93
|
+
// older pending transition could commit stale state over a newer navigation.
|
|
94
|
+
// current.current is advanced INSIDE apply (once the new DOM is committed), so
|
|
95
|
+
// inspect.getId() never reports the new frame while the old DOM is still live -
|
|
96
|
+
// a pick / anchor-resolve landing mid-swap then carries the old id and the shell
|
|
97
|
+
// guards drop it instead of stamping it onto the wrong frame.
|
|
98
|
+
const apply = () => { if (seq === swapSeq.current) { flushSync(() => { setErr(null); setMounted(next) }); current.current = id } }
|
|
92
99
|
if (document.startViewTransition) document.startViewTransition(apply)
|
|
93
100
|
else { apply(); document.getElementById('root')?.animate([{ opacity: 0.35 }, { opacity: 1 }], { duration: 180, easing: 'ease-out' }) }
|
|
94
101
|
if (announce) post({ type: 'sh:stage-at', at: id })
|
|
@@ -101,13 +108,27 @@ function Stage() {
|
|
|
101
108
|
}
|
|
102
109
|
}
|
|
103
110
|
|
|
111
|
+
// sh:stage-ready fires ONCE, after the first frame actually commits to the DOM (not when
|
|
112
|
+
// goto is merely scheduled) - the shell re-drives the review highlight on it, which must
|
|
113
|
+
// resolve against a live document
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
if (mounted && !readySent.current) { readySent.current = true; post({ type: 'sh:stage-ready', at: current.current }) }
|
|
116
|
+
}, [mounted])
|
|
117
|
+
|
|
104
118
|
useEffect(() => {
|
|
105
119
|
goto(startId, false)
|
|
106
|
-
|
|
120
|
+
|
|
121
|
+
// laser / comment-pick / anchor / highlight - the SAME controller the canvas
|
|
122
|
+
// frames run, so review works identically in the prototype. getId reports the
|
|
123
|
+
// CURRENT frame (the stage swaps in place). It self-installs its own listeners;
|
|
124
|
+
// we only borrow modeActive() to keep a review click from ALSO navigating.
|
|
125
|
+
const inspect = createInspect({ post, getId: () => current.current })
|
|
107
126
|
|
|
108
127
|
// data-goto is handled HERE, in place - never posted up as sh:go (capture phase
|
|
109
|
-
// beats any frame handler; preventDefault stops real <a href> navigations)
|
|
128
|
+
// beats any frame handler; preventDefault stops real <a href> navigations). A
|
|
129
|
+
// laser/comment click owns the press instead - it must not navigate.
|
|
110
130
|
const onClick = (e: MouseEvent) => {
|
|
131
|
+
if (inspect.modeActive()) return
|
|
111
132
|
const el = e.target instanceof Element ? e.target.closest('[data-goto]') : null
|
|
112
133
|
if (!el) return
|
|
113
134
|
e.preventDefault()
|
|
@@ -118,19 +139,21 @@ function Stage() {
|
|
|
118
139
|
document.addEventListener('click', onClick, true)
|
|
119
140
|
|
|
120
141
|
const onKey = (e: KeyboardEvent) => {
|
|
121
|
-
// Escape always exits, even mid-typing (matches the canvas bridge)
|
|
122
|
-
if (e.key === 'Escape') { post({ type: 'sh:stage-exit' }); return }
|
|
123
142
|
if ((e.metaKey || e.ctrlKey) && e.key === '/') { e.preventDefault(); post({ type: 'sh:stage-key', key: '/', code: e.code, meta: true }); return }
|
|
124
143
|
if (e.metaKey || e.ctrlKey) return // ⌘D is the browser's bookmark, not our theme
|
|
144
|
+
// Escape always reaches the shell, even mid-typing: it decides (cancel a
|
|
145
|
+
// comment/laser first, else exit) - the stage can't see review mode
|
|
146
|
+
if (e.key === 'Escape') { post({ type: 'sh:stage-key', key: 'Escape', code: e.code }); return }
|
|
125
147
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
|
|
126
|
-
// every play shortcut belongs to the shell (it owns walk order + chrome) - forward
|
|
127
|
-
|
|
148
|
+
// every play shortcut belongs to the shell (it owns walk order + chrome) - forward.
|
|
149
|
+
// l/c toggle laser/comment, C (shift+c) hides pins; the shell acts and broadcasts back.
|
|
150
|
+
if (/^Digit[0-9]$/.test(e.code) || ['d', 'h', 'r', 'l', 'c', 'C', '[', ']', 'ArrowRight', 'ArrowLeft'].includes(e.key))
|
|
128
151
|
post({ type: 'sh:stage-key', key: e.key, code: e.code })
|
|
129
152
|
}
|
|
130
153
|
window.addEventListener('keydown', onKey)
|
|
131
154
|
|
|
132
155
|
const onMsg = (e: MessageEvent) => {
|
|
133
|
-
if (e.source !== window.parent) return
|
|
156
|
+
if (e.source !== window.parent || (e.origin && e.origin !== location.origin)) return
|
|
134
157
|
const data = e.data
|
|
135
158
|
if (data?.type === 'sh:set-theme') {
|
|
136
159
|
document.documentElement.dataset.theme = data.theme
|
|
@@ -144,6 +167,7 @@ function Stage() {
|
|
|
144
167
|
document.removeEventListener('click', onClick, true)
|
|
145
168
|
window.removeEventListener('keydown', onKey)
|
|
146
169
|
window.removeEventListener('message', onMsg)
|
|
170
|
+
inspect.dispose()
|
|
147
171
|
}
|
|
148
172
|
}, [])
|
|
149
173
|
|