Files
dees-catalog/readme.plan.md
Juergen Kunz 39a4bf0dd3 feat(dees-stepper): refactor to use dees-tile and add overlay functionality
- 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.
2026-04-11 09:28:11 +00:00

20 KiB
Raw Blame History

Plan: dees-stepper — adopt dees-tile + optional overlay window layer

First line (per CLAUDE.md): Please reread /home/philkunz/.claude/CLAUDE.md before 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:

  1. Each step should be wrapped in a <dees-tile> — the unified "rounded on rounded" frame used by modals and panels — rather than a bespoke .step div.
  2. A DeesWindowLayer should be added behind the stepper, the same way DeesModal.createAndShow does, 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-tile header 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 20299)

  • IStep interface — title, content: TemplateResult, validationFunc, onReturnToStepFunc, internal flags (lines 2027).
  • :host { position: absolute; width: 100%; height: 100%; } (lines 5963).
  • .stepperContainer — absolute, 100% w/h, overflow: hidden, holds SweetScroll (lines 6469).
  • .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 7197). These frame styles overlap with what dees-tile already provides.
  • .step.selectedfilter: opacity(1) saturate(1) (lines 8993). Scroll-through visual cue, keep.
  • .step.hiddenStepfilter: opacity(0) (line 95). Keep.
  • .step.entrance — faster transition variant for first-render (lines 99105). Keep.
  • .step .stepCounterposition: absolute; top: 12px; right: 12px; pill (lines 111121). Move into header slot as a flex child.
  • .step .goBackposition: absolute; top: 12px; left: 12px; pill + icon + hover (lines 123161). Move into header slot as a flex child.
  • .step .title — centered, 24px, 64px top padding (lines 163171). Keep inside the tile's content slot; remove the 64px top padding since goBack/counter no longer overlap it.
  • .step .content — 32px padding (lines 173175). Keep.
  • render() (lines 179204) — maps steps to .step divs.
  • setScrollStatus() (lines 226263) — SweetScroll container setup + step validation kick-off. Keep mostly as-is; selectors still target .step/.selected so rename cautiously.
  • firstUpdated (lines 210218), updated (lines 220222), goBack (lines 265282), goNext (lines 284298) — 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 — canonical createAndShow + 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. Uses part="outer", part="header", part="content", part="footer" for external shadow-part styling.
  • ts_web/elements/00group-overlay/dees-windowlayer/dees-windowlayer.tscreateAndShow({ blur }), destroy(), dispatches clicked event on backdrop click, uses zIndexRegistry.
  • 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>&larr;</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-shadow from 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:157161) 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 228229). 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

  1. ts_web/elements/00group-layout/dees-stepper/dees-stepper.ts — main refactor (IStep, imports, render, styles, createAndShow, destroy, overlay state).
  2. ts_web/elements/00group-layout/dees-stepper/dees-stepper.demo.ts — add overlay launcher button, extract shared demoSteps const, import DeesStepper.

Files explicitly NOT modified:

  • dees-tile.ts — used as-is via its slot API.
  • dees-windowlayer.ts — used as-is via createAndShow / destroy / click event.
  • dees-modal.ts — reference only.
  • 00zindex.ts — reference only.

Verification

  1. Build: pnpm run build — must pass with no TS errors. Pure refactor, no new dependencies, no lib-check regressions expected.

  2. 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-tile frame 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.
  3. Overlay demo (new path):

    • Click the "Open stepper as overlay" button.
    • Confirm a dees-windowlayer with 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.
  4. 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.
  5. Grep sanity:

    • Confirm dees-stepper has no new unexpected match locations: grep dees-stepper ts_web/ should still only match stepper's own files.
    • Confirm no .step class collisions elsewhere (unlikely — .step is a plain class name; all usages should be shadow-scoped to dees-stepper).

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: false behaves. Can add a close button in a follow-up.
  • No onComplete callback in createAndShow. The last step doesn't auto-destroy the overlay — the app controls it via the step's validationFunc. Can add a callback option in a follow-up.
  • No width/size options in createAndShow. The step tile continues to use the stepper's existing max-width: 500px. Can parameterize in a follow-up.
  • Box-shadow in the .step transition list is dropped from the transition for cleanliness — the box-shadow is now on dees-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 optional footerContent field. External consumers of the inline API continue to work if they only set steps + selectedStep.
  • Biggest risk: SweetScroll's offsetTop / offsetHeight measurements on <dees-tile> may compute differently than on the former <div class="step"> because dees-tile has an internal display: 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 .step wrapper 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 requires overlay to be a reflected @property, not @state. I've already accounted for this in the plan (decorator change).