← Design · c125

tour-layer.md

The tour layer: IDs, a floating panel, Examples and Drills

From c123's addressable tour (thread owasp-agentic, 2026-10-27), built in the lab as a working prototype for c125 to pick up. Mark's direction, the same day:

We'll need a way to highlight sections … clear design distinction to separate the "Tour guide / meta narrative" from the live dashboard it floats over. We want to be as non-intrusive as possible to the UI … IDs that we use to highlight a component is much preferred over the component needing to know about this meta layer. We should be able to play these tours over the top of the real dashboard without polluting the dashboard space with more than ids. The tour space should have a way to create example instances of various pieces of the dashboard (error states).

And on drills: show what would happen if a violation occurred (the gauge it lights, your notification settings), with a Simulate violation button that sends real messages through the customer's channels, end to end. Drill receipts are worth keeping: "we track everything".

Ruled in the same conversation: the panel floats, it doesn't dock. Row keys: "start as light as possible, implementation will answer this one". c125 owns the runtime and protocol; c123 owns the OWASP content.

Try it: /pages/phase4/tour-owasp-agentic (the route, with Start and per-beat links), or any composed mock with ?tour=owasp-agentic&step=<step id>.

1. The protocol: what the dashboard carries

On the dashboardWhat it is
data-qp-id="<page>.<region>"A stable name on the element whose box can be highlighted. A layer component takes it on its root by attribute fallthrough (<DataMachine data-qp-id="home.findings" …>), so components don't change.
data-qp-key="<instance>" (optional)On a repeated element (a row's name cell), the instance it is. The step picks keys and can expand each match to its closest tr.
<TourHost />, once, in the layoutInert until a tour starts. In the lab: app/layouts/governance-mock.vue.

Nothing else. No tour imports, props or state in a page or component. The tour's own state lives in the tour layer (useState + sessionStorage), not the dashboard's URL. ?tour=&step= is only a way in, and the host strips it once read.

Naming. <page>.<region> in kebab case, with sub-regions dotted (mcp.policy.approval-verbs). An ID names a region by what it is, not by the tour that uses it, so any tour, the help mode and the MCP guide can share it.

What a missing ID does. The step still shows its panel with no ring. To build: a check that every ID a tour names exists in an ID registry, so a renamed region fails a build instead of silently losing its ring.

Row keys (open, as Mark ruled). The lab uses them on three lists: Sources rows, github's tools, and notification channels. Expanding a key to its tr keeps the attribute on a cell the row already renders. If implementation shows that region-level IDs are enough, drop keys.

2. The materials: Live, Example, Drill, and the tour itself

KindLooks likeMeans
Livethe dashboard as it is; the tour only draws a ring around ityour data
Examplea real dashboard component with example props, floated exactly over its live counterpart: white card, dashed grape frame, a dark tab reading "Example · your live value is N"what it would look like if …
Drilla dark "Drill D-0007" tag on a delivery-log row; messages titled "Qpoint drill"a real delivery about a simulated cause
The tourdark grape (grape-900), white type: the floating panel, numbered callouts, the 3 px top edge; the ring in grape-500 with a soft glowthe narrator, never the product

When an Example covers the target, the step draws no ring: the dashed frame already shows where to look.

Two switches the lab compares (Mark, 2026-10-27), in the panel header at every step and remembered for the visit:

  • Panel: Float · Split. Float is the panel floating over the page, which picks a side. Split docks the panel as a 420 px full-height column, and the dashboard narrows beside it. The host reserves the column with padding-right on <html> and publishes the width as --qp-dock-right. Anything the app fixes to the right edge adds that variable to its offset, the way it would a safe-area inset. In the lab that's only the PublishTray. That variable is the one convention split asks of a dashboard. ?tourLayout=split opens a tour that way.
  • Focus: Ring · Dim · Fade.
    • Ring: the ring and its glow.
    • Dim: one scrim over the viewport with holes cut for the focused regions and Example frames, so everything else darkens. The ring stays, quieter. The scrim never takes a click.
    • Fade (Mark's follow-up: dim every other element, not the page): the tour layer walks up from each focused element and, at every level, sets opacity on the siblings that don't contain a focused element. The focused elements, their ancestors and the page background keep full strength, so the page washes toward its own background with no dark layer. The styles are set at run time and restored on exit or a switch (composables/useTourFade.ts).
    • Fade's trade-off: context fades too, such as a table's header row when the focus is some of its rows. If Fade wins, a step may want a keep list of regions that stay at full strength.

    Swapping the dashboard's background colour would have needed the page to cooperate, and wouldn't dim its content.

Focus only what's specific (Mark, 2026-10-29: "I don't know that we gain anything by highlighting the whole page"). A step rings the one region that answers its question, not every region it mentions. The opening rings nothing; stop 1 Tune rings the impact card only; stop 2 Tune rings the two new controls only. Dim and Fade also fall back to the ring when the focus covers more than about half the viewport, since past that they darken almost nothing.

3. Drills

A drill runs a simulated cause through the real alert path:

  1. Before: the panel shows what Qpoint would send (the signals: severity, title, policy), the customer's settings today (each policy's live mode and what it does, and where each severity is routed, with "not connected" called out), and one sentence on what would happen today, worked out from those settings ("the call goes through, two findings are raised, Slack posts twice, your webhook gets one POST, and nobody is paged"). Examples show the cards that would light up.
  2. Simulate violation sends one message per signal to each connected channel that takes its severity. Every message is titled [Qpoint drill], carries the drill id, and says "Simulated from the tour by · no agent did this". Webhook payloads carry "drill": true, "drill_id".
  3. After: receipts per channel (delivered / 200 OK / not connected, nothing sent), a preview of the Slack message and the webhook payload, and the delivery log with the drill's rows tagged. The tour then points at what to fix, such as connecting PagerDuty and running it again.

Rules:

  • A drill never creates a finding and never enters evidence, framework detail or the evidence export. It lives in the delivery log, which is where "when was the alert path last tested?" gets answered.
  • To build: PagerDuty and other paging channels need an explicit tick per run, and should open the incident and resolve it automatically (dedup key qpoint-drill-<id>). Also a rate limit (one drill per minute per org), and admins only.
  • In the lab, nothing is sent: useNotificationsMock().runDrill() simulates the deliveries.

4. Content as data

A tour is data/tours/<id>.ts (types in data/tours/types.ts): stops, then steps. A step has:

  • a route and a beat;
  • the targets to ring, its callouts and its Examples;
  • the panel's blocks: lede, ask, numbers, mitigations, list, signals, settings, expect, receipts, route, yours, note;
  • at most one action: stage writes changes to the draft, drill runs a scenario.

Numbers and settings are functions of a context the app supplies; they never come from the page's text. Because content is data, the MCP guide (objective 7) can read every block without a browser.

The adapter is the seam. composables/useTourAdapter.ts is the only file that knows both the tour and the app. It supplies the context (the same data the pages read) and two verbs: stage(changes) and drill(scenario). In qdash, that file sits on the data interface. The tour components don't change.

5. Where to pick it up (work packages)

Each package states its inputs, its files, and when it is done. Packages W1–W4 are c125's; W5–W6 are c123's.

#PackageStarts fromDone when
W1ID protocol and registry. Write down the naming rules, keep a registry of IDs per page, and add the build check that every tour's IDs exist. Fold in c125's help ids (objective 1): one attribute for help, markers and tours.§1; data/tours/types.ts TourTarget; the IDs listed on /pages/phase4/tour-owasp-agenticdata-qp-id is the only attach point. A test fails on an unknown ID.
W2Tour host: ring, callouts, floating panel, pill. Harden the lab prototype: focus management, Esc to minimize, keyboard Next/Back, reduced motion, phone width, and the panel avoiding its targets (it picks a side today).components/tour/{Host,Panel,Blocks,Ring,Callout}.vue, composables/useTour.tsIt passes c125 objective 8's scenarios (empty, minimal, typical, overflow, error) on one lab page.
W3Examples. Example instances of any layer component, positioned over an ID. Decide whether Examples stay as render() functions in tour data or become named presets (like a design-site demo), so a non-engineer can write one.components/tour/Example.vue; examples in data/tours/owasp-agentic.tsAn error state of any DataMachine can be shown without touching the page.
W4Drill runtime. A real POST /drills that sends through the integrations' test path. Paging opt-in, auto-resolve, rate limit, admin-only. The delivery log's drill tag.§3; composables/useNotificationsMock.ts; the integrations mock's "Save and test" (pages/pages/integrations-ideas-v11.vue)One drill delivers to Slack, a webhook and a paging tool in a real environment, with receipts in the log.
W5OWASP signal catalog (c123). For each stop, the signals Qpoint sends: trigger, policy, severity, the card it lights. Stops 1 and 2 are written.owasp-agentic-tour-v2.md § The Drill beat; data/owasp-tour-fixture.ts DRILLSStops 3–5 have Watch, Tune and Drill steps in data/tours/owasp-agentic.ts.
W6Prove beat (c123 + c125). One shared evidence view (findings and config history filtered to the stop) or a board per stop.v2 spec, Open 5Each written stop has a Prove step.
W7MCP tour guide (objective 7, Phase 4; thread mcp-guide, 2026-10-29). First, a page API (window.qpTour) over useTour(). Then an MCP endpoint the dashboard hosts (/api/tour/mcp, Streamable HTTP), relayed to the open tab over SSE. The agent navigates, highlights and reads; only the person runs a drill or stages a change. Tested on a demo page and a seven-rung ladder before qdash.mcp-guide-plan.md; composables/useTour.ts; components/tour/Host.vueClaude Code walks the OWASP tour on :3000 from one prompt, recorded. The rung 1–4 script passes, and it runs unchanged against qdash after the port.

Also open: the operations journey (J1–J7, objective 6) can reuse the whole layer. Its first stop, "first signal arrived", is a drill in reverse: wait for a real signal instead of sending a fake one.

6. Panel iterations (2026-10-29)

Mark's review of v1: the dark panel may not survive a theme change, and the panel is information overload that collides with its own controls. Prompt, verbatim: tour-panel-iterations-prompt.md. Try it: /pages/phase4/tour-iterations (each iteration opens on Source health · Watch; switch at any step from the mock bar's tour row).

Nothing earlier is lost: an iteration is a value of the tour state (TourVariant in composables/useTour.ts), opened by link with ?tourVariant= (with tourLayout and tourFocus), and v1's files keep their v1 look. /pages/phase4/tour-owasp-agentic still opens v1.

PanelMaterialControlsOpens with
v1 · Grape (2026-10-27)TourPanel + TourBlocks, unchangeddark grape, white typein the panel headerFloat · Ring
v2 · CyanTourPanelCyan + TourBlocksCyan (v1 recoloured, nothing else)the page's own surface; every word cyan, 600–800 (100–400 in dark mode)in the panel header, plus Ring + DimSplit · Ring + Dim
v3 · BriefTourPanelBrief: the stop's name, the question, the step's lede (a drill: its "what if" and what happens today), the one action, Back / Nextas v2TourMetaBar, a tour row in the mock bar: stop and its bar, beats, risk IDs, Panel, Focus, minimize, endSplit · Ring + Dim

Across v2 and v3:

  • Cyan is the tour's material. Ring, callouts, Example frames, the top edge and the pill take it from the host (material prop; grape is the default, so v1 is unchanged). Plain Tailwind cyan; no token yet (Mark: don't worry about tokens in this phase).
  • Ring + Dim (focus: 'ringdim'): the scrim, with the ring at full strength (Dim alone keeps it quiet). On a dark page the scrim barely shows, so the ring carries the focus there.
  • A slow scroll (glide in TourHost): an eased glide (ease-in-out quint, 2026-10-29; each frame scrolls with behavior: 'instant', or the layer's scroll-behavior: smooth adds a lag of its own), 0.7–1.6 s by distance, that centres the step's first region under the sticky band (or puts its top just under the band when it's taller than the room). It stays put when the region is already in comfortable view, and a wheel or touch stops it. v1 keeps its quick scrollIntoView.
  • The dim reaches the page's floating chrome (Mark, same day: the sticky band and the Policy Config tray "float above the darkened layer"). The scrim sits under the band (z 15) so the band still covers what scrolls beneath it; useTourChromeDim darkens the sticky and fixed elements stacked above the scrim (z 16–59) with a brightness(0.58) filter, the scrim's own strength (the v2/v3 scrim is plain black at 0.42, which is exactly that filter over any colour, so the two match; a tinted scrim didn't), while Dim or Ring + Dim is on. Nothing moves in the stack. Set at run time and restored, like Fade.
  • v3 drops the cyan frame (Mark: "we don't need to highlight the whole page with a cyan ring"): no 3 px top edge, and the panel's edge is a faint hairline. The ring stays on the step's region only.
  • Pure cyan, on white or black (Mark: Tailwind's cyan "looks more green than cyan"; the tour's ground "white (black in dark mode) instead of a tint"). The tour's material is CSS variables in TourHost's <style> with tour-* classes: accent #00FFFF (ring, glow, fills, stop bar, TOUR chip, numbered badges), type deep cyan rgb(0 104 116) / titles rgb(0 74 84) in light mode (pure cyan is ~1.25:1 on white, unreadable as text) and pure cyan itself in dark mode, ground #FFF / #000. Used by v3's panel, the tour row and the cyan marks; v2's panel keeps its Tailwind cyan as recorded.
  • The mock bar's beats ask the host for a step (useTour().goTo), so routing and the glide are the host's, as for its own Next.

v4 · Tuned (same day): Mark redrew v3 in Illustrator (illustrator/c125-tour-panel-v3*.ai, exported for him to tune), and v4 is that drawing. v3 stays as it was.

  • Colour: #0099ff for every word and fill, white type on fills, neutral hairlines, Back outlined in the accent. The variables sit under :root.qp-tour-v4, a class the host puts on <html> while v4 runs, so the mock bar's tour row picks them up too.
  • Overlay: #73858f at 60% (Mark's first pick was #1f4e66). No ring with Dim: the cut-out is the highlight, with 12 px more room around the content than v3 (ROOM in TourHost; Mark: "a bit tight"), and the callout 28 px off it. Ring and Ring + Dim still draw one.
  • Chrome under the overlay: a brightness filter can't match a tinted scrim, so TourChromeScrim lays the same tint over the boxes of the sticky and fixed chrome above the page scrim (useTourChromeBoxes, measured every frame). One group carries the opacity, so overlapping pieces are tinted once. Only chrome with its own background is covered; tinting a see-through one (the page title before the band pins) tinted the page twice. A pill's radius is capped at half its height, as CSS does, so the collapsed tray isn't covered by an ellipse. The trade-off: before the band pins, the page title's text stays at full strength.
  • Callout: a white card (black in dark mode), no border, soft shadow, a 32 px numbered disc, the sentence at 13 px semibold in the accent, and a pointer toward the region.
  • A way out on the panel: a ✕ in its top-right corner, and End tour on the last step (v3 ends only from the mock bar's ✕, which scrolls away with the bar).
  • Header and stop bar (Mark's second drawing): "Tour : ▾" and a bold ✕ above a hairline; under it one segment per stop (the current one solid, the rest at 20%) and "n of N" counted from the tour's stops (OWASP has seven, so Source health is 2 of 7), then the stop's name, the question and the description.
  • Tour menu: the panel's top row carries the tour's name as a menu (▾) beside the ✕. It lists TOUR_CATALOG (data/tours/index.ts): written tours start where they begin, keeping Panel, Focus and iteration; the rest are named and marked "Not written yet". The catalog is c123's (its tours thread owns data/tours/*.ts): five tours in the order a customer meets them, Start here, Common AI risks, Observe then enforce, AI spend, OWASP® Agentic Top 10. It closes on a pick, a click outside or Esc.
  • Examples (Mark, on Common AI risks: the tab "looks awkward and broken", and "your live value is 7" didn't say what it meant). Each example gets the tour's pointer callout above its live card, with a Simulate violation box in it. Unticked: the live card shows, uncovered and highlighted, and the callout reads "Your card today". Ticked: the example covers the card in a dashed outline, and the callout reads "An example, not your data / Your card shows 7 today." (the tour data's "your live value is N", reworded in the component; the data is c123's). Each box unticks on a new step. While unticked the frame takes no clicks, so the live card stays usable.
    • Tried first, the same afternoon: the example under the step's text in the panel (an "examples: On page · In panel" switch). Mark moved the choice onto the card instead; capture c125-tour-panel-v4-examples-panel.png keeps it.
    • Note: the step's own drill button is also labelled "Simulate violation", and it does something different (it runs the drill through the alert path).
  • Callouts above unpinned chrome. Callouts and Examples sat at z 17–18, under the page title's sticky layer, so "Overview" and its rule drew across them. useTourChromeBoxes now also reports whether any sticky chrome is pinned (its top at its sticky offset); while nothing is, v4's callouts and Examples sit at z 50, above the chrome, and once the band pins they drop back under it, so it still covers what scrolls beneath.
  • Shadow: docked, the panel casts the sticky band's shadow turned on its side: a 7 px contact shadow at 0.11 and a 28 px falloff at 0.08, black fading leftward (the layer's StickyBand ._strip values).
  • Opens with Split · Dim. Capture: c125-tour-panel-v4-tuned.png, variant v4-tuned.

v5 · Panel (same day, from Mark's third drawing): v4 with an Example's controls in the panel. The card on the page keeps only its numbered disc. Under the step's text the panel lists each Example: the number, the card's name (the example component's own title), a Simulate box and a scenario <select> of the lab's five drills (each drill's short label, c123; the step's own drill is the default). Ticking, or picking a scenario, shows the example on its card. Each pick resets on a new step.

  • How the pick reaches the card: an Example's render reads its drill from ctx.drills[<its drill id>], so the host hands it a context where every drill id resolves to the chosen scenario (scenarioCtx in TourHost). Every Example card is now computed from its drill (c123 replaced the fixed ones the same afternoon: findingsExample, sensitiveExample, sourcesExample on a shared cardExample), so each follows the pick; a scenario that raises nothing on a card leaves it at its base value with "nothing this scenario would raise here".
  • Capture: c125-tour-panel-v5-panel.png, variant v5-panel.

The callout, one component (same day, from Mark's callout sheet): TourMarker (components/tour/Marker.vue) draws every callout v4 and v5 show. Props: n, title, description, size (lg, sm), pointer (left, center, none) and pointerSide (bottom when it sits above what it marks, top when below). With neither title nor description it's a pin: a teardrop with the disc upright inside. Extra content goes in its slot (v4's Simulate box). It's presentational: TourCallout positions it over a region (data's text is its description; a title and size are optional props), TourExample over a card. The tour's material moved to components/tour/tour-material.css so the gallery can load it without running a tour; .qp-tour-v4-scope gives v4's colours to one element's subtree. Gallery: "The callout: TourMarker" on /pages/phase4/tour-iterations.

Where a callout goes (same day; Mark, on Start here's "Add endpoints" callout running under the docked panel and over the mock bar's controls). TourFloat (components/tour/Float.vue) places every v4/v5 marker with Floating UI (@floating-ui/dom, added to design for this):

  1. Safe area (tourSafeArea, provided by TourHost, viewport coordinates): below the lab's own chrome (anything marked data-qp-tour-avoid: the mock bar) and below chrome pinned at the top (the band); 16 px short of the tour panel on whichever side it sits; 8 px off the window's edges.
  2. Sides in order: the preferred placement (default top-start; pins top), then top, bottom, right, left; the first that fits, else the one with the most room (flip, best fit).
  3. Slide, keep pointing: shift keeps it inside; the pointer (TourMarker pointerSide / pointerAt, now on any edge at any offset) aims at the target's start: a row's name, not the middle of a wide row.
  4. Author hint: a callout in tour data may carry placement (Floating UI's names); it's a first choice, not an order. The field is c123's to add to TourCallout; the host already passes it through.
  5. Don't cover what the step highlights (Mark, same day, after the installer token callout sat over the Download buttons): the host provides tourObstacles (the step's ring regions, Example frames and every callout's target, page coordinates). Floating UI has no obstacles, so TourFloat tries each side in turn (offset + shift) and scores it: area outside the safe area plus area over any obstacle. The first clear side wins, else the least covered. Checked on Start here · installer with the step's bottom-start hint ignored: the rule alone puts the callout below the Download area, over none of its buttons.
  • Checked: Start here · open now puts the callout left of "Add endpoints", clear of the mock bar and the panel (c125-tour-v4-start-here-open.png); the Sources row callout, v4's example callouts and v5's pins still sit above their targets.

Minimize (same day): v4 and v5's panel has a minimize button beside the ✕. Minimized, the overlay, the marks and the split column go, and the tour shrinks to a 48 px circle in the tour's blue at the bottom right (a placeholder route icon; the icon is to be designed). The circle reopens the tour on the same step. v1–v3 keep their pill.

Open:

  • The tour row scrolls away with the mock bar; a sticky one would sit over the band. Back / Next stay in the panel.
  • v3 hides the numbers, mitigations and lists. They're still in the tour data (for v1, v2 and the MCP guide). Where they come back, if at all, is the manual design pass.
  • In Split, a wide table (Sources) still runs under the docked panel. Its ring and the dim's hole stop 16 px short of the panel (clearOfDock in TourHost, v2 and v3), so the highlight never butts against the tour's column.
  • Captures: public/cycles/design/c125-tour-panel-{v1-grape,v2-cyan,v3-brief}.png, subject page/phase4-tour-panel (one variant lane each), on Source health · Watch.

Qpoint Brand Style Guide