- Updated IStep interface to include optional footerContent for step-specific actions. - Implemented createAndShow static method for displaying stepper as an overlay with DeesWindowLayer. - Refactored render method to wrap each step in a dees-tile, separating header, body, and footer content. - Enhanced CSS for dual-mode operation, adjusting styles for overlay and inline modes. - Added z-index management for overlay stepper to ensure proper stacking with window layer. - Updated demo to include a button for launching the stepper as an overlay.
20 KiB
Plan: dees-stepper — adopt dees-tile + optional overlay window layer
First line (per CLAUDE.md): Please reread
/home/philkunz/.claude/CLAUDE.mdbefore continuing.
Context
Today dees-stepper is an inline-only layout component: it hard-codes each step as a custom .step <div> with its own border / background / box-shadow / border-radius, and its :host is position: absolute; width: 100%; height: 100%; so it can only live inside a bounded parent container.
The user wants it to behave more like dees-modal:
- Each step should be wrapped in a
<dees-tile>— the unified "rounded on rounded" frame used by modals and panels — rather than a bespoke.stepdiv. - A
DeesWindowLayershould be added behind the stepper, the same wayDeesModal.createAndShowdoes, so the stepper can appear as an overlay on top of the page.
User has confirmed (via AskUserQuestion in this session):
- API: keep the current inline usage working AND add a static
createAndShow()like dees-modal. - Layout: keep the current vertical stack + SweetScroll behavior inside the overlay (don't switch to single-tile swap).
- Nav placement: split header/footer — goBack + step counter go into the
dees-tileheader slot; the title stays in the content area; the tile footer is used for optional next/submit buttons supplied per-step.
No external consumers of dees-stepper were found inside this package (grep dees-stepper|DeesStepper only matches its own source, demo, index, changelog, readme). External consumers in dependent projects may exist — the refactor is kept backward-compatible for the inline path.
Current state (reference)
File: ts_web/elements/00group-layout/dees-stepper/dees-stepper.ts (lines 20–299)
IStepinterface —title,content: TemplateResult,validationFunc,onReturnToStepFunc, internal flags (lines 20–27).:host { position: absolute; width: 100%; height: 100%; }(lines 59–63)..stepperContainer— absolute, 100% w/h,overflow: hidden, holds SweetScroll (lines 64–69)..step— max-width 500, min-height 300,border-radius: 12px, theme background, theme border,box-shadow: 0 8px 32px rgba(0,0,0,0.4),filter: opacity(0.55) saturate(0.85), transform transition (lines 71–97). These frame styles overlap with whatdees-tilealready provides..step.selected—filter: opacity(1) saturate(1)(lines 89–93). Scroll-through visual cue, keep..step.hiddenStep—filter: opacity(0)(line 95). Keep..step.entrance— faster transition variant for first-render (lines 99–105). Keep..step .stepCounter—position: absolute; top: 12px; right: 12px;pill (lines 111–121). Move into header slot as a flex child..step .goBack—position: absolute; top: 12px; left: 12px;pill + icon + hover (lines 123–161). Move into header slot as a flex child..step .title— centered, 24px, 64px top padding (lines 163–171). Keep inside the tile's content slot; remove the 64px top padding since goBack/counter no longer overlap it..step .content— 32px padding (lines 173–175). Keep.render()(lines 179–204) — mapsstepsto.stepdivs.setScrollStatus()(lines 226–263) — SweetScroll container setup + step validation kick-off. Keep mostly as-is; selectors still target.step/.selectedso rename cautiously.firstUpdated(lines 210–218),updated(lines 220–222),goBack(lines 265–282),goNext(lines 284–298) — untouched in behavior, only DOM selectors may need adjusting.
Reference files (read, do not modify):
ts_web/elements/00group-overlay/dees-modal/dees-modal.ts— canonicalcreateAndShow+destroy+ window-layer coordination + z-index registry usage.ts_web/elements/00group-layout/dees-tile/dees-tile.ts— slot API:slot="header", default slot,slot="footer". Auto-hides footer when slotted nodes are empty. Usespart="outer",part="header",part="content",part="footer"for external shadow-part styling.ts_web/elements/00group-overlay/dees-windowlayer/dees-windowlayer.ts—createAndShow({ blur }),destroy(), dispatchesclickedevent on backdrop click, useszIndexRegistry.ts_web/elements/00group-layout/dees-stepper/dees-stepper.demo.ts— existing inline demo.
Target state
1. IStep interface — add one optional field
export interface IStep {
title: string;
content: TemplateResult;
footerContent?: TemplateResult; // NEW: optional, rendered in dees-tile footer slot
validationFunc?: (stepper: DeesStepper, htmlElement: HTMLElement, signal?: AbortSignal) => Promise<any>;
onReturnToStepFunc?: (stepper: DeesStepper, htmlElement: HTMLElement) => Promise<any>;
validationFuncCalled?: boolean;
abortController?: AbortController;
}
Form-based steps don't need footerContent — their dees-form-submit stays inside the form in the content slot (as today). footerContent is for non-form steps that need an explicit primary action, or for any step that wants buttons in the conventional tile footer location.
2. New overlay-mode state + API on DeesStepper
@state() accessor overlay: boolean = false;
@state() accessor stepperZIndex: number = 1000;
private windowLayer?: DeesWindowLayer;
public static async createAndShow(optionsArg: {
steps: IStep[];
}): Promise<DeesStepper> {
const body = document.body;
const stepper = new DeesStepper();
stepper.steps = optionsArg.steps;
stepper.overlay = true;
stepper.windowLayer = await DeesWindowLayer.createAndShow({ blur: true });
stepper.windowLayer.addEventListener('click', async () => {
await stepper.destroy();
});
body.append(stepper.windowLayer); // (already appended inside createAndShow, but mirror dees-modal's pattern; see note)
body.append(stepper);
stepper.stepperZIndex = zIndexRegistry.getNextZIndex();
zIndexRegistry.register(stepper, stepper.stepperZIndex);
return stepper;
}
public async destroy() {
const domtools = await this.domtoolsPromise;
const container = this.shadowRoot!.querySelector('.stepperContainer');
container?.classList.add('predestroy');
await domtools.convenience.smartdelay.delayFor(200);
if (this.parentElement) this.parentElement.removeChild(this);
if (this.windowLayer) await this.windowLayer.destroy();
zIndexRegistry.unregister(this);
}
Note on body.append(windowLayer): DeesWindowLayer.createAndShow already appends the window layer to document.body (line 27 of dees-windowlayer.ts). dees-modal.ts:71 still calls body.append(modal.windowLayer) — that's either a no-op (already-attached nodes) or a re-parent to keep ordering. I will match dees-modal's exact sequence verbatim to avoid introducing subtle differences; if it's a bug in dees-modal it is out of scope for this task.
Minimum new scope for createAndShow: just steps for now. No onComplete, no showCloseButton, no width options. Future-proofing via additional options is an explicit follow-up — this plan keeps scope razor-sharp (per CLAUDE.md). The caller can already wire completion via the last step's validationFunc calling back into their own code.
3. Render template — wrap each step in <dees-tile>
public render() {
return html`
<div class="stepperContainer ${this.overlay ? 'overlay' : ''}" style="${this.overlay ? `z-index: ${this.stepperZIndex}` : ''}">
${this.steps.map((stepArg, i) => {
const isSelected = stepArg === this.selectedStep;
const isHidden = this.getIndexOfStep(stepArg) > this.getIndexOfStep(this.selectedStep);
const isFirst = i === 0;
const stepNumber = i + 1;
return html`
<dees-tile
class="step ${isSelected ? 'selected' : ''} ${isHidden ? 'hiddenStep' : ''} ${isFirst ? 'entrance' : ''}"
>
<div slot="header" class="step-header">
${!isFirst
? html`<div class="goBack" @click=${this.goBack}>
<span>←</span> go to previous step
</div>`
: html`<div class="goBack-spacer"></div>`}
<div class="stepCounter">Step ${stepNumber} of ${this.steps.length}</div>
</div>
<div class="step-body">
<div class="title">${stepArg.title}</div>
<div class="content">${stepArg.content}</div>
</div>
${stepArg.footerContent
? html`<div slot="footer" class="step-footer">${stepArg.footerContent}</div>`
: ''}
</dees-tile>
`;
})}
</div>
`;
}
Key detail: on the first step, render a .goBack-spacer (empty div) in the header instead of nothing — so the stepCounter stays right-aligned via justify-content: space-between. Without a spacer, flex would left-align the counter on step 1.
4. CSS changes
Remove from .step:
border-radius: 12px;background: ${cssManager.bdTheme(...)};border: 1px solid ${cssManager.bdTheme(...)};color: ${cssManager.bdTheme(...)};box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);overflow: hidden;
Why: dees-tile owns all of these now. The .step selector still exists (since dees-tile has class="step ..." on it), but it only controls the outer animation wrapper: max-width, min-height, margin, filter, transform, transition, user-select, pointer-events.
Keep on .step:
position: relative;pointer-events: none;+.step.selected { pointer-events: all; }max-width: 500px;/min-height: 300px;margin: auto; margin-bottom: 20px;filter: opacity(0.55) saturate(0.85);+.selected { filter: opacity(1) saturate(1); }.hiddenStep { filter: opacity(0); }- All the cubic-bezier transitions (transform/filter/box-shadow — but box-shadow is now a no-op since dees-tile provides the shadow; leave the transition spec in so we don't have to re-check browser parsing; or just drop
box-shadowfrom the transition list — I'll drop it for cleanliness). .step.entrance+.step.entrance.hiddenStep { transform: translateY(16px); }.step:last-child { margin-bottom: 100vh; }
Add for dees-tile shadow enhancement: use ::part(outer) to apply the modal-style elevated shadow only when in overlay mode (optional polish — inline mode stays flat):
.stepperContainer.overlay dees-tile.step::part(outer) {
box-shadow:
0 0 0 1px ${cssManager.bdTheme('hsl(0 0% 0% / 0.03)', 'hsl(0 0% 100% / 0.03)')},
0 8px 40px ${cssManager.bdTheme('hsl(0 0% 0% / 0.12)', 'hsl(0 0% 0% / 0.5)')},
0 2px 8px ${cssManager.bdTheme('hsl(0 0% 0% / 0.06)', 'hsl(0 0% 0% / 0.25)')};
}
This exactly mirrors the dees-modal::part(outer) shadow stack (dees-modal.ts:157–161) so the overlay stepper reads as "same visual language as modal."
Restyle .step-header (NEW — the <div slot="header">):
.step-header {
height: 48px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
gap: 12px;
}
Restyle .step .stepCounter → .step-header .stepCounter (move from absolute to flex child):
- Drop
position: absolute; top: 12px; right: 12px; - Keep everything else (padding, font-size, border-radius, background, border).
Restyle .step .goBack → .step-header .goBack (move from absolute to flex child):
- Drop
position: absolute; top: 12px; left: 12px; - Keep everything else (padding, font-size, border-radius, background, border, hover/active states).
Add .goBack-spacer:
.goBack-spacer { width: 1px; } /* placeholder so flex space-between works on step 1 */
Restyle .step .title:
- Drop
padding-top: 64px;— no longer overlaps anything since header is in its own slot. - Keep
text-align: center; font-family: 'Geist Sans', sans-serif; font-size: 24px; font-weight: 600; letter-spacing: -0.01em; color: inherit; - Add
padding-top: 32px;(or similar) so there's consistent breathing room above the title inside the tile content.
Add .step-footer (new container for stepArg.footerContent):
.step-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 12px 16px;
}
Add overlay-mode positioning:
.stepperContainer {
position: absolute;
width: 100%;
height: 100%;
overflow: hidden;
}
.stepperContainer.overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
}
.stepperContainer.predestroy {
opacity: 0;
transition: opacity 0.2s ease-in;
}
Adjust :host for dual-mode:
:host {
position: absolute; /* inline default */
width: 100%;
height: 100%;
font-family: ${cssGeistFontFamily};
color: var(--dees-color-text-primary);
}
:host([overlay]) {
position: fixed; /* overlay mode */
top: 0;
left: 0;
width: 100vw;
height: 100vh;
}
The overlay @state needs to reflect to an attribute for the :host([overlay]) selector to work. Since @state doesn't reflect attributes, use @property({ type: Boolean, reflect: true }) instead — change the decorator accordingly.
5. Imports to add in dees-stepper.ts
import { DeesWindowLayer } from '../../00group-overlay/dees-windowlayer/dees-windowlayer.js';
import { zIndexRegistry } from '../../00zindex.js';
import { cssGeistFontFamily } from '../../00fonts.js';
import '../../00group-layout/dees-tile/dees-tile.js';
dees-tile side-effect import registers the custom element. cssGeistFontFamily is only needed if I add it to :host (which I want, to match modal).
6. SweetScroll selector stability
setScrollStatus() selectors target .step and .selected (lines 228–229). These continue to match since I'm keeping those class names on the <dees-tile> elements. No selector changes needed.
One subtlety: offsetTop / offsetHeight on <dees-tile> should still work — the tile's :host is display: flex; flex-direction: column; which participates in layout. I'll verify visually in the demo.
7. Demo update
File: ts_web/elements/00group-layout/dees-stepper/dees-stepper.demo.ts
Current demo renders one inline stepper directly. I'll keep that and add an overlay launcher button above it:
export const stepperDemo = () => html`
<div style="padding: 16px;">
<dees-button @click=${async () => {
const stepper = await DeesStepper.createAndShow({
steps: [/* same steps as inline demo */],
});
}}>Open stepper as overlay</dees-button>
</div>
<dees-stepper .steps=${[/* ... existing inline demo steps ... */]}></dees-stepper>
`;
Extract the step definitions into a const demoSteps = [...] above the template so both the inline and overlay paths reuse them (DRY). Import DeesStepper at the top of the demo file.
Files to modify
ts_web/elements/00group-layout/dees-stepper/dees-stepper.ts— main refactor (IStep, imports, render, styles, createAndShow, destroy, overlay state).ts_web/elements/00group-layout/dees-stepper/dees-stepper.demo.ts— add overlay launcher button, extract shareddemoStepsconst, importDeesStepper.
Files explicitly NOT modified:
dees-tile.ts— used as-is via its slot API.dees-windowlayer.ts— used as-is viacreateAndShow/destroy/clickevent.dees-modal.ts— reference only.00zindex.ts— reference only.
Verification
-
Build:
pnpm run build— must pass with no TS errors. Pure refactor, no new dependencies, no lib-check regressions expected. -
Inline demo (backward compat):
- Start the demo server (port 8080 is already running) and navigate to the dees-stepper demo page.
- Confirm the stepper renders inline exactly like before: first step centered, subsequent steps dimmed below, scroll-through animation on goNext / goBack.
- Fill out the first form, submit → stepper scrolls to step 2. Click goBack → scrolls back.
- Confirm the
dees-tileframe is visible on each step (rounded, bordered, themed) and that the title + form are inside the tile's content area. - Confirm goBack button + step counter sit in the tile's header row, space-between, left/right respectively.
-
Overlay demo (new path):
- Click the "Open stepper as overlay" button.
- Confirm a
dees-windowlayerwith blur appears behind the stepper. - Confirm the stepper fills the viewport (fixed, 100vw×100vh).
- Confirm z-index stacking: stepper above window layer above page content.
- Click the window layer (outside the tile) → stepper animates out, then destroys along with the window layer.
- Re-open and step through forward & back — behavior identical to inline mode.
-
Playwright visual check (per CLAUDE.md: screenshots MUST go in
.playwright-mcp/):.playwright-mcp/dees-stepper-inline.png— inline mode, step 1 with form..playwright-mcp/dees-stepper-overlay.png— overlay mode, same step..playwright-mcp/dees-stepper-overlay-step3.png— overlay mode mid-flow, to verify scroll-stack visual.- Both light and dark themes if the demo has a theme toggle.
-
Grep sanity:
- Confirm
dees-stepperhas no new unexpected match locations:grep dees-stepper ts_web/should still only match stepper's own files. - Confirm no
.stepclass collisions elsewhere (unlikely —.stepis a plain class name; all usages should be shadow-scoped todees-stepper).
- Confirm
Open assumptions & deferred scope
These are explicit defaults in this plan. If the user wants different behavior for any of them, they should flag it on review — each is a simple follow-up but not in scope right now (CLAUDE.md: stay focused, no "while we're at it"):
- No close button on overlay stepper. Clicking the window layer backdrop is the only way to dismiss. Matches how dees-modal with
showCloseButton: falsebehaves. Can add a close button in a follow-up. - No
onCompletecallback increateAndShow. The last step doesn't auto-destroy the overlay — the app controls it via the step'svalidationFunc. Can add a callback option in a follow-up. - No width/size options in
createAndShow. The step tile continues to use the stepper's existingmax-width: 500px. Can parameterize in a follow-up. - Box-shadow in the
.steptransition list is dropped from the transition for cleanliness — the box-shadow is now ondees-tile::part(outer)and doesn't change between selected/hiddenStep, so transitioning it was already a no-op. pnpm start/ dev server path: I'll reuse the existing server on port 8080 that was already listening when this session began; if that server doesn't serve the stepper demo, I'll start wcctools manually.
Risk
- Low-medium. The change is localized to one component and its demo. No API removal, only an additive
createAndShow+ an optionalfooterContentfield. External consumers of the inline API continue to work if they only setsteps+selectedStep. - Biggest risk: SweetScroll's
offsetTop/offsetHeightmeasurements on<dees-tile>may compute differently than on the former<div class="step">becausedees-tilehas an internaldisplay: flex; flex-direction: column;host and a.tile-outer { flex: 1; min-height: 0; }inner frame. If the scroll math drifts, the mitigation is to keep the.stepwrapper as an outer<div>that contains a<dees-tile>, rather than putting the class directly on<dees-tile>. That preserves the exact box model SweetScroll was measuring. I'll try the direct-class approach first (simpler) and fall back to the wrapper approach if the scroll target looks off in the demo. - Second risk: The
:host([overlay])attribute selector requiresoverlayto be a reflected@property, not@state. I've already accounted for this in the plan (decorator change).