package webui import ( "strconv" "kjol/vdom" "kjol/wasmruntime" ) // Port of jsruntime/uikit/Modal.tsx. // // A modal is a *controller*, not a pure function: it owns the open flag, its slot // on the shared modal stack (so Escape closes one layer per press), the refs it // animates, and the document listener it installs. Create one per modal, ONCE, // alongside your signals — never inside a render function, which would rebuild it // (and its refs) every frame: // // edit := webui.NewModal(webui.ModalOptions{Size: webui.ModalMedium}) // return func() *vdom.VNode { // return Div( // webui.Button(webui.ButtonProps{Text: "Edit", OnClick: edit.Open}), // edit.Render(webui.ModalProps{Header: Text("Edit user")}, form...), // ) // } // // What the dialog does and why: // // 1. It renders PORTALED to document.body, so a modal opened from inside another // modal's body is not clipped by the parent's `overflow: hidden` and does not // have its `position: fixed` re-rooted by an ancestor `transform`. // 2. Escape closes only the TOP modal (see the modal stack below), so nested // modals unwind one layer per press. // 3. Opening runs the enter animation imperatively: the panel is rendered at // opacity 0 / scale(.95), then — after a DOUBLE requestAnimationFrame, which is // what makes the browser commit that initial style before the target lands — // SetStyle writes opacity 1 / scale(1) and the declared CSS transition // interpolates. Writing this through a signal would re-render the entire app on // every animation step. // // Faithful to the TSX (deliberately): the container is `` — the // ATTRIBUTE, not .showModal() — so there is no native top layer, no ::backdrop and // no native focus trap, and the original did no focus management of its own. None // is invented here. type Modal struct { opts ModalOptions open *vdom.Signal[bool] panelRef *vdom.Ref backdropRef *vdom.Ref unsubs []wasmruntime.Unsub } // ModalOptions configures a Modal. The zero value is usable: default size, aligned // to the top of the viewport, closes on Escape and on a backdrop click. type ModalOptions struct { Size ModalSize CenterOnScreen bool // OnClose fires after the modal closes, however it was dismissed (backdrop, the // ✕ button, Escape, or a Close() call). The controller owns the open flag, so // this is a notification — you do not have to mirror it in your own signal. OnClose func() KeepOnEscape bool // inverted: by default Escape closes the topmost modal KeepOnBackdrop bool // inverted: by default a backdrop click closes } // ModalSize selects the modal panel's max width. type ModalSize string const ( ModalSmall ModalSize = "small" ModalDefault ModalSize = "default" ModalMedium ModalSize = "medium" ModalLarge ModalSize = "large" ModalXLarge ModalSize = "xlarge" Modal2XLarge ModalSize = "2xlarge" Modal3XLarge ModalSize = "3xlarge" Modal4XLarge ModalSize = "4xlarge" Modal5XLarge ModalSize = "5xlarge" ModalFull ModalSize = "full" ) // The enter animation (ANIMATION_DURATION = 100ms in the TSX). The initial styles // are DECLARED on the element — so the reconciler emits an identical style attribute // on every render and never clobbers the imperative writes (see updateAttrs, which // only calls setAttribute when the declared value changes) — and the target styles // are written imperatively after a double rAF. const modalPanelStyle = "opacity:0;transform:scale(0.95);transition:opacity 100ms cubic-bezier(0.4, 0, 0.2, 1), transform 100ms cubic-bezier(0.4, 0, 0.2, 1)" const modalBackdropStyle = "opacity:0;transition:opacity 100ms ease-out" // -- Tailwind class constants -------------------------------------- const modalContainerBase = "fixed inset-0 z-[100] w-full h-dvh m-0 border-0 bg-transparent flex justify-center max-w-screen max-h-dvh" const modalContainerTop = "items-start pt-10" const modalContainerCenter = "items-center" const modalBackdrop = "fixed inset-0 bg-black/30" const modalBase = "relative bg-surface shadow-md text-sm w-full rounded-default max-h-[calc(100dvh_-_5rem)] flex flex-col overflow-hidden" var modalSizes = map[ModalSize]string{ ModalSmall: "max-w-md", ModalDefault: "max-w-xl", ModalMedium: "max-w-2xl", ModalLarge: "max-w-3xl", ModalXLarge: "max-w-4xl", Modal2XLarge: "max-w-5xl", Modal3XLarge: "max-w-6xl", Modal4XLarge: "max-w-7xl", Modal5XLarge: "max-w-[90rem]", ModalFull: "max-w-none", } const modalHeader = "flex items-center justify-between py-5 px-7 pb-4 border-b border-line text-lg font-semibold text-text-heading" const modalHeaderCloseOnly = "flex items-center justify-end p-4 pb-1" const modalCloseBtn = "cursor-pointer text-ink-muted bg-transparent border-0 p-0 leading-none hover:text-ink-soft" const modalBody = "px-7 py-6 overflow-y-auto flex-1 min-h-0 [background:linear-gradient(var(--color-white),var(--color-white))_bottom_/_100%_3rem_no-repeat_local,linear-gradient(to_bottom,transparent,var(--color-white))_bottom_/_100%_3rem_no-repeat_scroll,var(--color-white)]" const modalFooter = "py-4 px-7 pb-[calc(1rem_+_env(safe-area-inset-bottom,0px))] flex flex-row justify-end gap-2 border-t border-line bg-surface-muted rounded-b-default" const modalFooterSpacer = "h-2" const modalWizardError = "flex items-center gap-2 py-3 px-8 text-sm text-red-700 bg-red-50 dark:bg-red-950 border-t border-red-200 dark:border-red-900" const modalWizardErrorIcon = "shrink-0 text-red-500 dark:text-red-400" // Confirm modal const modalConfirmWrap = "flex justify-end gap-2" const modalConfirmCancel = "py-2 px-4 text-sm border border-line-strong rounded-default bg-transparent cursor-pointer hover:bg-surface-muted" const modalConfirmOkBase = "py-2 px-4 text-sm rounded-default border-0 cursor-pointer text-white" var modalConfirmOkVariants = map[string]string{ "danger": "bg-red-600 hover:bg-red-700", "primary": "bg-primary hover:bg-primary-hover", } // Wizard header const modalWizardHeader = "flex flex-col items-center gap-2 flex-1" const modalWizardTitleRow = "flex items-center justify-between w-full" const modalWizardTitle = "text-xl" const modalWizardStepName = "text-xs font-semibold text-ink-soft uppercase tracking-wider" const modalWizardSteps = "flex items-center justify-between relative w-full max-w-64" const modalWizardTrack = "absolute top-1/2 left-0 right-0 h-0.5 bg-surface-strong -translate-y-1/2" const modalWizardTrackFill = "h-full bg-primary transition-[width] duration-300 ease-in-out" const modalWizardStepWrap = "relative z-[1]" const modalStepIndicatorBase = "w-7 h-7 rounded-full flex items-center justify-center text-xs font-semibold border-2 shrink-0 transition-all duration-300 ease-in-out" const modalStepIndicatorPending = "border-line-strong text-ink-faint bg-surface" const modalStepIndicatorActive = "bg-primary text-white border-primary" const modalStepIndicatorCompleted = "bg-primary text-white border-primary" // Wizard footer const modalWizardFooter = "flex items-center justify-between w-full gap-2" const modalWizardBtnBase = "py-2 px-5 text-sm rounded-default cursor-pointer border-0 disabled:opacity-40 disabled:cursor-not-allowed" const modalWizardBtnBack = "bg-transparent border border-line-strong text-ink-soft enabled:hover:bg-surface-muted" const modalWizardBtnNext = "bg-neutral-800 text-white enabled:hover:bg-neutral-900" const modalWizardBtnFinish = "bg-primary text-white enabled:hover:bg-red-700" // NewModal creates a modal controller. Call it once, outside your render function. func NewModal(o ModalOptions) *Modal { return &Modal{ opts: o, open: vdom.NewSignal(false), panelRef: vdom.NewRef(), backdropRef: vdom.NewRef(), } } // IsOpen reports the current state. Safe to read during render. func (m *Modal) IsOpen() bool { return m.open.Get() } // Open, Close and Toggle drive the dialog. Safe to call from event handlers. func (m *Modal) Open() { if m.open.Get() { return } modalStackPush(m) if !m.opts.KeepOnEscape { m.unsubs = append(m.unsubs, wasmruntime.OnDocument(vdom.EVENT_KEYDOWN, false, m.onKeydown)) } m.open.Set(true) // The dialog does not exist yet — the signal write only *scheduled* a render. // Animate it in once it does. wasmruntime.AfterRender(m.mounted) } func (m *Modal) Close() { if !m.open.Get() { return } m.teardown() modalStackRemove(m) m.open.Set(false) if m.opts.OnClose != nil { m.opts.OnClose() } } func (m *Modal) Toggle() { if m.open.Get() { m.Close() } else { m.Open() } } // Dispose closes the modal and removes every listener. Call it if the component // owning this Modal goes away while the dialog might still be open. func (m *Modal) Dispose() { m.teardown() modalStackRemove(m) if m.open.Get() { m.open.Set(false) } } func (m *Modal) teardown() { for _, un := range m.unsubs { un() } m.unsubs = nil } // mounted runs once the dialog is in the DOM: play the enter animation. // // The double rAF is REQUIRED and not superstition: the target styles have to land // in a LATER frame than the initial ones, or the browser never commits an initial // value to interpolate from and the element simply appears. One frame is not // enough (the render commit and the first rAF can share a frame). func (m *Modal) mounted() { // Re-assert the initial state before animating. Normally redundant (the declared // style attribute already says this), but a re-opened modal can land on a DOM node // the reconciler reused, which would still carry the imperative end state. wasmruntime.SetStyle(m.backdropRef, "opacity", "0") wasmruntime.SetStyle(m.panelRef, "opacity", "0") wasmruntime.SetStyle(m.panelRef, "transform", "scale(0.95)") wasmruntime.RAF(func() { wasmruntime.RAF(m.reveal) }) } func (m *Modal) reveal() { if !m.open.Get() { return } wasmruntime.SetStyle(m.backdropRef, "opacity", "1") wasmruntime.SetStyle(m.panelRef, "opacity", "1") wasmruntime.SetStyle(m.panelRef, "transform", "scale(1)") } // onKeydown closes on Escape — but only the topmost modal, so a modal opened from // inside another closes one layer per press instead of collapsing the whole stack. func (m *Modal) onKeydown(ev vdom.Event) { if ev.Key() != vdom.KEY_ESCAPE || !modalIsTopmost(m) { return } ev.PreventDefault() m.Close() } // ---- the modal stack ---- // // The Go equivalent of the TSX's openModalStack: every open modal, in open order. // Order is the whole point — the document keydown handler is installed once per open // modal, and each one closes only if it is on top. var modalStack []*Modal func modalStackPush(m *Modal) { modalStack = append(modalStack, m) } func modalStackRemove(m *Modal) { for i, o := range modalStack { if o == m { modalStack = append(modalStack[:i], modalStack[i+1:]...) return } } } func modalIsTopmost(m *Modal) bool { return len(modalStack) > 0 && modalStack[len(modalStack)-1] == m } // ---- rendering ---- // ModalProps configures one render of a Modal. A nil Header renders the close-only // header (the TSX's `undefined` default); a nil Footer renders the spacer. Size and // alignment live on ModalOptions, because they belong to the modal, not to a frame // of it. type ModalProps struct { Header *vdom.VNode Footer *vdom.VNode } // Render draws the dialog when open, portaled to document.body. // // When closed it renders an EMPTY portal rather than nothing, so its slot in the // parent's child list never disappears — the reconciler diffs children by index, and // a vanishing child would shift every sibling after it. func (m *Modal) Render(p ModalProps, children ...*vdom.VNode) *vdom.VNode { if !m.open.Get() { return vdom.Portal() } nodes := modalContentNodes(p.Header, p.Footer, m.Close, children) return vdom.Portal(m.display(m.opts.Size, m.opts.CenterOnScreen, nodes...)) } // display is the TSX's ModalDisplay: the `` container, the backdrop // (clicking it closes), and the panel. Both animated elements carry a CONSTANT // declared style — the initial state — which the enter animation then overwrites // imperatively. func (m *Modal) display(size ModalSize, centerOnScreen bool, children ...*vdom.VNode) *vdom.VNode { align := modalContainerTop if centerOnScreen { align = modalContainerCenter } if size == "" { size = ModalDefault } backdrop := []vdom.Mod{ vdom.WithRef(m.backdropRef), vdom.Attr("class", modalBackdrop), vdom.Attr("style", modalBackdropStyle), } if !m.opts.KeepOnBackdrop { backdrop = append(backdrop, vdom.On(vdom.EVENT_CLICK, m.Close)) } panel := kids([]vdom.Mod{ vdom.WithRef(m.panelRef), vdom.Attr("class", cx(modalBase, modalSizes[size])), vdom.Attr("style", modalPanelStyle), }, children) return vdom.Dialog(vdom.Attr("open", "open"), vdom.Attr("class", cx(modalContainerBase, align)), vdom.Div(backdrop...), vdom.Div(panel...), ) } // modalCloseButton is the shared "✕" button (dismisses via onClose). func modalCloseButton(onClose func(), size int) *vdom.VNode { mods := []vdom.Mod{vdom.Attr("type", "button"), vdom.Attr("class", modalCloseBtn)} if onClose != nil { mods = append(mods, vdom.On(vdom.EVENT_CLICK, onClose)) } mods = append(mods, Icon("xmark", size, "")) return vdom.Button(mods...) } func modalCloseOnlyHeader(onClose func()) *vdom.VNode { return vdom.Div(vdom.Attr("class", modalHeaderCloseOnly), modalCloseButton(onClose, 24)) } func modalFullHeader(header *vdom.VNode, onClose func()) *vdom.VNode { return vdom.Div(vdom.Attr("class", modalHeader), header, modalCloseButton(onClose, 24)) } // modalContentNodes builds the header / body / footer panel children shared by // Modal.Render and ModalContent. A nil header renders the close-only header; a nil // footer renders the spacer. func modalContentNodes(header, footer *vdom.VNode, onClose func(), children []*vdom.VNode) []*vdom.VNode { var nodes []*vdom.VNode if header == nil { nodes = append(nodes, modalCloseOnlyHeader(onClose)) } else { nodes = append(nodes, modalFullHeader(header, onClose)) } nodes = append(nodes, vdom.Div(kids([]vdom.Mod{vdom.Attr("class", modalBody)}, children)...)) if footer == nil { nodes = append(nodes, vdom.Div(vdom.Attr("class", modalFooterSpacer))) } else { nodes = append(nodes, vdom.Div(vdom.Attr("class", modalFooter), footer)) } return nodes } // ---- the imperative opener (the TSX's ModalProvider / useModal / openModal) ---- // // Go has no context, so "open a modal from anywhere" is a package-level store plus a // host component the app renders ONCE, near the root of its tree: // // func App() *vdom.VNode { // return Div(page(), webui.ModalHost()) // } // // and from anywhere at all: // // webui.OpenModal(func() *vdom.VNode { // return webui.ModalContent(webui.ModalContentProps{Header: Text("Details")}, body...) // }, webui.ModalOptions{Size: webui.ModalMedium}) // // The content is a BUILDER, not a VNode: it is invoked on every render, so content // that reads signals stays live — which is what the TSX got for free by storing an // already-reactive JSXElement. var ( globalModal = NewModal(ModalOptions{}) globalContent func() *vdom.VNode // globalVersion exists only to force a re-render when the content is swapped // while the modal is already open (the open signal would not change). globalVersion = vdom.NewSignal(0) ) // OpenModal opens the shared modal with the given content and options. Rendered by // ModalHost. Calling it while a modal is already open swaps the content in place, as // the TSX did (no second enter animation). func OpenModal(content func() *vdom.VNode, o ModalOptions) { user := o.OnClose o.OnClose = func() { globalContent = nil if user != nil { user() } } globalModal.opts = o globalContent = content globalVersion.Update(func(v int) int { return v + 1 }) globalModal.Open() } // CloseModal closes the shared modal (the TSX's useModal().closeModal). func CloseModal() { globalModal.Close() } // ModalIsOpen reports whether the shared modal is open. func ModalIsOpen() bool { return globalModal.IsOpen() } // ModalHost renders the shared modal. Render it once, near the root of the app. // Nothing (an empty portal) when no modal is open. func ModalHost() *vdom.VNode { _ = globalVersion.Get() // subscribe: a content swap must repaint the host if !globalModal.IsOpen() || globalContent == nil { return vdom.Portal() } return vdom.Portal(globalModal.display(globalModal.opts.Size, globalModal.opts.CenterOnScreen, globalContent())) } // ModalContentProps configures ModalContent. type ModalContentProps struct { Header *vdom.VNode Footer *vdom.VNode // OnClose overrides the default dismiss action (CloseModal, i.e. close the // shared modal this content was opened into). OnClose func() } // ModalContent renders the header / body / footer trio for content passed to // OpenModal — the panel chrome that ModalHost's bare panel does not impose. The TSX // returned a fragment; here it is wrapped in a display:contents div so it adds no // layout box. func ModalContent(p ModalContentProps, children ...*vdom.VNode) *vdom.VNode { onClose := p.OnClose if onClose == nil { onClose = CloseModal } nodes := modalContentNodes(p.Header, p.Footer, onClose, children) return vdom.Div(kids([]vdom.Mod{vdom.Attr("class", "contents")}, nodes)...) } // ---- confirm ---- // ConfirmModalProps configures Modal.Confirm. ConfirmStyle is "danger" (default) or // "primary". type ConfirmModalProps struct { OnConfirm func() Title string Message string ConfirmText string CancelText string ConfirmStyle string } // Confirm renders this modal as a small, centred confirm dialog (as the TSX's // ConfirmModal did — it hard-coded MODAL_SMALL + centerOnScreen). Confirming runs // OnConfirm and then closes. // // del := webui.NewModal(webui.ModalOptions{}) // … // del.Confirm(webui.ConfirmModalProps{Message: "Delete this row?", OnConfirm: doDelete}) func (m *Modal) Confirm(p ConfirmModalProps) *vdom.VNode { if !m.open.Get() { return vdom.Portal() } title := pick(p.Title, "Confirm") confirmText := pick(p.ConfirmText, "Confirm") cancelText := pick(p.CancelText, "Cancel") okVariant := modalConfirmOkVariants[pick(p.ConfirmStyle, "danger")] if okVariant == "" { okVariant = modalConfirmOkVariants["danger"] } cancel := vdom.Button(vdom.Attr("type", "button"), vdom.Attr("class", modalConfirmCancel), vdom.On(vdom.EVENT_CLICK, m.Close), vdom.Text(cancelText), ) ok := vdom.Button(vdom.Attr("type", "button"), vdom.Attr("class", cx(modalConfirmOkBase, okVariant)), vdom.On(vdom.EVENT_CLICK, func() { if p.OnConfirm != nil { p.OnConfirm() } m.Close() }), vdom.Text(confirmText), ) footer := vdom.Div(vdom.Attr("class", modalConfirmWrap), cancel, ok) size := m.opts.Size if size == "" { size = ModalSmall } nodes := modalContentNodes(vdom.Text(title), footer, m.Close, []*vdom.VNode{vdom.Text(p.Message)}) return vdom.Portal(m.display(size, true, nodes...)) } // ---- wizard ---- // WizardStepContext is what a step's content function is handed. Go has no context, // so the TSX's WizardStepContext is threaded EXPLICITLY through the step render // function instead — that is the whole shape of it: // // {Title: "Account", Content: func(ctx webui.WizardStepContext) *vdom.VNode { // ctx.SetCanContinue(email.Get() != "") // gates this step's Next button // return webui.TextInput(…) // }} // // SetCanContinue is PER STEP (it writes the flag for ctx.Index), which is what the // single caller-owned CanContinue bool could not express. It is safe to call during // render: a write that does not change the flag does not schedule a render, so a // step that asserts its own completeness every frame cannot loop. type WizardStepContext struct { // Index is this step's position in Steps. Index int // Version increments every time the wizard is opened. It is the Go stand-in for // the TSX's openVersion memo, which re-instantiated each step's content on every // open: Go step content is a plain function with no instance state to reset, so // key any per-open state you keep yourself off this value. Version int SetCanContinue func(bool) NextStep func() PrevStep func() } // WizardStep is one step of a Wizard. Content is called on every render with this // step's context. type WizardStep struct { Title string Content func(WizardStepContext) *vdom.VNode } // Wizard is a multi-step modal: a progress header, one step's body at a time, and a // Back / Next(Finish) footer. Like Modal it is a controller — create it once, outside // render. // // It owns the step index and the per-step "can continue" flags; opening resets both // (and bumps the version), which is the TSX's on-open reset effect. type Wizard struct { modal *Modal step *vdom.Signal[int] canContinue *vdom.Signal[[]bool] version int } // WizardProps configures one render of a Wizard. type WizardProps struct { Steps []WizardStep // Title overrides the header title; empty falls back to the current step's title. Title string FinishText string // Error, when non-empty, renders the error strip above the footer. Error string OnComplete func() } // NewWizard creates a wizard controller. Size defaults to ModalLarge, as in the TSX. func NewWizard(o ModalOptions) *Wizard { if o.Size == "" { o.Size = ModalLarge } return &Wizard{ modal: NewModal(o), step: vdom.NewSignal(0), canContinue: vdom.NewSignal[[]bool](nil), } } // Open resets the wizard to step 0 with every step incomplete, bumps the version, and // opens the dialog. func (w *Wizard) Open() { w.version++ w.step.Set(0) w.canContinue.Set(nil) w.modal.Open() } func (w *Wizard) Close() { w.modal.Close() } func (w *Wizard) IsOpen() bool { return w.modal.IsOpen() } func (w *Wizard) Dispose() { w.modal.Dispose() } func (w *Wizard) Step() int { return w.step.Get() } func (w *Wizard) Modal() *Modal { return w.modal } // canContinueAt is the flag for step i (absent == not complete). func (w *Wizard) canContinueAt(i int) bool { flags := w.canContinue.Get() return i >= 0 && i < len(flags) && flags[i] } // setCanContinue writes step i's flag. A no-op write is dropped, so content that // calls ctx.SetCanContinue during render converges instead of re-rendering forever. func (w *Wizard) setCanContinue(i int, v bool) { if i < 0 || v == w.canContinueAt(i) { return } flags := w.canContinue.Get() next := make([]bool, max(len(flags), i+1)) copy(next, flags) next[i] = v w.canContinue.Set(next) } func modalStepIndicatorClass(i, cur int) string { switch { case i == cur: return cx(modalStepIndicatorBase, modalStepIndicatorActive) case i < cur: return cx(modalStepIndicatorBase, modalStepIndicatorCompleted) default: return cx(modalStepIndicatorBase, modalStepIndicatorPending) } } func modalWizardNextBtnClass(isLast bool) string { if isLast { return cx(modalWizardBtnBase, modalWizardBtnFinish) } return cx(modalWizardBtnBase, modalWizardBtnNext) } // Render draws the wizard when open (portaled, animated and Escape-closable like any // other modal), and an empty portal when closed. func (w *Wizard) Render(p WizardProps) *vdom.VNode { if !w.modal.IsOpen() { return vdom.Portal() } steps := p.Steps total := len(steps) cur := clampIndex(w.step.Get(), total) isFirst := cur == 0 isLast := cur == total-1 next := func() { if cur < total-1 { w.step.Set(cur + 1) } } prev := func() { if cur > 0 { w.step.Set(cur - 1) } } title := p.Title currentStepTitle := "" if cur >= 0 && cur < total { currentStepTitle = steps[cur].Title if title == "" { title = steps[cur].Title } } pct := 100.0 if total > 1 { pct = float64(cur) / float64(total-1) * 100 } pctStr := strconv.FormatFloat(pct, 'f', -1, 64) // Progress track + step indicators. stepsRow := []vdom.Mod{ vdom.Attr("class", modalWizardSteps), vdom.Div(vdom.Attr("class", modalWizardTrack), vdom.Div(vdom.Attr("class", modalWizardTrackFill), vdom.Attr("style", "width:"+pctStr+"%")), ), } for i := range steps { label := strconv.Itoa(i + 1) if i < cur { label = "✓" } stepsRow = append(stepsRow, vdom.Div(vdom.Attr("class", modalWizardStepWrap), vdom.Div(vdom.Attr("class", modalStepIndicatorClass(i, cur)), vdom.Text(label)), )) } titleRow := vdom.Div(vdom.Attr("class", modalWizardTitleRow), vdom.Span(vdom.Attr("class", modalWizardTitle), vdom.Text(title)), modalCloseButton(w.modal.Close, 24), ) wizardHeader := vdom.Div(vdom.Attr("class", modalWizardHeader), titleRow, vdom.Div(vdom.Attr("class", modalWizardStepName), vdom.Text(currentStepTitle)), vdom.Div(stepsRow...), ) headerDiv := vdom.Div(vdom.Attr("class", modalHeader), wizardHeader) // Body: every step is rendered (with its own context), and the non-current ones // are hidden — so a step's DOM, and anything the browser owns in it (scroll // offsets, an open