diff --git a/bun.lock b/bun.lock index ebcbdfe..dfb87f5 100644 --- a/bun.lock +++ b/bun.lock @@ -105,6 +105,7 @@ "@stackmap/viewer": "workspace:*", "geist": "^1.7.2", "gsap": "^3.15.0", + "lucide-react": "^1.48.0", "next": "^16.3.7", "react": "^19.3.0", "react-dom": "^19.3.0", diff --git a/commitlint.config.mjs b/commitlint.config.mjs index 9ebbd2f..4e2602a 100644 --- a/commitlint.config.mjs +++ b/commitlint.config.mjs @@ -34,5 +34,5 @@ export default { }, }, ], - rules: { 'site-no-release': [2, 'always'] }, + rules: { 'site-no-release': [2, 'always'], 'header-max-length': [0] }, }; diff --git a/packages/viewer/src/canvas/DiagramCanvas.tsx b/packages/viewer/src/canvas/DiagramCanvas.tsx index a58a937..084ca66 100644 --- a/packages/viewer/src/canvas/DiagramCanvas.tsx +++ b/packages/viewer/src/canvas/DiagramCanvas.tsx @@ -5,13 +5,14 @@ import { motionAllowed } from '../motion/motion'; import { useFlow } from '../motion/useFlow'; import { useSceneReveal } from '../motion/useSceneReveal'; import { useExplore } from '../explore/ExploreContext'; +import { useInScope } from '../explore/scope'; import { routeBetween } from '../explore/graph'; import { inMenu } from '../chrome/Toolbar'; import { CanvasPanel } from './CanvasPanel'; import { Minimap } from './Minimap'; import { toScene, type Scene } from './scene'; import { SceneLayers } from './SceneLayers'; -import { useZoom } from './useZoom'; +import { useZoom, type WheelMode } from './useZoom'; import type { Camera } from './useZoom'; import type { Transform } from './viewport'; import { CameraProvider, ContentProvider, SceneProvider, ViewportProvider } from './ViewportContext'; @@ -92,11 +93,25 @@ function DotGrid({ x, y, k }: Transform) { } /** `chrome` false (presentation): no toolbar, zoom bar or minimap, and the camera refits the bigger stage. */ -export function DiagramCanvas({ diagram, children, chrome = true }: { diagram: LaidOutDiagram; children?: ReactNode; chrome?: boolean }) { +export function DiagramCanvas({ + diagram, + children, + chrome = true, + minimap: withMinimap = true, + wheel = 'zoom', +}: { + diagram: LaidOutDiagram; + children?: ReactNode; + chrome?: boolean; + /** false: no minimap and no M key */ + minimap?: boolean; + wheel?: WheelMode; +}) { const scene = useMemo(() => toScene(diagram), [diagram]); const stageRef = useRef(null); const sceneRef = useRef(null); - const viewport = useZoom(stageRef, scene.content); + const viewport = useZoom(stageRef, scene.content, wheel); + const inScope = useInScope(); // Exports frame everything drawn: edge routes can swing outside the card/frame box (U-turns). const exportBox = useMemo(() => { const points = scene.edges.flatMap((e) => e.points); @@ -128,10 +143,10 @@ export function DiagramCanvas({ diagram, children, chrome = true }: { diagram: L useEffect(() => { const onKey = (e: globalThis.KeyboardEvent) => { const key = e.key.toLowerCase(); - if (key !== 'm' && key !== 'p') return; + if ((key !== 'm' && key !== 'p') || !inScope(e)) return; const t = e.target as HTMLElement | null; if (e.metaKey || e.ctrlKey || e.altKey || e.repeat || inMenu(t) || (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)))) return; - if (key === 'm' && !chromeRef.current) return; + if (key === 'm' && (!chromeRef.current || !withMinimap)) return; if (key === 'p' && !motionAllowed()) return; e.preventDefault(); if (key === 'm') setMinimap((v) => !v); @@ -139,7 +154,7 @@ export function DiagramCanvas({ diagram, children, chrome = true }: { diagram: L }; addEventListener('keydown', onKey); return () => removeEventListener('keydown', onKey); - }, [dispatch]); + }, [dispatch, inScope, withMinimap]); const flow = useFlow(scene); const playing = state.playing && motionAllowed(); useSceneReveal(sceneRef, scene, viewport.restored); @@ -192,10 +207,10 @@ export function DiagramCanvas({ diagram, children, chrome = true }: { diagram: L {/* Overlays are siblings of the stage, so wheel/drag on them never reaches d3-zoom. */} {chrome && ( - setMinimap((v) => !v)} /> + setMinimap((v) => !v) : undefined} /> )} - {chrome && minimap && ( + {chrome && withMinimap && minimap && ( diff --git a/packages/viewer/src/canvas/useZoom.ts b/packages/viewer/src/canvas/useZoom.ts index 2fd8975..d997ebf 100644 --- a/packages/viewer/src/canvas/useZoom.ts +++ b/packages/viewer/src/canvas/useZoom.ts @@ -32,10 +32,25 @@ export interface ViewportApi extends Camera { restored: boolean; } +/** + * `zoom`: the wheel zooms, as in a delivered file, which is the whole page. `modifier`: the viewer sits in a page + * that scrolls, so a plain wheel and a one-finger drag are the page's; ctrl/⌘ + wheel (a trackpad pinch) zooms and + * two fingers pan and pinch. + */ +export type WheelMode = 'zoom' | 'modifier'; + +// d3-zoom's default filter, plus the modifier mode's rules. Events it rejects are never prevented. +const zoomFilter = (wheel: WheelMode) => (event: WheelEvent & TouchEvent & MouseEvent) => { + if (wheel === 'zoom') return (!event.ctrlKey || event.type === 'wheel') && !event.button; + if (event.type === 'wheel') return event.ctrlKey || event.metaKey; + if (event.type === 'touchstart') return event.touches.length > 1; + return !event.ctrlKey && !event.button; +}; + const duration = (opts?: MoveOptions) => (opts?.instant || matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 150); const toZoom = (t: Transform) => zoomIdentity.translate(t.x, t.y).scale(t.k); -export function useZoom(stageRef: RefObject, content: Rect): ViewportApi { +export function useZoom(stageRef: RefObject, content: Rect, wheel: WheelMode = 'zoom'): ViewportApi { const [transform, setTransform] = useState({ x: 0, y: 0, k: 1 }); const current = useRef(transform); current.current = transform; @@ -52,6 +67,7 @@ export function useZoom(stageRef: RefObject, content: Rec const sel = select(el); const z = zoom() .scaleExtent([MIN_ZOOM, MAX_ZOOM]) + .filter(zoomFilter(wheel)) .on('zoom', (event: D3ZoomEvent) => { if (event.sourceEvent) pending.current = null; // the user took over const { x, y, k } = event.transform; @@ -59,6 +75,8 @@ export function useZoom(stageRef: RefObject, content: Rec }); // Double-click zoom is a surprise in a viewer where clicks will select nodes (M3). sel.call(z).on('dblclick.zoom', null); + // d3 claims every touch; in a scrolling page the browser keeps one-finger scrolling. + if (wheel === 'modifier') el.style.touchAction = 'pan-x pan-y'; behavior.current = z; const size = { width: el.clientWidth, height: el.clientHeight }; @@ -85,7 +103,7 @@ export function useZoom(stageRef: RefObject, content: Rec sel.on('.zoom', null); behavior.current = null; }; - }, [stageRef, content, stored]); + }, [stageRef, content, stored, wheel]); const animate = useCallback( (apply: (z: ZoomBehavior, el: HTMLDivElement) => void) => { diff --git a/packages/viewer/src/chrome/IdentityCard.tsx b/packages/viewer/src/chrome/IdentityCard.tsx index 0ac4298..26ab797 100644 --- a/packages/viewer/src/chrome/IdentityCard.tsx +++ b/packages/viewer/src/chrome/IdentityCard.tsx @@ -18,9 +18,11 @@ export function IdentityCard({ draft, onDetails }: { draft: DiagramDraft; onDeta )} - - - + {onDetails && ( + + + + )} ); } diff --git a/packages/viewer/src/chrome/Inspector.tsx b/packages/viewer/src/chrome/Inspector.tsx index cab329a..b07b6bc 100644 --- a/packages/viewer/src/chrome/Inspector.tsx +++ b/packages/viewer/src/chrome/Inspector.tsx @@ -1,5 +1,5 @@ import { ArrowDownLeft, ArrowUpRight, Copy, PanelRightClose, PanelRightOpen, X } from 'lucide-react'; -import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type Ref } from 'react'; +import { createContext, useContext, useEffect, useLayoutEffect, useRef, useState, type ReactNode, type Ref } from 'react'; import { countByType, TYPE_LABELS, type DiagramDraft, type DiagramEdge, type DiagramNode, type Evidence } from '@stackmap/core'; import { focusCard } from '../canvas/SceneLayers'; import { routeBetween } from '../explore/graph'; @@ -10,10 +10,19 @@ import { IconButton, PANEL_STYLE } from './ui'; const eyebrow = 'text-[12.5px] font-medium text-fg-muted'; +// The panel's title level; its sections sit one below and their notes two below. A page that embeds the viewer +// under its own headings moves the whole set down. +const Level = createContext<2 | 3>(2); +type Tag = 'h2' | 'h3' | 'h4' | 'h5'; +function useHeading(below: 0 | 1 | 2): Tag { + return `h${useContext(Level) + below}` as Tag; +} + function Section({ title, children }: { title: string; children: ReactNode }) { + const H = useHeading(1); return (
-

{title}

+ {title}
{children}
); @@ -60,11 +69,12 @@ function Legend({ draft }: { draft: DiagramDraft }) { } function Notes({ draft }: { draft: DiagramDraft }) { + const H = useHeading(2); return (
{draft.notes!.map((n, i) => (
-

{n.title}

+ {n.title}
    {n.items.map((item, j) => (
  • {item}
  • @@ -147,6 +157,7 @@ function NodeDetail({ node, toggle }: { node: DiagramNode; toggle: ReactNode }) const { draft, dispatch } = useExplore(); const { card } = node; const lane = node.lane ? draft.lanes?.find((l) => l.id === node.lane)?.label : undefined; + const H = useHeading(0); return ( <>
    @@ -167,7 +178,7 @@ function NodeDetail({ node, toggle }: { node: DiagramNode; toggle: ReactNode }) {toggle}
-

{card.title}

+ {card.title} {card.subtitle &&

{card.subtitle}

} {(card.tag || lane) && (

@@ -229,6 +240,7 @@ function RouteDetail({ toggle }: { toggle: ReactNode }) { const byId = new Map(draft.nodes.map((n) => [n.id, n])); const edgeById = new Map(draft.edges.map((e) => [e.id, e])); const title = (id: string) => byId.get(id)?.card.title ?? id; + const H = useHeading(0); const header = (

Route
@@ -254,9 +266,9 @@ function RouteDetail({ toggle }: { toggle: ReactNode }) { return ( <> {header} -

+ {title(state.route.from)} → {title(state.route.to)} -

+ {!route ? (

No directed connections lead from one to the other.

) : ( @@ -313,7 +325,24 @@ function Toggle({ collapsed, onToggle, ref }: { collapsed: boolean; onToggle: () ); } -export function Inspector({ collapsed, onToggle }: { collapsed: boolean; onToggle: () => void }) { +/** + * `onToggle` adds the collapse toggle; without it the panel is always open. `placement="static"` leaves the panel + * where its parent puts it at every width, instead of floating it over a narrow stage. `extra` follows the diagram's + * title while nothing is selected. + */ +export function Inspector({ + collapsed = false, + onToggle, + placement = 'auto', + headingLevel = 2, + extra, +}: { + collapsed?: boolean; + onToggle?: () => void; + placement?: 'auto' | 'static'; + headingLevel?: 2 | 3; + extra?: ReactNode; +}) { const { draft, state } = useExplore(); const selected = state.selected ? draft.nodes.find((n) => n.id === state.selected) : undefined; // The toggle re-renders as its counterpart; keep keyboard focus on it across the switch. @@ -332,7 +361,7 @@ export function Inspector({ collapsed, onToggle }: { collapsed: boolean; onToggl const motion = swapIn([...(body.current?.children ?? [])] as HTMLElement[]); return () => motion.cancel(); }, [state.selected]); - const toggle = ( + const toggle = onToggle && ( ); - if (collapsed) { + const Title = `h${headingLevel}` as const; + const narrow = placement === 'auto'; + if (collapsed && toggle) { return ( -
+ ); } diff --git a/packages/viewer/src/chrome/Toolbar.tsx b/packages/viewer/src/chrome/Toolbar.tsx index b40e9af..3c299c7 100644 --- a/packages/viewer/src/chrome/Toolbar.tsx +++ b/packages/viewer/src/chrome/Toolbar.tsx @@ -1,6 +1,7 @@ import { Filter, Moon, Presentation, Route, Search, Sun, Waypoints } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; import { useExplore } from '../explore/ExploreContext'; +import { useInScope } from '../explore/scope'; import type { ThemeChoice } from '../theme/theme'; import { ExportMenu } from './ExportMenu'; import { LensPanel } from './LensPanel'; @@ -11,8 +12,23 @@ import { IconButton, PANEL_CLASS, PANEL_STYLE, ToolbarDivider } from './ui'; const typing = (t: EventTarget | null) => t instanceof HTMLElement && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)); export const inMenu = (t: EventTarget | null) => t instanceof Element && !!t.closest('[role="menu"]'); -export function Toolbar({ theme, onToggleTheme, onPresent }: { theme: ThemeChoice; onToggleTheme: () => void; onPresent?: () => void }) { +export function Toolbar({ + theme, + onToggleTheme, + onPresent, + exports = true, + trace = true, +}: { + theme: ThemeChoice; + onToggleTheme: () => void; + onPresent?: () => void; + /** the export menu; a page that embeds the viewer has no file of its own to export */ + exports?: boolean; + /** the trace button; an embed that offers trace elsewhere can leave it out */ + trace?: boolean; +}) { const { state, dispatch } = useExplore(); + const inScope = useInScope(); const [lensOpen, setLensOpen] = useState(false); const routingRef = useRef(false); routingRef.current = !!state.routing || !!state.route; @@ -38,6 +54,7 @@ export function Toolbar({ theme, onToggleTheme, onPresent }: { theme: ThemeChoic // (cards, the stage and the popovers handle their own Escape and stop it). useEffect(() => { const onKey = (e: KeyboardEvent) => { + if (!inScope(e)) return; const onPage = e.target === document.body || e.target === document.documentElement; // Escape from the page body clears; while a route is picked or shown it ends it from any control too. if (e.key === 'Escape' && !e.defaultPrevented && !typing(e.target) && (onPage || routingRef.current)) { @@ -59,7 +76,7 @@ export function Toolbar({ theme, onToggleTheme, onPresent }: { theme: ThemeChoic }; addEventListener('keydown', onKey); return () => removeEventListener('keydown', onKey); - }, [dispatch]); + }, [dispatch, inScope]); // Clicking outside closes the popovers. useEffect(() => { @@ -111,14 +128,16 @@ export function Toolbar({ theme, onToggleTheme, onPresent }: { theme: ThemeChoic > - dispatch({ type: 'toggleTrace' })} - > - - + {trace && ( + dispatch({ type: 'toggleTrace' })} + > + + + )} dispatch({ type: 'toggleRoute' })}> @@ -132,7 +151,7 @@ export function Toolbar({ theme, onToggleTheme, onPresent }: { theme: ThemeChoic {theme === 'dark' ? : } - + {exports && } {/* Always mounted, so screen readers announce the hint when its text arrives. */}

(id === null ? `${uid}-overview` : `${uid}-v-${id}`); + const tabs = [{ id: null, label: 'Overview' }, ...(draft.views ?? []).map((v) => ({ ...v, id: v.id as string | null }))]; + const current = tabs.find((t) => t.id === state.view) ?? tabs[0]!; + // One indicator that slides between tabs, so a view change reads as a move rather than a blink. + const bar = useRef(null); + const placed = useRef<{ x: number; width: number } | null>(null); + useLayoutEffect(() => { + const place = () => { + const tab = document.getElementById(tabId(current.id)); + if (!tab || !bar.current) return null; + const to = { x: tab.offsetLeft, width: tab.offsetWidth }; + bar.current.style.width = `${to.width}px`; + bar.current.style.transform = `translateX(${to.x}px)`; + return to; + }; + const from = placed.current; + const to = place(); + placed.current = to; + // Tab widths change once the web font arrives; follow without animating. + void document.fonts?.ready.then(() => (placed.current = place() ?? placed.current)); + if (!from || !to || (from.x === to.x && from.width === to.width)) return; + const motion = slideIndicator(bar.current, from, to); + return () => motion.cancel(); + // tabId only changes with uid, which is stable for the component's life. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [current.id]); + // Roving focus across the tablist, per the ARIA tabs pattern. + const onKeyDown = (e: KeyboardEvent, i: number) => { + const last = tabs.length - 1; + const to = { ArrowRight: (i + 1) % tabs.length, ArrowLeft: (i + last) % tabs.length, Home: 0, End: last }[e.key]; + if (to === undefined) return; + e.preventDefault(); + const next = tabs[to]!; + dispatch({ type: 'view', id: next.id }); + document.getElementById(tabId(next.id))?.focus(); + }; + return ( + // Scrolls sideways rather than wrapping when views outgrow the row; -mb-px lays the indicator on the header rule. +

+ {tabs.map((tab, i) => ( + + ))} +
+ ); +} diff --git a/packages/viewer/src/chrome/ViewerShell.tsx b/packages/viewer/src/chrome/ViewerShell.tsx index a7e302f..e9f6e97 100644 --- a/packages/viewer/src/chrome/ViewerShell.tsx +++ b/packages/viewer/src/chrome/ViewerShell.tsx @@ -1,83 +1,23 @@ -import { useCallback, useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { KIND_LABELS, type LaidOutDiagram } from '@stackmap/core'; import { CanvasPanel } from '../canvas/CanvasPanel'; import { DiagramCanvas } from '../canvas/DiagramCanvas'; import { ExploreProvider, useExplore } from '../explore/ExploreContext'; import { readLiveConfig, readShown } from '../live'; -import { revealChrome, slideIndicator } from '../motion/motion'; +import { revealChrome } from '../motion/motion'; import type { ThemeChoice } from '../theme/theme'; import { IdentityCard } from './IdentityCard'; import { Inspector } from './Inspector'; import { PresentBar } from './Presentation'; import { inMenu } from './Toolbar'; import { Toolbar } from './Toolbar'; +import { ViewTabs } from './ViewTabs'; /** Below this width the inspector starts collapsed (Q27). */ const INSPECTOR_BREAKPOINT = 1100; -// Author view ids are prefixed so a view named "overview" can't collide with the built-in tab. -const tabId = (id: string | null) => (id === null ? 'sm-tab-overview' : `sm-tab-v-${id}`); const DIAGRAM_ID = 'sm-diagram'; -function ViewTabs() { - const { draft, state, dispatch } = useExplore(); - const tabs = [{ id: null, label: 'Overview' }, ...(draft.views ?? []).map((v) => ({ ...v, id: v.id as string | null }))]; - const current = tabs.find((t) => t.id === state.view) ?? tabs[0]!; - // One indicator that slides between tabs, so a view change reads as a move rather than a blink. - const bar = useRef(null); - const placed = useRef<{ x: number; width: number } | null>(null); - useLayoutEffect(() => { - const place = () => { - const tab = document.getElementById(tabId(current.id)); - if (!tab || !bar.current) return null; - const to = { x: tab.offsetLeft, width: tab.offsetWidth }; - bar.current.style.width = `${to.width}px`; - bar.current.style.transform = `translateX(${to.x}px)`; - return to; - }; - const from = placed.current; - const to = place(); - placed.current = to; - // Tab widths change once the web font arrives; follow without animating. - void document.fonts?.ready.then(() => (placed.current = place() ?? placed.current)); - if (!from || !to || (from.x === to.x && from.width === to.width)) return; - const motion = slideIndicator(bar.current, from, to); - return () => motion.cancel(); - }, [current.id]); - // Roving focus across the tablist, per the ARIA tabs pattern. - const onKeyDown = (e: KeyboardEvent, i: number) => { - const last = tabs.length - 1; - const to = { ArrowRight: (i + 1) % tabs.length, ArrowLeft: (i + last) % tabs.length, Home: 0, End: last }[e.key]; - if (to === undefined) return; - e.preventDefault(); - const next = tabs[to]!; - dispatch({ type: 'view', id: next.id }); - document.getElementById(tabId(next.id))?.focus(); - }; - return ( - // Scrolls sideways rather than wrapping when views outgrow the row; -mb-px lays the indicator on the header rule. -
- {tabs.map((tab, i) => ( - - ))} -
- ); -} - /** The identity card's details button shows the diagram itself in the inspector, whatever was selected. */ function DiagramIdentity({ onDetails }: { onDetails: () => void }) { const { draft, dispatch } = useExplore(); @@ -167,7 +107,7 @@ export function ViewerShell({ {KIND_LABELS[draft.kind]} - + {draft.nodes.length} nodes · {draft.edges.length} connections diff --git a/packages/viewer/src/chrome/ZoomBar.tsx b/packages/viewer/src/chrome/ZoomBar.tsx index 1fc552b..56b384b 100644 --- a/packages/viewer/src/chrome/ZoomBar.tsx +++ b/packages/viewer/src/chrome/ZoomBar.tsx @@ -2,7 +2,8 @@ import { Map as MapIcon, Maximize, Minus, Plus } from 'lucide-react'; import { useViewport } from '../canvas/ViewportContext'; import { IconButton, PANEL_CLASS, PANEL_STYLE, ToolbarDivider } from './ui'; -export function ZoomBar({ minimapOn, onToggleMinimap }: { minimapOn: boolean; onToggleMinimap: () => void }) { +/** Without `onToggleMinimap` the bar has no minimap button. */ +export function ZoomBar({ minimapOn = false, onToggleMinimap }: { minimapOn?: boolean; onToggleMinimap?: () => void }) { const { transform, zoomIn, zoomOut, fit } = useViewport(); return (
@@ -17,9 +18,11 @@ export function ZoomBar({ minimapOn, onToggleMinimap }: { minimapOn: boolean; on - - - + {onToggleMinimap && ( + + + + )}
); } diff --git a/packages/viewer/src/embed.test.tsx b/packages/viewer/src/embed.test.tsx new file mode 100644 index 0000000..e07e748 --- /dev/null +++ b/packages/viewer/src/embed.test.tsx @@ -0,0 +1,160 @@ +import { act, fireEvent, render, screen, within } from '@testing-library/react'; +import { useRef, type ReactNode } from 'react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { commerceApiLayout } from './samples/commerce-api.layout'; +import { DiagramCanvas } from './canvas/DiagramCanvas'; +import { IdentityCard } from './chrome/IdentityCard'; +import { Inspector } from './chrome/Inspector'; +import { Toolbar } from './chrome/Toolbar'; +import { ExploreProvider, useExplore } from './explore/ExploreContext'; +import { ViewerScope } from './explore/scope'; + +// Options for mounting the viewer inside someone else's page (the website): it must not own the URL, the keyboard +// or the scroll wheel there. + +const draft = commerceApiLayout.draft; + +function State() { + const { state } = useExplore(); + return {JSON.stringify({ selected: state.selected, query: state.query, playing: state.playing })}; +} +const stateOf = () => JSON.parse(screen.getByTestId('state').textContent!) as { selected: string | null; query: string | null; playing: boolean }; + +function Scoped({ children }: { children: ReactNode }) { + const root = useRef(null); + return ( + +
+ {children} +
+
+ ); +} + +afterEach(() => { + history.replaceState(null, '', location.pathname); +}); + +describe('embedding the viewer', () => { + it('leaves the page’s hash alone when it does not sync with the URL', () => { + history.replaceState(null, '', '#node=sessions'); + render( + + + + , + ); + expect(stateOf().selected).toBeNull(); + fireEvent.click(document.querySelector('.sm-card[data-card-id="orders"]')!); + expect(stateOf().selected).toBe('orders'); + expect(location.hash).toBe('#node=sessions'); + act(() => { + history.replaceState(null, '', '#node=edge'); + dispatchEvent(new HashChangeEvent('hashchange')); + }); + expect(stateOf().selected).toBe('orders'); + }); + + it('still mirrors the hash by default', () => { + render( + + + , + ); + fireEvent.click(document.querySelector('.sm-card[data-card-id="orders"]')!); + expect(location.hash).toBe('#node=orders'); + }); + + it('inside a scope, shortcuts pressed elsewhere on the page do nothing', () => { + render( + + + + {}} /> + + + + , + ); + fireEvent.keyDown(document.body, { key: '/' }); + fireEvent.keyDown(document.body, { key: 'p' }); + expect(stateOf()).toMatchObject({ query: null, playing: false }); + fireEvent.keyDown(screen.getByTestId('scope'), { key: '/' }); + expect(stateOf().query).toBe(''); + }); + + it('outside any scope, shortcuts work from the page body as before', () => { + render( + + + {}} /> + + + , + ); + fireEvent.keyDown(document.body, { key: '/' }); + expect(stateOf().query).toBe(''); + }); + + it('leaves a plain wheel to the page and zooms on ctrl or ⌘ + wheel', () => { + const { container } = render( + + + , + ); + const stage = container.querySelector('.sm-stage')!; + const transform = () => (container.querySelector('.sm-viewport') as HTMLElement).style.transform; + const before = transform(); + const plain = fireEvent.wheel(stage, { deltaY: -100, clientX: 0, clientY: 0, cancelable: true }); + expect(transform()).toBe(before); + expect(plain).toBe(true); // not prevented: the page scrolls + fireEvent.wheel(stage, { deltaY: -100, clientX: 0, clientY: 0, ctrlKey: true }); + const zoomed = transform(); + expect(zoomed).not.toBe(before); + fireEvent.wheel(stage, { deltaY: 100, clientX: 0, clientY: 0, metaKey: true }); + expect(transform()).not.toBe(zoomed); + }); + + it('can leave out the minimap', () => { + render( + + + , + ); + expect(screen.getByRole('button', { name: 'Fit to screen' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /minimap/i })).toBeNull(); + }); + + it('can leave export and trace out of the toolbar', () => { + render( + + + {}} exports={false} trace={false} /> + + , + ); + expect(screen.queryByRole('button', { name: /export/i })).toBeNull(); + expect(screen.queryByRole('button', { name: /^Trace/ })).toBeNull(); + expect(screen.getByRole('button', { name: 'Search nodes (/)' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Route between two nodes (R)' })).toBeInTheDocument(); + }); + + it('draws the identity card without a details button when there is nothing to open', () => { + render(); + expect(screen.queryByRole('button', { name: 'Diagram details' })).toBeNull(); + }); + + it('gives the inspector no toggle when it cannot collapse, and lets the page add to it', () => { + render( + + What the agent was asked

} /> +
, + ); + const inspector = screen.getByRole('complementary', { name: 'Inspector' }); + expect(within(inspector).queryByRole('button', { name: /inspector/i })).toBeNull(); + expect(within(inspector).getByRole('heading', { level: 3, name: draft.title })).toBeInTheDocument(); + expect(within(inspector).getByRole('heading', { level: 4, name: 'Legend' })).toBeInTheDocument(); + expect(within(inspector).getByText('What the agent was asked')).toBeInTheDocument(); + expect(inspector.className).not.toMatch(/@max-2xl:absolute/); + }); +}); diff --git a/packages/viewer/src/explore/ExploreContext.tsx b/packages/viewer/src/explore/ExploreContext.tsx index 34924fc..38e721e 100644 --- a/packages/viewer/src/explore/ExploreContext.tsx +++ b/packages/viewer/src/explore/ExploreContext.tsx @@ -2,7 +2,7 @@ import { createContext, useContext, useEffect, useMemo, useReducer, useRef, type import type { DiagramDraft } from '@stackmap/core'; import { emphasis, type Emphasis } from './emphasis'; import { buildGraph, type Graph } from './graph'; -import { explore, formatHash, parseHash, type ExploreAction, type ExploreState, type Known } from './state'; +import { explore, formatHash, INITIAL, parseHash, type ExploreAction, type ExploreState, type Known } from './state'; export interface Explore { draft: DiagramDraft; @@ -36,9 +36,18 @@ const knownOf = (d: DiagramDraft): Known => ({ types: new Set(d.nodes.map((n) => n.type)), }); -export function ExploreProvider({ draft, children }: { draft: DiagramDraft; children: ReactNode }) { +export function ExploreProvider({ + draft, + syncHash = true, + children, +}: { + draft: DiagramDraft; + /** false when the viewer is one part of someone else's page: the URL is that page's, not a deep link */ + syncHash?: boolean; + children: ReactNode; +}) { const known = useMemo(() => knownOf(draft), [draft]); - const [state, dispatch] = useReducer(explore, undefined, () => parseHash(location.hash, known)); + const [state, dispatch] = useReducer(explore, undefined, () => (syncHash ? parseHash(location.hash, known) : INITIAL)); const graph = useMemo(() => buildGraph(draft.nodes.map((n) => n.id), draft.edges, { timed: draft.kind === 'sequence' }), [draft]); const em = useMemo(() => emphasis(draft, graph, state), [draft, graph, state]); const stateRef = useRef(state); @@ -47,10 +56,11 @@ export function ExploreProvider({ draft, children }: { draft: DiagramDraft; chil // Deep links: the hash mirrors view/node/lens without adding history entries. const hash = formatHash(state); useEffect(() => { - if (hash === location.hash || (!hash && !location.hash)) return; + if (!syncHash || hash === location.hash || (!hash && !location.hash)) return; history.replaceState(history.state, '', hash || location.pathname + location.search); - }, [hash]); + }, [hash, syncHash]); useEffect(() => { + if (!syncHash) return; // A new node in the hash is a new reveal, so the camera follows every deep link, not just the first. const onHash = () => dispatch({ @@ -59,7 +69,7 @@ export function ExploreProvider({ draft, children }: { draft: DiagramDraft; chil }); addEventListener('hashchange', onHash); return () => removeEventListener('hashchange', onHash); - }, [known]); + }, [known, syncHash]); const value = useMemo(() => ({ draft, state, dispatch, graph, emphasis: em }), [draft, state, graph, em]); return ( diff --git a/packages/viewer/src/explore/scope.ts b/packages/viewer/src/explore/scope.ts new file mode 100644 index 0000000..23f616b --- /dev/null +++ b/packages/viewer/src/explore/scope.ts @@ -0,0 +1,13 @@ +import { createContext, useCallback, useContext, type RefObject } from 'react'; + +// A delivered file is the whole page, so its shortcuts listen on the window. Mounted inside another page, the +// viewer gets a scope: its shortcuts then act only on keys pressed while focus is inside that element. +const ScopeContext = createContext | null>(null); + +export const ViewerScope = ScopeContext.Provider; + +/** Whether a window key event is the viewer's to handle: always without a scope, else only from inside it. */ +export function useInScope(): (e: Event) => boolean { + const root = useContext(ScopeContext); + return useCallback((e: Event) => !root || (e.target instanceof Node && !!root.current?.contains(e.target)), [root]); +} diff --git a/site/app/examples/page.tsx b/site/app/examples/page.tsx index 6ce39bc..b5ae5aa 100644 --- a/site/app/examples/page.tsx +++ b/site/app/examples/page.tsx @@ -24,7 +24,7 @@ const agents = EXAMPLES.filter((e) => e.prompt).length; export default function ExamplesPage() { const { items, thumbs, rest } = galleryProps(EXAMPLES); return ( -
+

Examples.

diff --git a/site/app/globals.css b/site/app/globals.css index e25bd44..128ba59 100644 --- a/site/app/globals.css +++ b/site/app/globals.css @@ -12,41 +12,23 @@ --font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, monospace; --color-accent: var(--site-accent); - /* The floor lets the longest title, "Architecture.", fit a 320px screen. */ - --text-mega: clamp(44px, 13.9vw, 200px); - --text-mega--line-height: 0.92; - --text-mega--letter-spacing: -0.05em; + /* The v2 Type board: page titles share the hero's size; docs titles sit a step below. */ + --text-mega: clamp(40px, 5vw, 72px); + --text-mega--line-height: 1.04; + --text-mega--letter-spacing: -0.04em; --text-mega--font-weight: 600; - --text-feature: clamp(56px, 8.3vw, 120px); - --text-feature--line-height: 0.95; - --text-feature--letter-spacing: -0.05em; - --text-feature--font-weight: 600; - --text-hero: clamp(52px, 7.8vw, 112px); - --text-hero--line-height: 0.9375; - --text-hero--letter-spacing: -0.045em; - --text-hero--font-weight: 600; - --text-kinetic: clamp(44px, 6.7vw, 96px); - --text-kinetic--line-height: 1; - --text-kinetic--letter-spacing: -0.045em; - --text-kinetic--font-weight: 600; - --text-section: clamp(34px, 5vw, 72px); - --text-section--line-height: 1.01; - --text-section--letter-spacing: -0.04em; - --text-section--font-weight: 600; - --text-display: clamp(36px, 4.2vw, 60px); - --text-display--line-height: 1.1667; - --text-display--letter-spacing: -0.03em; - --text-display--font-weight: 600; + --text-doc: clamp(40px, 4.5vw, 64px); + --text-doc--line-height: 1.02; + --text-doc--letter-spacing: -0.04em; + --text-doc--font-weight: 600; --text-h2: clamp(28px, 2.8vw, 40px); --text-h2--line-height: 1.1; - --text-h2--letter-spacing: -0.03em; + --text-h2--letter-spacing: -0.035em; --text-h2--font-weight: 600; - --text-lede: clamp(17px, 1.53vw, 22px); - --text-lede--line-height: 1.41; + --text-lede: clamp(18px, 1.53vw, 22px); + --text-lede--line-height: 1.4; --text-body: 16px; --text-body--line-height: 1.6875; - --text-prompt: clamp(26px, 4.4vw, 64px); - --text-prompt--line-height: 1.3; --text-code: 13px; --text-code--line-height: 1.7; } @@ -69,12 +51,6 @@ body { 0 8px 24px -12px rgb(0 0 0 / 0.12); } -/* Both lockups are in the markup; the nearest forced theme picks one, so the nav can follow each scene. */ -[data-theme='dark'] .lockup-light, -[data-theme='light'] .lockup-dark { - display: none; -} - /* An async edge's dashes running toward its head: one dash-and-gap per loop. */ @keyframes dash-flow { to { diff --git a/site/app/kinds/[kind]/page.tsx b/site/app/kinds/[kind]/page.tsx index 0b22172..5130b36 100644 --- a/site/app/kinds/[kind]/page.tsx +++ b/site/app/kinds/[kind]/page.tsx @@ -40,7 +40,7 @@ export default async function KindPage({ params }: PageProps<'/kinds/[kind]'>) { const file = `${SKILL_EXAMPLE[kind]}.json`; const next = DIAGRAM_KINDS[(DIAGRAM_KINDS.indexOf(kind) + 1) % DIAGRAM_KINDS.length]!; return ( -

+

diff --git a/site/app/layout.tsx b/site/app/layout.tsx index d87bf21..022ac71 100644 --- a/site/app/layout.tsx +++ b/site/app/layout.tsx @@ -6,8 +6,9 @@ import { tokens } from '@stackmap/core'; import { Footer } from '@/components/site/Footer'; import { Nav } from '@/components/site/Nav'; import { DESCRIPTION, OPEN_GRAPH, REPO, SITE_NAME, siteUrl } from '@/lib/seo'; -import { themeCss } from '@/lib/theme'; +import { THEME_SCRIPT, themeCss } from '@/lib/theme'; import './globals.css'; +import './site.css'; export const metadata: Metadata = { metadataBase: siteUrl(), @@ -28,8 +29,10 @@ export const viewport: Viewport = { export default function RootLayout({ children }: { children: ReactNode }) { return ( - + // The head script sets data-theme from the reader's stored choice before React hydrates. + +