- git-sync: Commit + Push aller persistent konfigurierten Projekt-Repos
- deep-research: Deep research harness — fan-out web searches, fetch sources, adversarially verify claims, synthesize a cited report. - When the user wants a deep, multi-source, fact-checked research report on any topic. BEFORE invoking, check if the question is specific enough to research directly — if underspecified (e.g., "what car to buy" without budget/use-case/region), ask 2-3 clarifying questions to narrow scope. Then pass the refined question as args, weaving the answers in.
- dataviz: Use this skill whenever you are about to create ANY chart, graph, plot, dashboard, or data visualization, in ANY output medium — an HTML or React artifact, inline SVG, plotting code in any library (matplotlib, plotly, d3, Recharts, …), an image/PNG you will render and upload, or a chart shared into Slack. Read it BEFORE writing the first line of chart code, choosing chart colors, building a stat tile / meter / KPI row, or laying out a dashboard. Produces visualizations that read as one system — elegant, accessible, consistent in light and dark — using a brand-neutral placeholder palette you swap for your own. Teaches a design-system-agnostic method: a form heuristic, a color formula with a runnable validator, mark specs, and interaction rules. A validated default palette is documented in `references/palette.md` — swap that file's values for your brand's. Triggers on: "chart", "graph", "plot", "data viz", "visualization", "dashboard", "analytics", "visualize data", "categorical colors", "sequential / diverging palette", "stat tile", "sparkline", "heatmap", "legend", "axis", "tooltip", "chart colors", "color by series".
- artifact-design: Design guidance and fundamentals for Artifacts.
- update-config: Use this skill to configure the Claude Code harness via settings.json. Automated behaviors ("from now on when X", "each time X", "whenever X", "before/after X") require hooks configured in settings.json - the harness executes these, not Claude, so memory/preferences cannot fulfill them. Also use for: permissions ("allow X", "add permission", "move permission to"), env vars ("set X=Y"), hook troubleshooting, or any changes to settings.json/settings.local.json files. Examples: "allow npm commands", "add bq permission to global settings", "move permission to user settings", "set DEBUG=true", "when claude stops show X". For simple settings like theme/model, suggest the /config command.
- keybindings-help: Use when the user wants to customize keyboard shortcuts, rebind keys, add chord bindings, or modify ~/.claude/keybindings.json. Examples: "rebind ctrl+s", "add a chord shortcut", "change the submit key", "customize keybindings".
- verify: Verify that a code change actually does what it's supposed to by exercising it end-to-end and observing behavior — drive the affected flow, not just tests or typecheck. Run before committing nontrivial changes; bootstraps this repo's project verify skill if none exists yet. Don't invoke it on a diff that only touches tests, docs, or other code with no runtime surface to drive (a change to product source always has one) — there's nothing to observe.
- code-review: Review the current diff for correctness bugs and reuse/simplification/efficiency cleanups at the given effort level (low/medium: fewer, high-confidence findings; high→max: broader coverage, may include uncertain findings; ultra: deep multi-agent review in the cloud). Pass --comment to post findings as inline PR comments, or --fix to apply the findings to the working tree after the review.
- simplify: Review the changed code for reuse, simplification, efficiency, and altitude cleanups, then apply the fixes. Quality only — it does not hunt for bugs; use /code-review for that.
- fewer-permission-prompts: Scan your transcripts for common read-only Bash and MCP tool calls, then add a prioritized allowlist to project .claude/settings.json to reduce permission prompts.
- loop: Run a prompt or slash command on a recurring interval (e.g. /loop 5m /foo). Omit the interval to let the model self-pace. - When the user wants to set up a recurring task, poll for status, or run something repeatedly on an interval (e.g. "check the deploy every 5 minutes", "keep running /babysit-prs"). Do NOT invoke for one-off tasks.
- schedule: Create, update, list, or run scheduled cloud agents (routines) that execute on a cron schedule. - When the user wants to schedule a recurring cloud agent, set up automated tasks, create a cron job for Claude Code, or manage their scheduled agents/routines. Also use when the user wants a one-time scheduled run ("run this once at 3pm", "remind me to check X tomorrow").
- claude-api: Reference for the Claude API / Anthropic SDK — model ids, pricing, params, streaming, tool use, MCP, agents, caching, token counting, model migration.
TRIGGER — read BEFORE opening the target file; don't skip because it "looks like a one-liner" — whenever: the prompt names Claude/Anthropic in any form (Claude, Anthropic, Fable, Opus, Sonnet, Haiku, `anthropic`, `@anthropic-ai`, `claude-*`, `us.anthropic.*`, `[1m]`); the user asks about an LLM (pricing/model choice/limits/caching) — never answer from memory; OR the task is LLM-shaped with provider unstated (agent/MCP/tool-definition/multi-agent/RAG/LLM-judge/computer-use; generate/summarize/extract/classify/rewrite/converse over NL; debugging refusals/cutoffs/streaming/tool-calls/tokens).
SKIP only when another provider is being worked on (overrides all triggers): OpenAI/GPT/Gemini/Llama/Mistral/Cohere/Ollama named in the query; OR `grep -rE 'openai|langchain_openai|google.generativeai|genai|mistralai|cohere|ollama'` over the project hits (run this grep FIRST if no provider named — don't Read the file).
- run: Launch and drive this project's app to see a change working. Use when asked to run, start, or screenshot the app, or to confirm a change works in the real app (not just tests). First looks for a project skill that already covers launching the app; otherwise falls back to built-in patterns per project type (CLI, server, TUI, Electron, browser-driven, library).
- init: Initialize a new CLAUDE.md file with codebase documentation
- review: Review a GitHub pull request; for your working diff use /code-review
- security-review: Complete a security review of the pending changes on the current branch

git-sync deep-research dataviz artifact-design update-config keybindings-help verify code-review simplify fewer-permission-prompts loop schedule claude-api run init review security-review


Genau — die Marker stecken in der Szene, also erzwingt jede Selektion ein `setScene`, und `set_selection` allein reicht nicht. Unser TS-Anteil ist jetzt ~0; die verbleibende Zeit steckt in fires Neuaufbau der kompletten Szene (10k+ Handles) bei jedem `setScene`. Deshalb spürbar besser, aber nicht durchschlagend.

Der einzig durchschlagende Hebel: die Marker inkrementell aktualisieren, statt den ganzen Graphen neu zu schicken. Der Basis-Graph ändert sich bei Selektion nicht — nur die paar Marker-Handles kommen/gehen. Wenn fire nur diese hinzufügt/entfernt, bleibt der 10k-Graph unangetastet und der Rebuild entfällt.

Das hängt an fires API: ob es ein inkrementelles Add/Remove einzelner Handles (oder einen Szene-Diff/Patch) gibt, statt nur `setScene` (Vollersatz). Ohne das bleibt nur Vollersatz.

Bevor ich das plane, muss ich fires Command-Surface kennen. Soll ich das `@ad/fire`-API (in `geko-fire/browser`) daraufhin ansehen — welche Commands es neben `setScene`/`set_selection` für partielle Szenen-Updates gibt?


können wir die Anteile listen
ein protokoll, dass ich in verschiedenen Szaniren testn kann, und das ich dann übergfeben kanbn?


Für die Anteile brauche ich Instrumentierung an zwei Stellen: im Processor (unser TS: `handleEvent`, `replay`, Marker-Refine) und im Host, der die Reactions auf fire anwendet (`setScene` = fires Rebuild). Ich sehe mir kurz an, wie Fire3D die Reactions anwendet.


1	<template>
2

3

10	            <FireControls :class="['fire3d__controls', { visible: controlsVisible }]" />
11

12

13
14	        <FireContextMenu
15	            v-if="contextMenu?.entries?.length"
16	            :entries="contextMenu.entries"
17	            @select="onContextMenuSelect"
18	            @close="closeContextMenu"
19	        />
20
21

22	            <slot name="editor" />
23

24
25

26	            <slot name="bottom" />
27

28
29	        <ToastNotification ref="toast" />
30	        <AppDialog ref="dialog" />
31

32	</template>
33
34
232
233
285

<template>

            <FireControls :class="['fire3d__controls', { visible: controlsVisible }]" />

        <FireContextMenu
            v-if="contextMenu?.entries?.length"
            :entries="contextMenu.entries"
            @select="onContextMenuSelect"
            @close="closeContextMenu"
        />

            <slot name="editor" />

            <slot name="bottom" />

        <ToastNotification ref="toast" />
        <AppDialog ref="dialog" />

</template>


können wir die Anteile listen ein protokoll, dass ich in verschiedenen Szaniren testn kann, und das ich dann übergfeben kanbn?


Verstanden. Ich baue ein zuschaltbares Mess-Protokoll (`window.__cpqPerf = true`), das pro Event eine Zeile in die Konsole schreibt mit den Anteilen: Processor (`proc`, davon `replay`/`refine`) gegen fires Szene-Rebuild (`fire.setScene`) und die übrigen fire-Commands. Kein Verhalten ändert sich, wenn aus.

**1. Profiler**


Write

// Lightweight, opt-in event profiler for the configurator pipeline. Off by default; enable at runtime
// from the browser console with `window.__cpqPerf = true` (no reload needed). Each processed event
// prints one line with the time shares: `proc` (our TS, split into `proc.replay` / `proc.refine`)
// against `fire.*` (the engine applying the reactions — `fire.setScene` is the full-scene rebuild).
//
//   [cpq-perf] selection-click   total=831.0ms  proc=15.2ms (replay=0.0 refine=12.1)  fire.setScene=812.0ms  reactions=3 handles=10501
//
// proc + fire.* ≈ total; proc.replay / proc.refine are the breakdown *within* proc.
class Perf {
    private spans: Array<{ name: string; ms: number }> = [];
    private label = "";
    private t0 = 0;
    private active = false;

    get enabled(): boolean {
        return !!(globalThis as any).__cpqPerf;
    }

    // Start a frame. Everything recorded until end() is attributed to this event.
    begin(label: string): void {
        if (!this.enabled) return;
        this.label = label || "event";
        this.spans = [];
        this.t0 = performance.now();
        this.active = true;
    }

    // Record a named duration into the current frame (summed if the name repeats).
    add(name: string, ms: number): void {
        if (!this.active) return;
        this.spans.push({ name, ms });
    }

    // Time a synchronous span and record it.
    span<T>(name: string, fn: () => T): T {
        if (!this.active) return fn();
        const t = performance.now();
        try {
            return fn();
        } finally {
            this.add(name, performance.now() - t);
        }
    }

    // Time an async span and record it.
    async spanAsync<T>(name: string, fn: () => Promise<T>): Promise<T> {
        if (!this.active) return fn();
        const t = performance.now();
        try {
            return await fn();
        } finally {
            this.add(name, performance.now() - t);
        }
    }

    // Close the frame and print the line. `extra` appends key=value fields (e.g. reactions, handles).
    end(extra?: Record<string, number | string>): void {
        if (!this.active) return;
        this.active = false;
        const total = performance.now() - this.t0;

        const summed = new Map<string, number>();
        for (const s of this.spans) summed.set(s.name, (summed.get(s.name) ?? 0) + s.ms);

        const proc = summed.get("proc") ?? 0;
        const replay = summed.get("proc.replay") ?? 0;
        const refine = summed.get("proc.refine") ?? 0;
        const fire = [...summed].filter(([n]) => n.startsWith("fire."));

        const fireStr = fire.map(([n, ms]) => `${n}=${ms.toFixed(1)}ms`).join("  ");
        const extraStr = extra ? "  " + Object.entries(extra).map(([k, v]) => `${k}=${v}`).join(" ") : "";
        // eslint-disable-next-line no-console
        console.log(
            `[cpq-perf] ${this.label.padEnd(18)} total=${total.toFixed(1)}ms  ` +
                `proc=${proc.toFixed(1)}ms (replay=${replay.toFixed(1)} refine=${refine.toFixed(1)})  ` +
                `${fireStr}${extraStr}`,
        );
    }
}

export const perf = new Perf();


File created successfully at: /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/perf.ts (file state is current in your context — no need to Read it back)

// Lightweight, opt-in event profiler for the configurator pipeline. Off by default; enable at runtime
// from the browser console with `window.__cpqPerf = true` (no reload needed). Each processed event
// prints one line with the time shares: `proc` (our TS, split into `proc.replay` / `proc.refine`)
// against `fire.*` (the engine applying the reactions — `fire.setScene` is the full-scene rebuild).
//
//   [cpq-perf] selection-click   total=831.0ms  proc=15.2ms (replay=0.0 refine=12.1)  fire.setScene=812.0ms  reactions=3 handles=10501
//
// proc + fire.* ≈ total; proc.replay / proc.refine are the breakdown *within* proc.
class Perf {
    private spans: Array<{ name: string; ms: number }> = [];
    private label = "";
    private t0 = 0;
    private active = false;

    get enabled(): boolean {
        return !!(globalThis as any).__cpqPerf;
    }

    // Start a frame. Everything recorded until end() is attributed to this event.
    begin(label: string): void {
        if (!this.enabled) return;
        this.label = label || "event";
        this.spans = [];
        this.t0 = performance.now();
        this.active = true;
    }

    // Record a named duration into the current frame (summed if the name repeats).
    add(name: string, ms: number): void {
        if (!this.active) return;
        this.spans.push({ name, ms });
    }

    // Time a synchronous span and record it.
    span<T>(name: string, fn: () => T): T {
        if (!this.active) return fn();
        const t = performance.now();
        try {
            return fn();
        } finally {
            this.add(name, performance.now() - t);
        }
    }

    // Time an async span and record it.
    async spanAsync<T>(name: string, fn: () => Promise<T>): Promise<T> {
        if (!this.active) return fn();
        const t = performance.now();
        try {
            return await fn();
        } finally {
            this.add(name, performance.now() - t);
        }
    }

    // Close the frame and print the line. `extra` appends key=value fields (e.g. reactions, handles).
    end(extra?: Record<string, number | string>): void {
        if (!this.active) return;
        this.active = false;
        const total = performance.now() - this.t0;

        const summed = new Map<string, number>();
        for (const s of this.spans) summed.set(s.name, (summed.get(s.name) ?? 0) + s.ms);

        const proc = summed.get("proc") ?? 0;
        const replay = summed.get("proc.replay") ?? 0;
        const refine = summed.get("proc.refine") ?? 0;
        const fire = [...summed].filter(([n]) => n.startsWith("fire."));

        const fireStr = fire.map(([n, ms]) => `${n}=${ms.toFixed(1)}ms`).join("  ");
        const extraStr = extra ? "  " + Object.entries(extra).map(([k, v]) => `${k}=${v}`).join(" ") : "";
        // eslint-disable-next-line no-console
        console.log(
            `[cpq-perf] ${this.label.padEnd(18)} total=${total.toFixed(1)}ms  ` +
                `proc=${proc.toFixed(1)}ms (replay=${replay.toFixed(1)} refine=${refine.toFixed(1)})  ` +
                `${fireStr}${extraStr}`,
        );
    }
}

export const perf = new Perf();


**2. Processor instrumentieren** (`replay`-Recompute, `refine`)


Edit

import type { CommandContext, PlacementModule, Pick, PrepareResult } from "./PlacementModule";

import type { CommandContext, PlacementModule, Pick, PrepareResult } from "./PlacementModule";
import { perf } from "../perf";


The file /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/events/DefaultCommandProcessor.ts has been updated successfully. (file state is current in your context — no need to Read it back)

import type { CommandContext, PlacementModule, Pick, PrepareResult } from "./PlacementModule";
import { perf } from "../perf";

import { extractMoveData, extractRotateData } from "./eventData";

import { IDENTITY_QUAT } from "../model/transformMath";

export class DefaultCommandProcessor extends CommandProcessor {


    private replay(): any {
        if (!this.replayDirty && this.cachedScene) return this.cachedScene;
        const events = this.eventStore.getEventsToReplay();
        this.cachedScene = this.groups.replay(
            this.eventStore.getInitialState(),
            this.pendingRegroup ? [...events, this.pendingRegroup] : events,
        );
        this.replayDirty = false;
        return this.cachedScene;
    }

    private replay(): any {
        if (!this.replayDirty && this.cachedScene) return this.cachedScene;
        this.cachedScene = perf.span("proc.replay", () => {
            const events = this.eventStore.getEventsToReplay();
            return this.groups.replay(
                this.eventStore.getInitialState(),
                this.pendingRegroup ? [...events, this.pendingRegroup] : events,
            );
        });
        this.replayDirty = false;
        return this.cachedScene;
    }


    private replay(): any {
        if (!this.replayDirty && this.cachedScene) return this.cachedScene;
        const events = this.eventStore.getEventsToReplay();
        this.cachedScene = this.groups.replay(
            this.eventStore.getInitialState(),
            this.pendingRegroup ? [...events, this.pendingRegroup] : events,
        );
        this.replayDirty = false;
        return this.cachedScene;
    }

    private replay(): any {
        if (!this.replayDirty && this.cachedScene) return this.cachedScene;
        this.cachedScene = perf.span("proc.replay", () => {
            const events = this.eventStore.getEventsToReplay();
            return this.groups.replay(
                this.eventStore.getInitialState(),
                this.pendingRegroup ? [...events, this.pendingRegroup] : events,
            );
        });
        this.replayDirty = false;
        return this.cachedScene;
    }

-        const events = this.eventStore.getEventsToReplay();

-        this.cachedScene = this.groups.replay(

-            this.eventStore.getInitialState(),

-            this.pendingRegroup ? [...events, this.pendingRegroup] : events,

+        this.cachedScene = perf.span("proc.replay", () => {

+            const events = this.eventStore.getEventsToReplay();

+                this.eventStore.getInitialState(),

+                this.pendingRegroup ? [...events, this.pendingRegroup] : events,


    private refine(type: string, event: any, reactions: UIReaction[]): UIReaction[] {
        let out = reactions;
        for (const f of this.refineHooks.get(type) ?? []) out = f(event, this.ctx, out);
        return out;
    }

    private refine(type: string, event: any, reactions: UIReaction[]): UIReaction[] {
        let out = reactions;
        for (const f of this.refineHooks.get(type) ?? []) {
            const current = out;
            out = perf.span("proc.refine", () => f(event, this.ctx, current));
        }
        return out;
    }


    private refine(type: string, event: any, reactions: UIReaction[]): UIReaction[] {
        let out = reactions;
        for (const f of this.refineHooks.get(type) ?? []) out = f(event, this.ctx, out);
        return out;
    }

    private refine(type: string, event: any, reactions: UIReaction[]): UIReaction[] {
        let out = reactions;
        for (const f of this.refineHooks.get(type) ?? []) {
            const current = out;
            out = perf.span("proc.refine", () => f(event, this.ctx, current));
        }
        return out;
    }

-        for (const f of this.refineHooks.get(type) ?? []) out = f(event, this.ctx, out);

+        for (const f of this.refineHooks.get(type) ?? []) {

+            out = perf.span("proc.refine", () => f(event, this.ctx, current));


**3. Host instrumentieren** (`fire.setScene` und Frame-Grenzen)


import { CommandProcessor } from "../../configurator/events/CommandProcessor";
import { DefaultCommandProcessor } from "../../configurator/events/DefaultCommandProcessor";

import { CommandProcessor } from "../../configurator/events/CommandProcessor";
import { DefaultCommandProcessor } from "../../configurator/events/DefaultCommandProcessor";
import { perf } from "../../configurator/perf";


The file /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/components/visual/Fire3D.vue has been updated successfully. (file state is current in your context — no need to Read it back)

import { CommandProcessor } from "../../configurator/events/CommandProcessor";
import { DefaultCommandProcessor } from "../../configurator/events/DefaultCommandProcessor";
import { perf } from "../../configurator/perf";

<template>

            <FireControls :class="['fire3d__controls', { visible: controlsVisible }]" />

        <FireContextMenu
            v-if="contextMenu?.entries?.length"
            :entries="contextMenu.entries"
            @select="onContextMenuSelect"
            @close="closeContextMenu"
        />

            <slot name="editor" />

            <slot name="bottom" />

        <ToastNotification ref="toast" />
        <AppDialog ref="dialog" />

</template>

import type { UIReaction, MenuEntry } from "../../configurator/types";

+import { perf } from "../../configurator/perf";


            case "sendCommand":
                if (reaction.cmd === "setScene") {
                    currentSceneGraph.value = reaction.args[0];
                    await visual.value?.setScene(reaction.args[0]);
                } else if (reaction.cmd === "selection-click") {
                    // Handle selection click
                    const nodeId = reaction.args[0] as string;
                    emit("selectionClick", nodeId);
                } else {
                    await visual.value?.execute(reaction.cmd, ...reaction.args);
                }
                break;

            case "sendCommand":
                if (reaction.cmd === "setScene") {
                    currentSceneGraph.value = reaction.args[0];
                    await perf.spanAsync("fire.setScene", () => visual.value?.setScene(reaction.args[0]));
                } else if (reaction.cmd === "selection-click") {
                    // Handle selection click
                    const nodeId = reaction.args[0] as string;
                    emit("selectionClick", nodeId);
                } else {
                    await perf.spanAsync(`fire.${reaction.cmd}`, () => visual.value?.execute(reaction.cmd, ...reaction.args));
                }
                break;


            case "sendCommand":
                if (reaction.cmd === "setScene") {
                    currentSceneGraph.value = reaction.args[0];
                    await visual.value?.setScene(reaction.args[0]);
                } else if (reaction.cmd === "selection-click") {
                    // Handle selection click
                    const nodeId = reaction.args[0] as string;
                    emit("selectionClick", nodeId);
                } else {
                    await visual.value?.execute(reaction.cmd, ...reaction.args);
                }
                break;

            case "sendCommand":
                if (reaction.cmd === "setScene") {
                    currentSceneGraph.value = reaction.args[0];
                    await perf.spanAsync("fire.setScene", () => visual.value?.setScene(reaction.args[0]));
                } else if (reaction.cmd === "selection-click") {
                    // Handle selection click
                    const nodeId = reaction.args[0] as string;
                    emit("selectionClick", nodeId);
                } else {
                    await perf.spanAsync(`fire.${reaction.cmd}`, () => visual.value?.execute(reaction.cmd, ...reaction.args));
                }
                break;

-                    await visual.value?.setScene(reaction.args[0]);

+                    await perf.spanAsync("fire.setScene", () => visual.value?.setScene(reaction.args[0]));

-                    await visual.value?.execute(reaction.cmd, ...reaction.args);

+                    await perf.spanAsync(`fire.${reaction.cmd}`, () => visual.value?.execute(reaction.cmd, ...reaction.args));


Jetzt die Frame-Grenzen um die Event-Eintrittspunkte über einen Helper.


// Reactions are awaited sequentially: a sendCommand only starts after the previous one

// Run one event end-to-end under the profiler: `proc` is the processor, the rest is fire applying
// the reactions. No-op overhead when profiling is off (window.__cpqPerf falsy).
async function runEvent(label: string, produce: () => UIReaction[]): Promise<void> {
    perf.begin(label);
    const reactions = perf.span("proc", produce);
    await processReactions(reactions);
    perf.end({ reactions: reactions.length, handles: currentSceneGraph.value?.handles?.length ?? 0 });
}

// Reactions are awaited sequentially: a sendCommand only starts after the previous one


// Run one event end-to-end under the profiler: `proc` is the processor, the rest is fire applying
// the reactions. No-op overhead when profiling is off (window.__cpqPerf falsy).
async function runEvent(label: string, produce: () => UIReaction[]): Promise<void> {
    perf.begin(label);
    const reactions = perf.span("proc", produce);
    await processReactions(reactions);
    perf.end({ reactions: reactions.length, handles: currentSceneGraph.value?.handles?.length ?? 0 });
}

// Reactions are awaited sequentially: a sendCommand only starts after the previous one

+// Run one event end-to-end under the profiler: `proc` is the processor, the rest is fire applying

+// the reactions. No-op overhead when profiling is off (window.__cpqPerf falsy).

+async function runEvent(label: string, produce: () => UIReaction[]): Promise<void> {

+    const reactions = perf.span("proc", produce);

+    perf.end({ reactions: reactions.length, handles: currentSceneGraph.value?.handles?.length ?? 0 });

// resolved. This keeps a "stop_interaction_mode" before a following "setScene", and moves

// scene rebuilds off the synchronous engine event/processInput stack so an active gizmo is


    if (e.ctrlKey && e.key === "z") {
        e.preventDefault();
        const reactions = processor.handleEvent({ eventTypeName: "undo" });
        processReactions(reactions);
    }
    if (e.ctrlKey && e.key === "y") {
        e.preventDefault();
        const reactions = processor.handleEvent({ eventTypeName: "redo" });
        processReactions(reactions);
    }
    if (e.key === "Escape") {
        e.preventDefault();
        const reactions = processor.handleEvent({ eventTypeName: "esc" });
        processReactions(reactions);
    }
    if (e.shiftKey && ARROW_DIR[e.key]) {
        e.preventDefault();
        e.stopPropagation(); // keep fire's camera controls from also consuming the arrow
        processReactions(processor.handleEvent({ eventTypeName: "keyboard-build", direction: ARROW_DIR[e.key] }));
    }

    if (e.ctrlKey && e.key === "z") {
        e.preventDefault();
        runEvent("undo", () => processor.handleEvent({ eventTypeName: "undo" }));
    }
    if (e.ctrlKey && e.key === "y") {
        e.preventDefault();
        runEvent("redo", () => processor.handleEvent({ eventTypeName: "redo" }));
    }
    if (e.key === "Escape") {
        e.preventDefault();
        runEvent("esc", () => processor.handleEvent({ eventTypeName: "esc" }));
    }
    if (e.shiftKey && ARROW_DIR[e.key]) {
        e.preventDefault();
        e.stopPropagation(); // keep fire's camera controls from also consuming the arrow
        runEvent("keyboard-build", () => processor.handleEvent({ eventTypeName: "keyboard-build", direction: ARROW_DIR[e.key] }));
    }


    if (e.ctrlKey && e.key === "z") {
        e.preventDefault();
        const reactions = processor.handleEvent({ eventTypeName: "undo" });
        processReactions(reactions);
    }
    if (e.ctrlKey && e.key === "y") {
        e.preventDefault();
        const reactions = processor.handleEvent({ eventTypeName: "redo" });
        processReactions(reactions);
    }
    if (e.key === "Escape") {
        e.preventDefault();
        const reactions = processor.handleEvent({ eventTypeName: "esc" });
        processReactions(reactions);
    }
    if (e.shiftKey && ARROW_DIR[e.key]) {
        e.preventDefault();
        e.stopPropagation(); // keep fire's camera controls from also consuming the arrow
        processReactions(processor.handleEvent({ eventTypeName: "keyboard-build", direction: ARROW_DIR[e.key] }));
    }

    if (e.ctrlKey && e.key === "z") {
        e.preventDefault();
        runEvent("undo", () => processor.handleEvent({ eventTypeName: "undo" }));
    }
    if (e.ctrlKey && e.key === "y") {
        e.preventDefault();
        runEvent("redo", () => processor.handleEvent({ eventTypeName: "redo" }));
    }
    if (e.key === "Escape") {
        e.preventDefault();
        runEvent("esc", () => processor.handleEvent({ eventTypeName: "esc" }));
    }
    if (e.shiftKey && ARROW_DIR[e.key]) {
        e.preventDefault();
        e.stopPropagation(); // keep fire's camera controls from also consuming the arrow
        runEvent("keyboard-build", () => processor.handleEvent({ eventTypeName: "keyboard-build", direction: ARROW_DIR[e.key] }));
    }

-        const reactions = processor.handleEvent({ eventTypeName: "undo" });

+        runEvent("undo", () => processor.handleEvent({ eventTypeName: "undo" }));

-        const reactions = processor.handleEvent({ eventTypeName: "redo" });

+        runEvent("redo", () => processor.handleEvent({ eventTypeName: "redo" }));

-        const reactions = processor.handleEvent({ eventTypeName: "esc" });

+        runEvent("esc", () => processor.handleEvent({ eventTypeName: "esc" }));

-        processReactions(processor.handleEvent({ eventTypeName: "keyboard-build", direction: ARROW_DIR[e.key] }));

+        runEvent("keyboard-build", () => processor.handleEvent({ eventTypeName: "keyboard-build", direction: ARROW_DIR[e.key] }));


function onContextMenuSelect(entry: MenuEntry) {
    processReactions(entry.reactions);
    closeContextMenu();
}

function onContextMenuSelect(entry: MenuEntry) {
    runEvent("menu", () => entry.reactions);
    closeContextMenu();
}


function onContextMenuSelect(entry: MenuEntry) {
    processReactions(entry.reactions);
    closeContextMenu();
}

function onContextMenuSelect(entry: MenuEntry) {
    runEvent("menu", () => entry.reactions);
    closeContextMenu();
}

+    runEvent("menu", () => entry.reactions);


    visual.value.subscribe((event: any) => {
        const reactions = processor.handleEvent(event);
        processReactions(reactions);
    });

    visual.value.subscribe((event: any) => {
        runEvent(event?.eventTypeName ?? "event", () => processor.handleEvent(event));
    });


    visual.value.subscribe((event: any) => {
        const reactions = processor.handleEvent(event);
        processReactions(reactions);
    });

    visual.value.subscribe((event: any) => {
        runEvent(event?.eventTypeName ?? "event", () => processor.handleEvent(event));
    });

visual.value = await createVisual3D(root.value!, props.config);

-        const reactions = processor.handleEvent(event);

+        runEvent(event?.eventTypeName ?? "event", () => processor.handleEvent(event));


    dispatch: async (event: any) => {
        await visualPromise;
        processReactions(processor.handleEvent(event));
    },

    dispatch: async (event: any) => {
        await visualPromise;
        await runEvent(event?.eventTypeName ?? "event", () => processor.handleEvent(event));
    },


    dispatch: async (event: any) => {
        await visualPromise;
        processReactions(processor.handleEvent(event));
    },

    dispatch: async (event: any) => {
        await visualPromise;
        await runEvent(event?.eventTypeName ?? "event", () => processor.handleEvent(event));
    },

-        processReactions(processor.handleEvent(event));

+        await runEvent(event?.eventTypeName ?? "event", () => processor.handleEvent(event));

subscribe: async (handler: (event: unknown) => void) => {


Bash

pnpm --filter @ad-cpq/common-frontend test 2>&1 | grep -E 'Test Files|Tests '; pnpm --filter @ad-cpq/common-frontend typecheck 2>&1 | grep -vE 'ProductBrowser\.vue' | grep -E 'error TS' | head; echo "---done---"

Run tests and typecheck


---done---


pnpm --filter @ad-cpq/common-frontend test 2>&1 | tail -6

Confirm test result


No projects found in "/Users/marcus.hinz/claude-projects/robotunits"


Arbeitsverzeichnis wurde auf robotunits zurückgesetzt. Ich gebe den Pfad explizit an.


pnpm -C /Users/marcus.hinz/projects/adesso-cpq --filter @ad-cpq/common-frontend test 2>&1 | tail -6; echo "=== tsc ==="; pnpm -C /Users/marcus.hinz/projects/adesso-cpq --filter @ad-cpq/common-frontend typecheck 2>&1 | grep -vE 'ProductBrowser\.vue' | grep -E 'error TS' | head; echo "---done---"

Run tests and typecheck with explicit dir


 Test Files  8 passed (8)
      Tests  112 passed (112)
   Start at  10:26:14
   Duration  916ms (transform 989ms, setup 0ms, import 1.46s, tests 324ms, environment 3.48s)

=== tsc ===
src/components/visual/Fire3D.vue(144,65): error TS2322: Type 'Promise<void> | undefined' is not assignable to type 'Promise<void>'.
src/components/visual/Fire3D.vue(150,72): error TS2322: Type 'unknown' is not assignable to type 'Promise<unknown>'.
---done---


Zwei Typfehler aus `spanAsync` — die fire-Aufrufe liefern `Promise<void> | undefined` (Optional-Chaining), meine Signatur verlangt `Promise<T>`. Ich lockere die Signatur.


Edit

    // Time an async span and record it.
    async spanAsync<T>(name: string, fn: () => Promise<T>): Promise<T> {
        if (!this.active) return fn();
        const t = performance.now();
        try {
            return await fn();
        } finally {
            this.add(name, performance.now() - t);
        }
    }

    // Time an async span and record it. Accepts anything awaitable (incl. `Promise<void> | undefined`).
    async spanAsync<T>(name: string, fn: () => T): Promise<Awaited<T>> {
        if (!this.active) return await fn();
        const t = performance.now();
        try {
            return await fn();
        } finally {
            this.add(name, performance.now() - t);
        }
    }


The file /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/perf.ts has been updated successfully. (file state is current in your context — no need to Read it back)

    // Time an async span and record it. Accepts anything awaitable (incl. `Promise<void> | undefined`).
    async spanAsync<T>(name: string, fn: () => T): Promise<Awaited<T>> {
        if (!this.active) return await fn();
        const t = performance.now();
        try {
            return await fn();
        } finally {
            this.add(name, performance.now() - t);
        }
    }

// Lightweight, opt-in event profiler for the configurator pipeline. Off by default; enable at runtime
// from the browser console with `window.__cpqPerf = true` (no reload needed). Each processed event
// prints one line with the time shares: `proc` (our TS, split into `proc.replay` / `proc.refine`)
// against `fire.*` (the engine applying the reactions — `fire.setScene` is the full-scene rebuild).
//
//   [cpq-perf] selection-click   total=831.0ms  proc=15.2ms (replay=0.0 refine=12.1)  fire.setScene=812.0ms  reactions=3 handles=10501
//
// proc + fire.* ≈ total; proc.replay / proc.refine are the breakdown *within* proc.
class Perf {
    private spans: Array<{ name: string; ms: number }> = [];
    private label = "";
    private t0 = 0;
    private active = false;

    get enabled(): boolean {
        return !!(globalThis as any).__cpqPerf;
    }

    // Start a frame. Everything recorded until end() is attributed to this event.
    begin(label: string): void {
        if (!this.enabled) return;
        this.label = label || "event";
        this.spans = [];
        this.t0 = performance.now();
        this.active = true;
    }

    // Record a named duration into the current frame (summed if the name repeats).
    add(name: string, ms: number): void {
        if (!this.active) return;
        this.spans.push({ name, ms });
    }

    // Time a synchronous span and record it.
    span<T>(name: string, fn: () => T): T {
        if (!this.active) return fn();
        const t = performance.now();
        try {
            return fn();
        } finally {
            this.add(name, performance.now() - t);
        }
    }

    // Time an async span and record it.
    async spanAsync<T>(name: string, fn: () => Promise<T>): Promise<T> {
        if (!this.active) return fn();
        const t = performance.now();
        try {
            return await fn();
        } finally {
            this.add(name, performance.now() - t);
        }
    }

    // Close the frame and print the line. `extra` appends key=value fields (e.g. reactions, handles).
    end(extra?: Record<string, number | string>): void {
        if (!this.active) return;
        this.active = false;
        const total = performance.now() - this.t0;

        const summed = new Map<string, number>();
        for (const s of this.spans) summed.set(s.name, (summed.get(s.name) ?? 0) + s.ms);

        const proc = summed.get("proc") ?? 0;
        const replay = summed.get("proc.replay") ?? 0;
        const refine = summed.get("proc.refine") ?? 0;
        const fire = [...summed].filter(([n]) => n.startsWith("fire."));

        const fireStr = fire.map(([n, ms]) => `${n}=${ms.toFixed(1)}ms`).join("  ");
        const extraStr = extra ? "  " + Object.entries(extra).map(([k, v]) => `${k}=${v}`).join(" ") : "";
        // eslint-disable-next-line no-console
        console.log(
            `[cpq-perf] ${this.label.padEnd(18)} total=${total.toFixed(1)}ms  ` +
                `proc=${proc.toFixed(1)}ms (replay=${replay.toFixed(1)} refine=${refine.toFixed(1)})  ` +
                `${fireStr}${extraStr}`,
        );
    }
}

export const perf = new Perf();

-    // Time an async span and record it.

-    async spanAsync<T>(name: string, fn: () => Promise<T>): Promise<T> {

+    // Time an async span and record it. Accepts anything awaitable (incl. `Promise<void> | undefined`).

+    async spanAsync<T>(name: string, fn: () => T): Promise<Awaited<T>> {

+        if (!this.active) return await fn();


Bash

pnpm -C /Users/marcus.hinz/projects/adesso-cpq --filter @ad-cpq/common-frontend typecheck 2>&1 | grep -vE 'ProductBrowser\.vue' | grep -E 'error TS' | head; echo "---done---"

Re-run typecheck


Fertig. Das Protokoll steht, zuschaltbar, ohne Verhaltensänderung wenn aus. Tests 112 grün, Typecheck sauber (nur vorbestehende ProductBrowser-Fehler).

**Benutzung**

In der Browser-Konsole (Storybook) einschalten:
```js
window.__cpqPerf = true    // aus: = false
```

Danach schreibt jedes verarbeitete Event eine Zeile:
```
[cpq-perf] selection-click    total=831.0ms  proc=15.2ms (replay=0.0 refine=12.1)  fire.setScene=812.0ms  reactions=3 handles=10501
```

**Die Anteile**
- `total` — Gesamtzeit vom Event bis alle Reactions auf fire angewandt sind.
- `proc` — unser TS (`handleEvent`), davon:
  - `replay` — Modell-Neuaufbau (0, wenn Cache greift).
  - `refine` — Marker-Aufbau (`withSelectionMarkers`) + Kontext-Einträge.
- `fire.setScene` — fires Neuaufbau der kompletten Szene (der verbleibende Flaschenhals).
- `fire.<cmd>` — jede andere fire-Command (`set_selection`, `start_placement_mode`, …) einzeln.
- `reactions` / `handles` — Anzahl Reactions und Handles im Graphen.

**Erfasst** alle Eintrittspunkte: Selektion/Kontext/Placement/Move (fire → subscribe), Undo/Redo/Esc/Keyboard-Build (Tastatur), Kontextmenü-Aktionen (`menu`), Produkt-Hinzufügen (`add-product`).

Szenarien durchspielen (kleiner vs. großer Graph, Selektion vs. Bau vs. Move), Konsole kopieren, übergeben. An den Zeilen sieht man direkt, ob `fire.setScene` dominiert — dann bestätigt das den nächsten Hebel (Marker inkrementell statt Voll-`setScene`).

Ein Detail: bei sehr schnell aufeinanderfolgenden Klicks können sich Frames überlappen (der `setScene`-Await läuft noch), dann verschmelzen zwei Zeilen — für manuelles Testen einzeln klicken.

Commit auf dein Wort.


wie schalte ich das an?


In der Browser-Konsole der laufenden Storybook-/App-Seite:

1. DevTools öffnen (F12 / Cmd+Opt+I), Tab **Console**.
2. Eintippen und Enter:
```js
window.__cpqPerf = true
```
3. Im Viewport klicken/bauen/verschieben — die `[cpq-perf]`-Zeilen erscheinen direkt in derselben Konsole.

Ausschalten: `window.__cpqPerf = false`.

Kein Reload nötig — das Flag wird bei jedem Event live gelesen. Wichtig: dieselbe Seite/derselbe Frame, in dem fire läuft (nicht die Storybook-Manager-Konsole; falls die Story in einem iframe steckt, in der Konsole den Kontext auf den `storybook-preview-iframe` umstellen, dann `window.__cpqPerf = true`).


dakommt nix


Wahrscheinlich der iframe: Die Story läuft im `storybook-preview-iframe`, mein Code liest das Flag auf dessen `window`. Wenn du es in der obersten Konsole setzt, sieht das Modul im iframe nichts.

Setz es direkt aufs iframe-Window (aus der obersten Konsole):
```js
document.querySelector('#storybook-preview-iframe').contentWindow.__cpqPerf = true
```

Oder im Kontext-Dropdown der Konsole (oben links) auf `storybook-preview-iframe` umschalten, dann:
```js
window.__cpqPerf = true
```

Prüfen, ob es angekommen ist (im iframe-Kontext):
```js
window.__cpqPerf   // muss true sein
```

Kommt dann immer noch nichts, läuft die Storybook-Instanz vermutlich noch auf dem Stand vor meiner Änderung — `perf.ts` ist neu, das braucht ggf. einen Neustart:
```
pnpm --filter @ad-cpq/common-frontend storybook
```

Sag mir, was `window.__cpqPerf` im iframe-Kontext zurückgibt — dann sehe ich, ob es am Flag oder am Build liegt.


[cpq-perf] selection          total=0.1ms  proc=0.0ms (replay=0.0 refine=0.0)    reactions=0 handles=387
perf.ts:73 [cpq-perf] selection          total=0.0ms  proc=0.0ms (replay=0.0 refine=0.0)    reactions=0 handles=424
perf.ts:73 [cpq-perf] selection          total=0.0ms  proc=0.0ms (replay=0.0 refine=0.0)    reactions=0 handles=463


Das läuft — aber du siehst gerade nur das Rauschen. Die `selection`-Zeilen (reactions=0) sind fires kontinuierlicher Hover-/Auswahl-Stream, der immer `[]` zurückgibt. Die Zeile, die zählt, ist **`selection-click`** (der echte Klick), außerdem `add-product`, `interaction-mode` (Move/Placement), `context-click`.

Dass `handles` von 387→463 wächst, zeigt: die `selection-click`-Events *passieren* (Marker vergrößern den Graphen) — ihre Zeilen stecken nur zwischen dem Hover-Rauschen.

Zwei Dinge:
1. Ich blende das Hover-Rauschen (`selection`) aus, damit das Log lesbar ist.
2. Für den langsamen Fall brauchst du einen **großen** Graphen (deine ~1-s-Szene), dann ein Teil anklicken und die `selection-click`-Zeile lesen.


Edit

async function runEvent(label: string, produce: () => UIReaction[]): Promise<void> {
    perf.begin(label);
    const reactions = perf.span("proc", produce);
    await processReactions(reactions);
    perf.end({ reactions: reactions.length, handles: currentSceneGraph.value?.handles?.length ?? 0 });
}

async function runEvent(label: string, produce: () => UIReaction[]): Promise<void> {
    // "selection" is fire's continuous hover/selection stream (always yields no reactions) — pure noise
    // in the log, so it is not profiled. The actual click is "selection-click".
    const profile = label !== "selection";
    if (profile) perf.begin(label);
    const reactions = perf.span("proc", produce);
    await processReactions(reactions);
    if (profile) perf.end({ reactions: reactions.length, handles: currentSceneGraph.value?.handles?.length ?? 0 });
}


async function runEvent(label: string, produce: () => UIReaction[]): Promise<void> {
    perf.begin(label);
    const reactions = perf.span("proc", produce);
    await processReactions(reactions);
    perf.end({ reactions: reactions.length, handles: currentSceneGraph.value?.handles?.length ?? 0 });
}

async function runEvent(label: string, produce: () => UIReaction[]): Promise<void> {
    // "selection" is fire's continuous hover/selection stream (always yields no reactions) — pure noise
    // in the log, so it is not profiled. The actual click is "selection-click".
    const profile = label !== "selection";
    if (profile) perf.begin(label);
    const reactions = perf.span("proc", produce);
    await processReactions(reactions);
    if (profile) perf.end({ reactions: reactions.length, handles: currentSceneGraph.value?.handles?.length ?? 0 });
}

// Run one event end-to-end under the profiler: `proc` is the processor, the rest is fire applying

// the reactions. No-op overhead when profiling is off (window.__cpqPerf falsy).

+    // "selection" is fire's continuous hover/selection stream (always yields no reactions) — pure noise

+    // in the log, so it is not profiled. The actual click is "selection-click".

+    const profile = label !== "selection";

-    perf.end({ reactions: reactions.length, handles: currentSceneGraph.value?.handles?.length ?? 0 });

+    if (profile) perf.end({ reactions: reactions.length, handles: currentSceneGraph.value?.handles?.length ?? 0 });

// Reactions are awaited sequentially: a sendCommand only starts after the previous one


[cpq-perf] selection          total=0.1ms  proc=0.0ms (replay=0.0 refine=0.0)    reactions=0 handles=387 perf.ts:73 [cpq-perf] selection          total=0.0ms  proc=0.0ms (replay=0.0 refine=0.0)    rea…


Bash

pnpm -C /Users/marcus.hinz/projects/adesso-cpq --filter @ad-cpq/common-frontend typecheck 2>&1 | grep -vE 'ProductBrowser\.vue' | grep -E 'error TS' | head; echo "---done---"

Typecheck after noise guard


Hover-Rauschen ist raus. Nach dem HMR-Reload zeigt das Log nur noch echte Events: `selection-click`, `add-product`, `interaction-mode`, `context-click`, `menu`, `keyboard-build`, `undo`/`redo`.

Vorgehen für den langsamen Fall: große Szene laden/bauen, ein Teil anklicken, die `selection-click`-Zeile ansehen. Wenn dort `fire.setScene` den `total` dominiert (und `proc` klein ist), ist der Befund bestätigt — der Rest liegt in fires Voll-Rebuild, und der nächste Hebel ist die inkrementelle Marker-Aktualisierung.

Schick mir eine `selection-click`-Zeile vom großen Graphen, dann ordne ich die Anteile ein.


[cpq-perf] selection-click    total=1364.4ms  proc=0.2ms (replay=0.0 refine=0.2)  fire.stop_interaction_mode=0.1ms  fire.setScene=1347.8ms  fire.set_selection=16.2ms  reactions=3 handles=692
perf.ts:73 [cpq-perf] selection-click    total=1551.4ms  proc=4.5ms (replay=3.9 refine=0.1)  fire.stop_interaction_mode=0.0ms  fire.setScene=1526.9ms  fire.set_selection=19.9ms  reactions=3 handles=730
perf.ts:73 [cpq-perf] selection-click    total=1709.2ms  proc=4.9ms (replay=4.2 refine=0.2)  fire.stop_interaction_mode=0.0ms  fire.setScene=1683.8ms  fire.set_selection=20.5ms  reactions=3 handles=768


Eindeutig. Der Befund ist bestätigt — und drastischer als gedacht:

| | Wert |
|---|---|
| `total` | 1364–1709 ms |
| `proc` (unser TS) | **0,2–4,9 ms** |
| `fire.setScene` | **1348–1684 ms** (≈ 99 %) |
| `fire.set_selection` | 16–20 ms |
| `handles` | 692–768 |

Unser TS ist erledigt — das Caching zieht (`replay` 0–4 ms). Die gesamte Zeit steckt in `fire.setScene`: **1,3–1,7 s, um ~700 Handles neu aufzubauen**, nur um ~3 Marker-Kugeln hinzuzufügen. fire reißt bei jedem `setScene` den kompletten Szenengraphen ab und baut ihn neu.

Das heißt zweierlei:
1. **Selektion darf kein `setScene` mehr auslösen.** Der Basis-Graph ist unverändert; nur die Marker kommen/gehen. Wenn fire die paar Marker-Handles einzeln hinzufügt/entfernt, baut es 3 statt 700 → der 1,3-s-Klick verschwindet.
2. Derselbe Hebel gilt später für Move/Bau: jedes `setScene` mit dem Voll-Graphen zahlt fires Rebuild. Das Reaction-Modell „Szene neu rechnen → `setScene(voll)`" ist grundsätzlich der falsche Weg für inkrementelle Änderungen.

Der nächste Schritt hängt an fires API: ob es Commands für inkrementelles Hinzufügen/Entfernen/Aktualisieren einzelner Entities gibt (statt nur `setScene` = Vollersatz). Das läuft über `visual.execute(cmd, …)` in `@ad-cpq/visual-runtime` bzw. `@ad/fire`.

Dafür muss ich fires Command-Surface ansehen. Soll ich `@ad-cpq/visual-runtime` und `@ad/fire` daraufhin durchsehen, welche inkrementellen Entity-Operationen es gibt?


[cpq-perf] selection-click    total=1364.4ms  proc=0.2ms (replay=0.0 refine=0.2)  fire.stop_interaction_mode=0.1ms  fire.setScene=1347.8ms  fire.set_selection=16.2ms  reactions=3 handles=692
perf.ts:73 [cpq-perf] selection-click    total=1551.4ms  proc=4.5ms (replay=3.9 refine=0.1)  fire.stop_interaction_mode=0.0ms  fire.setScene=1526.9ms  fire.set_selection=19.9ms  reactions=3 handles=730
perf.ts:73 [cpq-perf] selection-click    total=1709.2ms  proc=4.9ms (replay=4.2 refine=0.2)  fire.stop_interaction_mode=0.0ms  fire.setScene=1683.8ms  fire.set_selection=20.5ms  reactions=3 handles=768
perf.ts:73 [cpq-perf] selection-click    total=1688.1ms  proc=0.3ms (replay=0.0 refine=0.3)  fire.stop_interaction_mode=0.1ms  fire.setScene=1665.3ms  fire.set_selection=22.3ms  reactions=3 handles=768
perf.ts:73 [cpq-perf] selection-click    total=1899.7ms  proc=4.9ms (replay=4.3 refine=0.1)  fire.stop_interaction_mode=0.2ms  fire.setScene=1873.6ms  fire.set_selection=21.0ms  reactions=3 handles=806
perf.ts:73 [cpq-perf] selection-click    total=1863.0ms  proc=0.4ms (replay=0.0 refine=0.3)  fire.stop_interaction_mode=0.0ms  fire.setScene=1841.5ms  fire.set_selection=21.0ms  reactions=3 handles=804
perf.ts:73 [cpq-perf] selection-click    total=2033.8ms  proc=5.3ms (replay=4.6 refine=0.2)  fire.stop_interaction_mode=0.0ms  fire.setScene=2007.0ms  fire.set_selection=21.5ms  reactions=3 handles=843
perf.ts:73 [cpq-perf] selection-click    total=2222.2ms  proc=5.5ms (replay=4.8 refine=0.2)  fire.stop_interaction_mode=0.1ms  fire.setScene=2193.3ms  fire.set_selection=23.3ms  reactions=3 handles=882
perf.ts:73 [cpq-perf] selection-click    total=2403.5ms  proc=5.6ms (replay=4.8 refine=0.3)  fire.stop_interaction_mode=0.0ms  fire.setScene=2374.0ms  fire.set_selection=23.9ms  reactions=3 handles=920
perf.ts:73 [cpq-perf] selection-click    total=2429.8ms  proc=0.3ms (replay=0.0 refine=0.3)  fire.stop_interaction_mode=0.1ms  fire.setScene=2404.5ms  fire.set_selection=24.8ms  reactions=3 handles=919
perf.ts:73 [cpq-perf] context-click      total=22.6ms  proc=0.6ms (replay=0.0 refine=0.5)  fire.set_selection=21.5ms  reactions=2 handles=919
perf.ts:73 [cpq-perf] menu               total=2526.9ms  proc=0.1ms (replay=0.0 refine=0.0)  fire.setScene=2347.2ms  fire.set_selection=179.6ms  reactions=1 handles=915
perf.ts:73 [cpq-perf] selection-click    total=2587.6ms  proc=0.5ms (replay=0.0 refine=0.4)  fire.stop_interaction_mode=0.1ms  fire.setScene=2566.8ms  fire.set_selection=20.2ms  reactions=3 handles=919
perf.ts:73 [cpq-perf] context-click      total=16.7ms  proc=0.9ms (replay=0.0 refine=0.9)  fire.set_selection=15.4ms  reactions=2 handles=919
perf.ts:73 [cpq-perf] menu               total=2330.7ms  proc=0.0ms (replay=0.0 refine=0.0)  fire.setScene=2143.4ms  fire.set_selection=187.2ms  reactions=1 handles=915
perf.ts:73 [cpq-perf] context-click      total=21.2ms  proc=0.4ms (replay=0.0 refine=0.3)  fire.set_selection=20.4ms  reactions=2 handles=915
perf.ts:73 [cpq-perf] context-click      total=21.0ms  proc=0.4ms (replay=0.0 refine=0.4)  fire.set_selection=20.2ms  reactions=2 handles=915
perf.ts:73 [cpq-perf] menu               total=2365.7ms  proc=0.1ms (replay=0.0 refine=0.0)  fire.setScene=2190.5ms  fire.set_selection=175.1ms  reactions=1 handles=915
perf.ts:73 [cpq-perf] context-click      total=15.8ms  proc=0.4ms (replay=0.0 refine=0.3)  fire.set_selection=15.1ms  reactions=2 handles=915
perf.ts:73 [cpq-perf] interaction-mode   total=0.0ms  proc=0.0ms (replay=0.0 refine=0.0)    reactions=0 handles=915
perf.ts:73 [cpq-perf] interaction-mode   total=2532.5ms  proc=5.7ms (replay=5.5 refine=0.0)  fire.setScene=2526.7ms  reactions=1 handles=915
perf.ts:73 [cpq-perf] interaction-mode   total=0.0ms  proc=0.0ms (replay=0.0 refine=0.0)    reactions=0 handles=915
perf.ts:73 [cpq-perf] context-click      total=22.0ms  proc=0.6ms (replay=0.0 refine=0.5)  fire.set_selection=21.0ms  reactions=2 handles=915
perf.ts:73 [cpq-perf] interaction-mode   total=0.1ms  proc=0.0ms (replay=0.0 refine=0.0)    reactions=0 handles=915
perf.ts:73 [cpq-perf] interaction-mode   total=6.2ms  proc=0.0ms (replay=0.0 refine=0.0)    reactions=0 handles=915
perf.ts:73 [cpq-perf] context-click      total=16.8ms  proc=0.5ms (replay=0.0 refine=0.4)  fire.set_selection=15.3ms  reactions=2 handles=915
perf.ts:73 [cpq-perf] interaction-mode   total=0.0ms  proc=0.0ms (replay=0.0 refine=0.0)    reactions=0 handles=915
perf.ts:73 [cpq-perf] interaction-mode   total=6.1ms  proc=0.0ms (replay=0.0 refine=0.0)    reactions=0 handles=915


Diese Serie schließt die Diagnose ab und liefert zwei neue Belege:

**`context-click` = 16–22 ms** — und macht *kein* `setScene`, nur `set_selection`. Das ist der direkte Gegenbeweis: derselbe „Teil anfassen"-Vorgang ohne `setScene` ist **~100× schneller**. Bestätigt Hebel 1 empirisch: Selektion ohne `setScene` → ~20 ms statt 1,3–2,4 s.

**`menu` (Platzieren) = 2,3–2,5 s** — das ist mein eigener Marker-Räum-`setScene` von neulich. Der kostet jetzt selbst 2,3 s. Sobald Marker inkrementell sind, entfällt auch dieses Räum-`setScene` (Marker-Entities einfach entfernen).

**`interaction-mode` (Move/Placement) = 2,5 s `setScene`** — jede Modelländerung zahlt denselben Voll-Rebuild.

Restliche Zahlen: `set_selection` ~20 ms (ok), im Placement-Pfad einmal 175 ms. `proc` durchweg 0–6 ms — unser Teil ist raus.

Fazit: **Jedes `setScene` ist ein fire-Voll-Rebuild, linear mit der Handle-Zahl.** Es gibt keinen TS-seitigen Hebel mehr — der Fix ist, `setScene` bei inkrementellen Änderungen zu vermeiden und stattdessen fires Entity-Operationen zu nutzen. Zuerst für Marker (Selektion), dann für Move/Bau.

Das kann ich nur planen, wenn ich fires Command-Surface kenne. Ich brauche dein Go, um `@ad-cpq/visual-runtime` und `@ad/fire` daraufhin durchzusehen, welche inkrementellen Entity-Commands (add/remove/update Entity, Transform-Update, Overlay-Layer) es neben `setScene`/`set_selection` gibt.


[cpq-perf] selection-click    total=1364.4ms  proc=0.2ms (replay=0.0 refine=0.2)  fire.stop_interaction_mode=0.1ms  fire.setScene=1347.8ms  fire.set_selection=16.2ms  reactions=3 handles=692 perf.ts:7…


ok -- andere KIste
ich hab in StoryBokk plötzlich schrollabrs - kann das dazu füheren, dass der scrollrad zoom nicht mehr geht?


Ja, plausibel. Scrollbars heißt, ein Container overflowt und ist scrollbar geworden — ein Mausrad-Event über dieser Scroll-Fläche wird von ihr als Seiten-Scroll verschluckt, statt fires Canvas zu erreichen (bzw. fire kann `wheel` nicht mehr abfangen/`preventDefault`en). Dann zoomt das Rad nicht mehr, sondern scrollt.


kannst du sehen, wo das herkommt?


Ja, das darf ich jetzt ansehen. Ich prüfe die Layout-Quellen (Story-Wrapper, Fire3D/Configurator-CSS, Storybook-Preview) und was zuletzt daran geändert wurde.


cd /Users/marcus.hinz/projects/adesso-cpq && echo "=== recent Fire3D.vue style/template changes (last commit) ===" && git log --oneline -3 -- packages/frontend/src/components/visual/Fire3D.vue && echo "=== grep overflow/height/vh in visual + storybook ===" && grep -rnE 'overflow|100vh|height:\s*100|min-height' packages/frontend/src/components/visual/*.vue packages/frontend/.storybook/ 2>/dev/null | grep -vE 'node_modules' | head -40

Inspect layout/overflow sources


<persisted-output>
Output too large (180.5KB). Full output saved to: /Users/marcus.hinz/.config/ai-control/pools/private/projects/-Users-marcus-hinz-claude-projects-robotunits/a37553af-b32b-4577-a395-bb0adfdab8ca/tool-results/b5fordpq6.txt

Preview (first 2KB):
=== recent Fire3D.vue style/template changes (last commit) ===
5564353 Storybook: Rollenfoerderer-Produkte und Docking-Regeln, Fire3D/Service-Anpassungen
b05bebf Graph-Export-Button + Keyboard-Prototyp fürs Anbauen
ea77fdc Neue Einbaupunkte: GroupProcessor/DefaultCommandProcessor + ProductConfigurator/ProductBrowser/Fire3D
=== grep overflow/height/vh in visual + storybook ===
packages/frontend/src/components/visual/FireContextMenu.vue:68:    overflow: hidden;
packages/frontend/src/components/visual/ProductConfigurator.vue:58:    height: 100%;
packages/frontend/src/components/visual/ProductConfigurator.vue:59:    min-height: 0;
packages/frontend/src/components/visual/Fire3D.vue:246:    height: 100%;
packages/frontend/src/components/visual/Fire3D.vue:247:    min-height: 0;
packages/frontend/src/components/visual/Fire3D.vue:253:    height: 100%;
packages/frontend/src/components/visual/Fire3D.vue:254:    min-height: 0;
packages/frontend/src/components/visual/FireControls.vue:168:    overflow: hidden;
packages/frontend/src/components/visual/ProductEditorDemo.vue:93:    height: 100vh;
...
</persisted-output>

=== recent Fire3D.vue style/template changes (last commit) ===
5564353 Storybook: Rollenfoerderer-Produkte und Docking-Regeln, Fire3D/Service-Anpassungen
b05bebf Graph-Export-Button + Keyboard-Prototyp fürs Anbauen
ea77fdc Neue Einbaupunkte: GroupProcessor/DefaultCommandProcessor + ProductConfigurator/ProductBrowser/Fire3D
=== grep overflow/height/vh in visual + storybook ===
packages/frontend/src/components/visual/FireContextMenu.vue:68:    overflow: hidden;
packages/frontend/src/components/visual/ProductConfigurator.vue:58:    height: 100%;
packages/frontend/src/components/visual/ProductConfigurator.vue:59:    min-height: 0;
packages/frontend/src/components/visual/Fire3D.vue:246:    height: 100%;
packages/frontend/src/components/visual/Fire3D.vue:247:    min-height: 0;
packages/frontend/src/components/visual/Fire3D.vue:253:    height: 100%;
packages/frontend/src/components/visual/Fire3D.vue:254:    min-height: 0;
packages/frontend/src/components/visual/FireControls.vue:168:    overflow: hidden;
packages/frontend/src/components/visual/ProductEditorDemo.vue:93:    height: 100vh;
] = c; continue; }\n\n    let c_len = _utf8len[c];\n    // skip 5 & 6 byte codes\n    if (c_len > 4) { utf16buf[out++] = 0xfffd; i += c_len - 1; continue; }\n\n    // apply mask on first byte\n    c &= c_len === 2 ? 0x1f : c_len === 3 ? 0x0f : 0x07;\n    // join the rest\n    while (c_len > 1 && i < len) {\n      c = (c << 6) | (buf[i++] & 0x3f);\n      c_len--;\n    }\n\n    // terminated by end of string?\n    if (c_len > 1) { utf16buf[out++] = 0xfffd; continue; }\n\n    if (c < 0x10000) {\n      utf16buf[out++] = c;\n    } else {\n      c -= 0x10000;\n      utf16buf[out++] = 0xd800 | ((c >> 10) & 0x3ff);\n      utf16buf[out++] = 0xdc00 | (c & 0x3ff);\n    }\n  }\n\n  return buf2binstring(utf16buf, out);\n};\n\n\n// Calculate max possible position in utf8 buffer,\n// that will not break sequence. If that's not possible\n// - (very small limits) return max size as is.\n//\n// buf[] - utf8 bytes array\n// max   - length limit (mandatory);\nvar utf8border = (buf, max) => {\n\n  max = max || buf.length;\n  if (max > buf.length) { max = buf.length; }\n\n  // go back from last position, until start of sequence found\n  let pos = max - 1;\n  while (pos >= 0 && (buf[pos] & 0xC0) === 0x80) { pos--; }\n\n  // Very small and broken sequence,\n  // return max, because we should return something anyway.\n  if (pos < 0) { return max; }\n\n  // If we came to start of buffer - that means buffer is too small,\n  // return max too.\n  if (pos === 0) { return max; }\n\n  return (pos + _utf8len[buf[pos]] > max) ? pos : max;\n};\n\nvar strings = {\n\tstring2buf: string2buf,\n\tbuf2string: buf2string,\n\tutf8border: utf8border\n};\n\n// (C) 1995-2013 Jean-loup Gailly and Mark Adler\n// (C) 2014-2017 Vitaly Puzrin and Andrey Tupitsin\n//\n// This software is provided 'as-is', without any express or implied\n// warranty. In no event will the authors be held liable for any damages\n// arising from the use of this software.\n//\n// Permission is granted to anyone to use this software for any purpose,\n// including commercial applications, and to alter it and redistribute it\n// freely, subject to the following restrictions:\n//\n// 1. The origin of this software must not be misrepresented; you must not\n//   claim that you wrote the original software. If you use this software\n//   in a product, an acknowledgment in the product documentation would be\n//   appreciated but is not required.\n// 2. Altered source versions must be plainly marked as such, and must not be\n//   misrepresented as being the original software.\n// 3. This notice may not be removed or altered from any source distribution.\n\nfunction ZStream() {\n  /* next input byte */\n  this.input = null; // JS specific, because we have no pointers\n  this.next_in = 0;\n  /* number of bytes available at input */\n  this.avail_in = 0;\n  /* total number of input bytes read so far */\n  this.total_in = 0;\n  /* next output byte should be put there */\n  this.output = null; // JS specific, because we have no pointers\n  this.next_out = 0;\n  /* remaining free space at output */\n  this.avail_out = 0;\n  /* total number of bytes output so far */\n  this.total_out = 0;\n  /* last error message, NULL if no error */\n  this.msg = ''/*Z_NULL*/;\n  /* not visible by applications */\n  this.state = null;\n  /* best guess about the data type: binary or text */\n  this.data_type = 2/*Z_UNKNOWN*/;\n  /* adler32 value of the uncompressed data */\n  this.adler = 0;\n}\n\nvar zstream = ZStream;\n\nconst toString$1 = Object.prototype.toString;\n\n/* Public constants ==========================================================*/\n/* ===========================================================================*/\n\nconst {\n  Z_NO_FLUSH: Z_NO_FLUSH$1, Z_SYNC_FLUSH, Z_FULL_FLUSH, Z_FINISH: Z_FINISH$2,\n  Z_OK: Z_OK$2, Z_STREAM_END: Z_STREAM_END$2,\n  Z_DEFAULT_COMPRESSION,\n  Z_DEFAULT_STRATEGY,\n  Z_DEFLATED: Z_DEFLATED$1\n} = constants$2;\n\n/* ===========================================================================*/\n\n\n/**\n * class Deflate\n *\n * Generic JS-style wrapper for zlib calls. If you don't need\n * streaming behaviour - use more simple functions: [[deflate]],\n * [[deflateRaw]] and [[gzip]].\n **/\n\n/* internal\n * Deflate.chunks -> Array\n *\n * Chunks of output data, if [[Deflate#onData]] not overridden.\n **/\n\n/**\n * Deflate.result -> Uint8Array\n *\n * Compressed result, generated by default [[Deflate#onData]]\n * and [[Deflate#onEnd]] handlers. Filled after you push last chunk\n * (call [[Deflate#push]] with `Z_FINISH` / `true` param).\n **/\n\n/**\n * Deflate.err -> Number\n *\n * Error code after deflate finished. 0 (Z_OK) on success.\n * You will not need it in real life, because deflate errors\n * are possible only on wrong options or bad `onData` / `onEnd`\n * custom handlers.\n **/\n\n/**\n * Deflate.msg -> String\n *\n * Error message, if [[Deflate.err]] != 0\n **/\n\n\n/**\n * new Deflate(options)\n * - options (Object): zlib deflate options.\n *\n * Creates new deflator instance with specified params. Throws exception\n * on bad params. Supported options:\n *\n * - `level`\n * - `windowBits`\n * - `memLevel`\n * - `strategy`\n * - `dictionary`\n *\n * [http://zlib.net/manual.html#Advanced](http://zlib.net/manual.html#Advanced)\n * for more information on these.\n *\n * Additional options, for internal needs:\n *\n * - `chunkSize` - size of generated data chunks (16K by default)\n * - `raw` (Boolean) - do raw deflate\n * - `gzip` (Boolean) - create gzip wrapper\n * - `header` (Object) - custom header for gzip\n *   - `text` (Boolean) - true if compressed data believed to be text\n *   - `time` (Number) - modification time, unix timestamp\n *   - `os` (Number) - operation system code\n *   - `extra` (Array) - array of bytes with extra data (max 65536)\n *   - `name` (String) - file name (binary string)\n *   - `comment` (String) - comment (binary string)\n *   - `hcrc` (Boolean) - true if header crc should be added\n *\n * ##### Example:\n *\n * ```javascript\n * const pako = require('pako')\n *   , chunk1 = new Uint8Array([1,2,3,4,5,6,7,8,9])\n *   , chunk2 = new Uint8Array([10,11,12,13,14,15,16,17,18,19]);\n *\n * const deflate = new pako.Deflate({ level: 3});\n *\n * deflate.push(chunk1, false);\n * deflate.push(chunk2, true);  // true -> last chunk\n *\n * if (deflate.err) { throw new Error(deflate.err); }\n *\n * console.log(deflate.result);\n * ```\n **/\nfunction Deflate$1(options) {\n  this.options = common.assign({\n    level: Z_DEFAULT_COMPRESSION,\n    method: Z_DEFLATED$1,\n    chunkSize: 16384,\n    windowBits: 15,\n    memLevel: 8,\n    strategy: Z_DEFAULT_STRATEGY\n  }, options || {});\n\n  let opt = this.options;\n\n  if (opt.raw && (opt.windowBits > 0)) {\n    opt.windowBits = -opt.windowBits;\n  }\n\n  else if (opt.gzip && (opt.windowBits > 0) && (opt.windowBits < 16)) {\n    opt.windowBits += 16;\n  }\n\n  this.err    = 0;      // error code, if happens (0 = Z_OK)\n  this.msg    = '';     // error message\n  this.ended  = false;  // used to avoid multiple onEnd() calls\n  this.chunks = [];     // chunks of compressed data\n\n  this.strm = new zstream();\n  this.strm.avail_out = 0;\n\n  let status = deflate_1$2.deflateInit2(\n    this.strm,\n    opt.level,\n    opt.method,\n    opt.windowBits,\n    opt.memLevel,\n    opt.strategy\n  );\n\n  if (status !== Z_OK$2) {\n    throw new Error(messages[status]);\n  }\n\n  if (opt.header) {\n    deflate_1$2.deflateSetHeader(this.strm, opt.header);\n  }\n\n  if (opt.dictionary) {\n    let dict;\n    // Convert data if needed\n    if (typeof opt.dictionary === 'string') {\n      // If we need to compress text, change encoding to utf8.\n      dict = strings.string2buf(opt.dictionary);\n    } else if (toString$1.call(opt.dictionary) === '[object ArrayBuffer]') {\n      dict = new Uint8Array(opt.dictionary);\n    } else {\n      dict = opt.dictionary;\n    }\n\n    status = deflate_1$2.deflateSetDictionary(this.strm, dict);\n\n    if (status !== Z_OK$2) {\n      throw new Error(messages[status]);\n    }\n\n    this._dict_set = true;\n  }\n}\n\n/**\n * Deflate#push(data[, flush_mode]) -> Boolean\n * - data (Uint8Array|ArrayBuffer|String): input data. Strings will be\n *   converted to utf8 byte sequence.\n * - flush_mode (Number|Boolean): 0..6 for corresponding Z_NO_FLUSH..Z_TREE modes.\n *   See constants. Skipped or `false` means Z_NO_FLUSH, `true` means Z_FINISH.\n *\n * Sends input data to deflate pipe, generating [[Deflate#onData]] calls with\n * new compressed chunks. Returns `true` on success. The last data block must\n * have `flush_mode` Z_FINISH (or `true`). That will flush internal pending\n * buffers and call [[Deflate#onEnd]].\n *\n * On fail call [[Deflate#onEnd]] with error code and return false.\n *\n * ##### Example\n *\n * ```javascript\n * push(chunk, false); // push one of data chunks\n * ...\n * push(chunk, true);  // push last chunk\n * ```\n **/\nDeflate$1.prototype.push = function (data, flush_mode) {\n  const strm = this.strm;\n  const chunkSize = this.options.chunkSize;\n  let status, _flush_mode;\n\n  if (this.ended) { return false; }\n\n  if (flush_mode === ~~flush_mode) _flush_mode = flush_mode;\n  else _flush_mode = flush_mode === true ? Z_FINISH$2 : Z_NO_FLUSH$1;\n\n  // Convert data if needed\n  if (typeof data === 'string') {\n    // If we need to compress text, change encoding to utf8.\n    strm.input = strings.string2buf(data);\n  } else if (toString$1.call(data) === '[object ArrayBuffer]') {\n    strm.input = new Uint8Array(data);\n  } else {\n    strm.input = data;\n  }\n\n  strm.next_in = 0;\n  strm.avail_in = strm.input.length;\n\n  for (;;) {\n    if (strm.avail_out === 0) {\n      strm.output = new Uint8Array(chunkSize);\n      strm.next_out = 0;\n      strm.avail_out = chunkSize;\n    }\n\n    // Make sure avail_out > 6 to avoid repeating markers\n    if ((_flush_mode === Z_SYNC_FLUSH || _flush_mode === Z_FULL_FLUSH) && strm.avail_out <= 6) {\n      this.onData(strm.output.subarray(0, strm.next_out));\n      strm.avail_out = 0;\n      continue;\n    }\n\n    status = deflate_1$2.deflate(strm, _flush_mode);\n\n    // Ended => flush and finish\n    if (status === Z_STREAM_END$2) {\n      if (strm.next_out > 0) {\n        this.onData(strm.output.subarray(0, strm.next_out));\n      }\n      status = deflate_1$2.deflateEnd(this.strm);\n      this.onEnd(status);\n      this.ended = true;\n      return status === Z_OK$2;\n    }\n\n    // Flush if out buffer full\n    if (strm.avail_out === 0) {\n      this.onData(strm.output);\n      continue;\n    }\n\n    // Flush if requested and has data\n    if (_flush_mode > 0 && strm.next_out > 0) {\n      this.onData(strm.output.subarray(0, strm.next_out));\n      strm.avail_out = 0;\n      continue;\n    }\n\n    if (strm.avail_in === 0) break;\n  }\n\n  return true;\n};\n\n\n/**\n * Deflate#onData(chunk) -> Void\n * - chunk (Uint8Array): output data.\n *\n * By default, stores data blocks in `chunks[]` property and glue\n * those in `onEnd`. Override this handler, if you need another behaviour.\n **/\nDeflate$1.prototype.onData = function (chunk) {\n  this.chunks.push(chunk);\n};\n\n\n/**\n * Deflate#onEnd(status) -> Void\n * - status (Number): deflate status. 0 (Z_OK) on success,\n *   other if not.\n *\n * Called once after you tell deflate that the input stream is\n * complete (Z_FINISH). By default - join collected chunks,\n * free memory and fill `results` / `err` properties.\n **/\nDeflate$1.prototype.onEnd = function (status) {\n  // On success - join\n  if (status === Z_OK$2) {\n    this.result = common.flattenChunks(this.chunks);\n  }\n  this.chunks = [];\n  this.err = status;\n  this.msg = this.strm.msg;\n};\n\n\n/**\n * deflate(data[, options]) -> Uint8Array\n * - data (Uint8Array|ArrayBuffer|String): input data to compress.\n * - options (Object): zlib deflate options.\n *\n * Compress `data` with deflate algorithm and `options`.\n *\n * Supported options are:\n *\n * - level\n * - windowBits\n * - memLevel\n * - strategy\n * - dictionary\n *\n * [http://zlib.net/manual.html#Advanced](http://zlib.net/manual.html#Advanced)\n * for more information on these.\n *\n * Sugar (options):\n *\n * - `raw` (Boolean) - say that we work with raw stream, if you don't wish to specify\n *   negative windowBits implicitly.\n *\n * ##### Example:\n *\n * ```javascript\n * const pako = require('pako')\n * const data = new Uint8Array([1,2,3,4,5,6,7,8,9]);\n *\n * console.log(pako.deflate(data));\n * ```\n **/\nfunction deflate$1(input, options) {\n  const deflator = new Deflate$1(options);\n\n  deflator.push(input, true);\n\n  // That will never happens, if you don't cheat with options :)\n  if (deflator.err) { throw deflator.msg || messages[deflator.err]; }\n\n  return deflator.result;\n}\n\n\n/**\n * deflateRaw(data[, options]) -> Uint8Array\n * - data (Uint8Array|ArrayBuffer|String): input data to compress.\n * - options (Object): zlib deflate options.\n *\n * The same as [[deflate]], but creates raw data, without wrapper\n * (header and adler32 crc).\n **/\nfunction deflateRaw$1(input, options) {\n  options = options || {};\n  options.raw = true;\n  return deflate$1(input, options);\n}\n\n\n/**\n * gzip(data[, options]) -> Uint8Array\n * - data (Uint8Array|ArrayBuffer|String): input data to compress.\n * - options (Object): zlib deflate options.\n *\n * The same as [[deflate]], but create gzip wrapper instead of\n * deflate one.\n **/\nfunction gzip$1(input, options) {\n  options = options || {};\n  options.gzip = true;\n  return deflate$1(input, options);\n}\n\n\nvar Deflate_1$1 = Deflate$1;\nvar deflate_2 = deflate$1;\nvar deflateRaw_1$1 = deflateRaw$1;\nvar gzip_1$1 = gzip$1;\nvar constants$1 = constants$2;\n\nvar deflate_1$1 = {\n\tDeflate: Deflate_1$1,\n\tdeflate: deflate_2,\n\tdeflateRaw: deflateRaw_1$1,\n\tgzip: gzip_1$1,\n\tconstants: constants$1\n};\n\n// (C) 1995-2013 Jean-loup Gailly and Mark Adler\n// (C) 2014-2017 Vitaly Puzrin and Andrey Tupitsin\n//\n// This software is provided 'as-is', without any express or implied\n// warranty. In no event will the authors be held liable for any damages\n// arising from the use of this software.\n//\n// Permission is granted to anyone to use this software for any purpose,\n// including commercial applications, and to alter it and redistribute it\n// freely, subject to the following restrictions:\n//\n// 1. The origin of this software must not be misrepresented; you must not\n//   claim that you wrote the original software. If you use this software\n//   in a product, an acknowledgment in the product documentation would be\n//   appreciated but is not required.\n// 2. Altered source versions must be plainly marked as such, and must not be\n//   misrepresented as being the original software.\n// 3. This notice may not be removed or altered from any source distribution.\n\n// See state defs from inflate.js\nconst BAD$1 = 16209;       /* got a data error -- remain here until reset */\nconst TYPE$1 = 16191;      /* i: waiting for type bits, including last-flag bit */\n\n/*\n   Decode literal, length, and distance codes and write out the resulting\n   literal and match bytes until either not enough input or output is\n   available, an end-of-block is encountered, or a data error is encountered.\n   When large enough input and output buffers are supplied to inflate(), for\n   example, a 16K input buffer and a 64K output buffer, more than 95% of the\n   inflate execution time is spent in this routine.\n\n   Entry assumptions:\n\n        state.mode === LEN\n        strm.avail_in >= 6\n        strm.avail_out >= 258\n        start >= strm.avail_out\n        state.bits < 8\n\n   On return, state.mode is one of:\n\n        LEN -- ran out of enough output space or enough available input\n        TYPE -- reached end of block code, inflate() to interpret next block\n        BAD -- error in block data\n\n   Notes:\n\n    - The maximum input bits used by a length/distance pair is 15 bits for the\n      length code, 5 bits for the length extra, 15 bits for the distance code,\n      and 13 bits for the distance extra.  This totals 48 bits, or six bytes.\n      Therefore if strm.avail_in >= 6, then there is enough input to avoid\n      checking for available input while decoding.\n\n    - The maximum bytes that a single length/distance pair can output is 258\n      bytes, which is the maximum length that can be coded.  inflate_fast()\n      requires strm.avail_out >= 258 for each loop to avoid checking for\n      output space.\n */\nvar inffast = function inflate_fast(strm, start) {\n  let _in;                    /* local strm.input */\n  let last;                   /* have enough input while in < last */\n  let _out;                   /* local strm.output */\n  let beg;                    /* inflate()'s initial strm.output */\n  let end;                    /* while out < end, enough space available */\n//#ifdef INFLATE_STRICT\n  let dmax;                   /* maximum distance from zlib header */\n//#endif\n  let wsize;                  /* window size or zero if not using window */\n  let whave;                  /* valid bytes in the window */\n  let wnext;                  /* window write index */\n  // Use `s_window` instead `window`, avoid conflict with instrumentation tools\n  let s_window;               /* allocated sliding window, if wsize != 0 */\n  let hold;                   /* local strm.hold */\n  let bits;                   /* local strm.bits */\n  let lcode;                  /* local strm.lencode */\n  let dcode;                  /* local strm.distcode */\n  let lmask;                  /* mask for first level of length codes */\n  let dmask;                  /* mask for first level of distance codes */\n  let here;                   /* retrieved table entry */\n  let op;                     /* code bits, operation, extra bits, or */\n                              /*  window position, window bytes to copy */\n  let len;                    /* match length, unused bytes */\n  let dist;                   /* match distance */\n  let from;                   /* where to copy match from */\n  let from_source;\n\n\n  let input, output; // JS specific, because we have no pointers\n\n  /* copy state to local variables */\n  const state = strm.state;\n  //here = state.here;\n  _in = strm.next_in;\n  input = strm.input;\n  last = _in + (strm.avail_in - 5);\n  _out = strm.next_out;\n  output = strm.output;\n  beg = _out - (start - strm.avail_out);\n  end = _out + (strm.avail_out - 257);\n//#ifdef INFLATE_STRICT\n  dmax = state.dmax;\n//#endif\n  wsize = state.wsize;\n  whave = state.whave;\n  wnext = state.wnext;\n  s_window = state.window;\n  hold = state.hold;\n  bits = state.bits;\n  lcode = state.lencode;\n  dcode = state.distcode;\n  lmask = (1 << state.lenbits) - 1;\n  dmask = (1 << state.distbits) - 1;\n\n\n  /* decode literals and length/distances until end-of-block or not enough\n     input data or output space */\n\n  top:\n  do {\n    if (bits < 15) {\n      hold += input[_in++] << bits;\n      bits += 8;\n      hold += input[_in++] << bits;\n      bits += 8;\n    }\n\n    here = lcode[hold & lmask];\n\n    dolen:\n    for (;;) { // Goto emulation\n      op = here >>> 24/*here.bits*/;\n      hold >>>= op;\n      bits -= op;\n      op = (here >>> 16) & 0xff/*here.op*/;\n      if (op === 0) {                          /* literal */\n        //Tracevv((stderr, here.val >= 0x20 && here.val < 0x7f ?\n        //        \"inflate:         literal '%c'\\n\" :\n        //        \"inflate:         literal 0x%02x\\n\", here.val));\n        output[_out++] = here & 0xffff/*here.val*/;\n      }\n      else if (op & 16) {                     /* length base */\n        len = here & 0xffff/*here.val*/;\n        op &= 15;                           /* number of extra bits */\n        if (op) {\n          if (bits < op) {\n            hold += input[_in++] << bits;\n            bits += 8;\n          }\n          len += hold & ((1 << op) - 1);\n          hold >>>= op;\n          bits -= op;\n        }\n        //Tracevv((stderr, \"inflate:         length %u\\n\", len));\n        if (bits < 15) {\n          hold += input[_in++] << bits;\n          bits += 8;\n          hold += input[_in++] << bits;\n          bits += 8;\n        }\n        here = dcode[hold & dmask];\n\n        dodist:\n        for (;;) { // goto emulation\n          op = here >>> 24/*here.bits*/;\n          hold >>>= op;\n          bits -= op;\n          op = (here >>> 16) & 0xff/*here.op*/;\n\n          if (op & 16) {                      /* distance base */\n            dist = here & 0xffff/*here.val*/;\n            op &= 15;                       /* number of extra bits */\n            if (bits < op) {\n              hold += input[_in++] << bits;\n              bits += 8;\n              if (bits < op) {\n                hold += input[_in++] << bits;\n                bits += 8;\n              }\n            }\n            dist += hold & ((1 << op) - 1);\n//#ifdef INFLATE_STRICT\n            if (dist > dmax) {\n              strm.msg = 'invalid distance too far back';\n              state.mode = BAD$1;\n              break top;\n            }\n//#endif\n            hold >>>= op;\n            bits -= op;\n            //Tracevv((stderr, \"inflate:         distance %u\\n\", dist));\n            op = _out - beg;                /* max distance in output */\n            if (dist > op) {                /* see if copy from window */\n              op = dist - op;               /* distance back in window */\n              if (op > whave) {\n                if (state.sane) {\n                  strm.msg = 'invalid distance too far back';\n                  state.mode = BAD$1;\n                  break top;\n                }\n\n// (!) This block is disabled in zlib defaults,\n// don't enable it for binary compatibility\n//#ifdef INFLATE_ALLOW_INVALID_DISTANCE_TOOFAR_ARRR\n//                if (len <= op - whave) {\n//                  do {\n//                    output[_out++] = 0;\n//                  } while (--len);\n//                  continue top;\n//                }\n//                len -= op - whave;\n//                do {\n//                  output[_out++] = 0;\n//                } while (--op > whave);\n//                if (op === 0) {\n//                  from = _out - dist;\n//                  do {\n//                    output[_out++] = output[from++];\n//                  } while (--len);\n//                  continue top;\n//                }\n//#endif\n              }\n              from = 0; // window index\n              from_source = s_window;\n              if (wnext === 0) {           /* very common case */\n                from += wsize - op;\n                if (op < len) {         /* some from window */\n                  len -= op;\n                  do {\n                    output[_out++] = s_window[from++];\n                  } while (--op);\n                  from = _out - dist;  /* rest from output */\n                  from_source = output;\n                }\n              }\n              else if (wnext < op) {      /* wrap around window */\n                from += wsize + wnext - op;\n                op -= wnext;\n                if (op < len) {         /* some from end of window */\n                  len -= op;\n                  do {\n                    output[_out++] = s_window[from++];\n                  } while (--op);\n                  from = 0;\n                  if (wnext < len) {  /* some from start of window */\n                    op = wnext;\n                    len -= op;\n                    do {\n                      output[_out++] = s_window[from++];\n                    } while (--op);\n                    from = _out - dist;      /* rest from output */\n                    from_source = output;\n                  }\n                }\n              }\n              else {                      /* contiguous in window */\n                from += wnext - op;\n                if (op < len) {         /* some from window */\n                  len -= op;\n                  do {\n                    output[_out++] = s_window[from++];\n                  } while (--op);\n                  from = _out - dist;  /* rest from output */\n                  from_source = output;\n                }\n              }\n              while (len > 2) {\n                output[_out++] = from_source[from++];\n                output[_out++] = from_source[from++];\n                output[_out++] = from_source[from++];\n                len -= 3;\n              }\n              if (len) {\n                output[_out++] = from_source[from++];\n                if (len > 1) {\n                  output[_out++] = from_source[from++];\n                }\n              }\n            }\n            else {\n              from = _out - dist;          /* copy direct from output */\n              do {                        /* minimum length is three */\n                output[_out++] = output[from++];\n                output[_out++] = output[from++];\n                output[_out++] = output[from++];\n                len -= 3;\n              } while (len > 2);\n              if (len) {\n                output[_out++] = output[from++];\n                if (len > 1) {\n                  output[_out++] = output[from++];\n                }\n              }\n            }\n          }\n          else if ((op & 64) === 0) {          /* 2nd level distance code */\n            here = dcode[(here & 0xffff)/*here.val*/ + (hold & ((1 << op) - 1))];\n            continue dodist;\n          }\n          else {\n            strm.msg = 'invalid distance code';\n            state.mode = BAD$1;\n            break top;\n          }\n\n          break; // need to emulate goto via \"continue\"\n        }\n      }\n      else if ((op & 64) === 0) {              /* 2nd level length code */\n        here = lcode[(here & 0xffff)/*here.val*/ + (hold & ((1 << op) - 1))];\n        continue dolen;\n      }\n      else if (op & 32) {                     /* end-of-block */\n        //Tracevv((stderr, \"inflate:         end of block\\n\"));\n        state.mode = TYPE$1;\n        break top;\n      }\n      else {\n        strm.msg = 'invalid literal/length code';\n        state.mode = BAD$1;\n        break top;\n      }\n\n      break; // need to emulate goto via \"continue\"\n    }\n  } while (_in < last && _out < end);\n\n  /* return unused bytes (on entry, bits < 8, so in won't go too far back) */\n  len = bits >> 3;\n  _in -= len;\n  bits -= len << 3;\n  hold &= (1 << bits) - 1;\n\n  /* update state and return */\n  strm.next_in = _in;\n  strm.next_out = _out;\n  strm.avail_in = (_in < last ? 5 + (last - _in) : 5 - (_in - last));\n  strm.avail_out = (_out < end ? 257 + (end - _out) : 257 - (_out - end));\n  state.hold = hold;\n  state.bits = bits;\n  return;\n};\n\n// (C) 1995-2013 Jean-loup Gailly and Mark Adler\n// (C) 2014-2017 Vitaly Puzrin and Andrey Tupitsin\n//\n// This software is provided 'as-is', without any express or implied\n// warranty. In no event will the authors be held liable for any damages\n// arising from the use of this software.\n//\n// Permission is granted to anyone to use this software for any purpose,\n// including commercial applications, and to alter it and redistribute it\n// freely, subject to the following restrictions:\n//\n// 1. The origin of this software must not be misrepresented; you must not\n//   claim that you wrote the original software. If you use this software\n//   in a product, an acknowledgment in the product documentation would be\n//   appreciated but is not required.\n// 2. Altered source versions must be plainly marked as such, and must not be\n//   misrepresented as being the original software.\n// 3. This notice may not be removed or altered from any source distribution.\n\nconst MAXBITS = 15;\nconst ENOUGH_LENS$1 = 852;\nconst ENOUGH_DISTS$1 = 592;\n//const ENOUGH = (ENOUGH_LENS+ENOUGH_DISTS);\n\nconst CODES$1 = 0;\nconst LENS$1 = 1;\nconst DISTS$1 = 2;\n\nconst lbase = new Uint16Array([ /* Length codes 257..285 base */\n  3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31,\n  35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258, 0, 0\n]);\n\nconst lext = new Uint8Array([ /* Length codes 257..285 extra */\n  16, 16, 16, 16, 16, 16, 16, 16, 17, 17, 17, 17, 18, 18, 18, 18,\n  19, 19, 19, 19, 20, 20, 20, 20, 21, 21, 21, 21, 16, 72, 78\n]);\n\nconst dbase = new Uint16Array([ /* Distance codes 0..29 base */\n  1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193,\n  257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, 6145,\n  8193, 12289, 16385, 24577, 0, 0\n]);\n\nconst dext = new Uint8Array([ /* Distance codes 0..29 extra */\n  16, 16, 16, 16, 17, 17, 18, 18, 19, 19, 20, 20, 21, 21, 22, 22,\n  23, 23, 24, 24, 25, 25, 26, 26, 27, 27,


Der Grep zog .storybook-JSON mit rein. Ich schaue gezielt: was am Fire3D-Template/CSS zuletzt geändert wurde und ob der Storybook-Preview/Story-Wrapper einen Scroll-Container hat.


echo "=== Fire3D.vue diff (template+style only) letzter Commit ===" && git diff b05bebf 5564353 -- packages/frontend/src/components/visual/Fire3D.vue | grep -E '^[+-].*(template|<div|class=|height|overflow|grid|100vh|style=|fire3d)' | head -40 && echo "=== storybook preview config ===" && ls packages/frontend/.storybook/ && echo "---" && sed -n '1,80p' packages/frontend/.storybook/preview.* 2>/dev/null

Fire3D layout diff and storybook preview


=== Fire3D.vue diff (template+style only) letzter Commit ===
=== storybook preview config ===
app-resources
assets
fire
main.ts
manager.ts
preview.ts
products
server-root
themes.ts
---
import type { Preview, Decorator } from "@storybook/vue3";
import { defineComponent, watchEffect, h } from "vue";
import { addons } from "@storybook/preview-api";
import "../src/css/theme.css";
import "../src/css/base.css";
import { PRESETS } from "./themes";

const SESSION_KEY = "cpq-custom-theme-vars";

let customVars: Record<string, string> = {};
try {
    const saved = sessionStorage.getItem(SESSION_KEY);
    if (saved) customVars = JSON.parse(saved) as Record<string, string>;
} catch {
    /* ignore parse errors */
}

// ─── Decorator var tracking ───────────────────────────────────────────────────
// Every --cpq-* var the decorator writes to :root is tracked here.
// At the start of each render the entire set is removed so that component
// tokens from one story never "leak" into the next story's export or visuals.

let decoratorVars = new Set<string>();

function writeRootVar(root: HTMLElement, prop: string, value: string): void {
    root.style.setProperty(prop, value);
    decoratorVars.add(prop);
}

function clearDecoratorVars(root: HTMLElement): void {
    for (const prop of decoratorVars) root.style.removeProperty(prop);
    decoratorVars = new Set();
}

// ─── Channel setup (lazy — only after Storybook is fully initialized) ─────────

let channelReady = false;

function initChannel(): void {
    if (channelReady) return;
    channelReady = true;
    try {
        const ch = addons.getChannel();

        ch.on("cpq/requestVars", () => {
            // Read from root.style (inline overrides only) so the export only
            // contains vars that were actively set — no noise from theme.css defaults.
            const root = document.documentElement;
            const vars: Record<string, string> = {};
            for (let i = 0; i < root.style.length; i++) {
                const prop = root.style[i];
                if (prop.startsWith("--cpq-")) vars[prop] = root.style.getPropertyValue(prop).trim();
            }
            ch.emit("cpq/varsResponse", vars);
        });

        ch.on("cpq/applyVars", (vars: Record<string, string>) => {
            customVars = vars;
            try {
                sessionStorage.setItem(SESSION_KEY, JSON.stringify(vars));
            } catch {
                /* ignore */
            }
            // Immediately apply for instant visual feedback;
            // the decorator will do a clean re-apply on the next render.
            const root = document.documentElement;
            for (const [key, value] of Object.entries(vars)) {
                if (key.startsWith("--cpq-")) root.style.setProperty(key, value);
            }
        });

        ch.on("cpq/clearCustomVars", () => {
            customVars = {};
            try {
                sessionStorage.removeItem(SESSION_KEY);
            } catch {
                /* ignore */
            }
        });
    } catch {


Fire3D-Layout ist unverändert. Der Verdacht liegt beim Story-Decorator (Wrapper um jede Story). Ich lese den Decorator-Teil.


sed -n '80,220p' packages/frontend/.storybook/preview.ts

Read preview decorator


    } catch {
        /* channel not yet available — will retry on next story render */ channelReady = false;
    }
}

// ─── Story-arg → CSS-var bridge ───────────────────────────────────────────────
// Recognized story arg names that map to Layer-1 global tokens.
// When present in context.args they are written to :root so that:
//   (a) the export always captures the story's active values, and
//   (b) controls in stories without a wrapper-div inline style still work.

// Values can be a single CSS var name or an array when one story arg must drive
// multiple component tokens (e.g. themeImageBg is shared between PBP and PBC).
const STORY_THEME_ARG_MAP: Record<string, string | string[]> = {
    // ── Layer-1 global tokens ────────────────────────────────────────────────
    themeColorPrimary: "--cpq-color-primary",
    themeColorFocus: "--cpq-color-focus",
    themeColorDanger: "--cpq-color-danger",
    themeColorText: "--cpq-color-text",
    themeColorLabel: "--cpq-color-label",
    themeColorSurface: "--cpq-color-surface",
    themeColorBorder: "--cpq-color-border",
    themeColorHover: "--cpq-color-hover",
    themeRadiusBase: "--cpq-radius-base",
    themeControlHeight: "--cpq-control-height",
    themeBorderWidth: "--cpq-border-width",
    themeFontFamily: "--cpq-font-family",
    themeDisabledOpacity: "--cpq-disabled-opacity",
    themePlaceholderOpacity: "--cpq-placeholder-opacity",
    themeFocusOffset: "--cpq-focus-offset",
    // ── ProductBrowserPanel component tokens ─────────────────────────────────
    themeWidthNarrow: "--cpq-pbp-width-narrow",
    themeWidthWide: "--cpq-pbp-width-wide",
    themeBg: "--cpq-pbp-bg",
    themeBorderColor: "--cpq-pbp-border-color",
    themeRadius: "--cpq-pbp-radius",
    themeBoxPadding: "--cpq-pbp-box-padding",
    themeBoxHoverBg: "--cpq-pbp-box-hover-bg",
    themeItemHeight: "--cpq-pbp-item-height",
    // ── ProductSettingsPanel component tokens ────────────────────────────────
    themePspWidth: "--cpq-psp-width",
    themePspPadding: "--cpq-psp-padding",
    themePspGap: "--cpq-psp-gap",
    themePspBg: "--cpq-psp-bg",
    themePspBorderColor: "--cpq-psp-border-color",
    themePspRadius: "--cpq-psp-radius",
    themePspBorderWidth: "--cpq-psp-border-width",
    themePspActionsGap: "--cpq-psp-actions-gap",
    themePspTitleSize: "--cpq-psp-title-size",
    themePspTitleWeight: "--cpq-psp-title-weight",
    themePspTitleColor: "--cpq-psp-title-color",
    themePspBodyGap: "--cpq-psp-body-gap",
    // ── ProductBrowserCard component tokens ──────────────────────────────────
    themeCardBg: "--cpq-pb-card-bg",
    themeCardRadius: "--cpq-pb-card-radius",
    themeCardShadow: "--cpq-pb-card-shadow",
    themeCardWidth: "--cpq-pb-card-width",
    themeCardPadding: "--cpq-pb-card-padding",
    themeCardGap: "--cpq-pb-card-gap",
    themeLabelColor: "--cpq-pb-label-color",
    themeLabelSize: "--cpq-pb-label-size",
    themeLabelWeight: "--cpq-pb-label-weight",
    themeImageHeight: "--cpq-pb-image-height",
    themeImageRadius: "--cpq-pb-image-radius",
    // themeImageBg is used by both PBP ('--cpq-pbp-image-bg') and PBC ('--cpq-pb-image-bg')
    themeImageBg: ["--cpq-pbp-image-bg", "--cpq-pb-image-bg"],
    themeImageBorder: "--cpq-pb-image-border",
};

// ─── Decorator ────────────────────────────────────────────────────────────────

const themeDecorator: Decorator = (Story, context) => {
    initChannel();

    const root = document.documentElement;

    function applyArgMap(a: Record<string, unknown>, defaults?: Record<string, unknown>): void {
        for (const [argName, cssVar] of Object.entries(STORY_THEME_ARG_MAP)) {
            const value = a[argName];
            if (value == null || value === "") continue;
            // In 'custom' mode: skip args still at their story default so that
            // imported customVars take precedence over unmodified defaults.
            if (defaults != null && value === defaults[argName]) continue;
            const targets = Array.isArray(cssVar) ? cssVar : [cssVar];
            for (const t of targets) writeRootVar(root, t, String(value));
        }
    }

    // Story is a PartialStoryFn — must be called to get the ConcreteComponent.
    // Calling it here (at decoration time) mirrors what the original `return Story()` did.
    const StoryComponent = Story();

    return defineComponent({
        setup() {
            // watchEffect re-runs whenever context.args or context.globals change,
            // because Storybook makes them Vue reactive objects. This ensures CSS
            // vars on :root are updated live when the user edits controls.
            watchEffect(() => {
                clearDecoratorVars(root);

                const presetKey = (context.globals["cpqPreset"] as string) || "default";
                const args = context.args as Record<string, unknown>;

                if (presetKey === "custom") {
                    for (const [key, value] of Object.entries(customVars)) {
                        if (key.startsWith("--cpq-")) writeRootVar(root, key, value);
                    }
                    // Pass initialArgs so that args still at their story defaults
                    // don't overwrite imported customVars values.
                    applyArgMap(args, context.initialArgs as Record<string, unknown>);
                } else {
                    const preset = PRESETS[presetKey] ?? PRESETS["default"];
                    for (const [key, value] of Object.entries(preset.vars)) {
                        if (key.startsWith("--cpq-")) writeRootVar(root, key, value);
                    }
                    applyArgMap(args);
                }
            });

            return () => h(StoryComponent);
        },
    });
};

// ─── Preview config ───────────────────────────────────────────────────────────

const preview: Preview = {
    globalTypes: {
        cpqPreset: {
            description: "CPQ Theme Preset",
            toolbar: {
                title: "Theme",
                icon: "paintbrush",
                items: [
                    { value: "default", title: "Default" },
                    { value: "adesso", title: "Adesso" },
                    { value: "custom", title: "Custom" },
                ],
                dynamicTitle: true,
            },
        },


Decorator fügt keinen Wrapper mit Padding hinzu — er rendert die Story direkt. Die Stories wrappen selbst in `<div style="height:100vh">`. Klassiker: `100vh` plus ein Body-Margin/Box-Sizing kippt über. Ich prüfe base.css (html/body-Reset) und ob die Story-Dateien zuletzt geändert wurden.


echo "=== base.css html/body/box-sizing ===" && grep -nE 'html|body|box-sizing|margin|\*\s*\{' packages/frontend/src/css/base.css | head -20; echo "=== recent changes to visual stories ===" && git log --oneline -3 -- 'packages/frontend/src/components/visual/*.stories.ts'; echo "=== fire3d container in DOM? bottom slot? ==="; grep -n 'fire3d__bottom\|100vh\|setBaseline\|product-configurator' packages/frontend/src/components/visual/*.stories.ts

Check base.css reset and story history


=== base.css html/body/box-sizing ===
3:    font: var(--cpq-field-font, var(--cpq-font-body, 14px/1.4 system-ui, sans-serif));
20:    margin-left: var(--cpq-space-1);
27:    box-sizing: border-box;
34:    font: var(--cpq-field-font, var(--cpq-font-body, 14px/1.4 system-ui, sans-serif));
58:    margin-top: var(--cpq-space-1);
=== recent changes to visual stories ===
f6b0546 Verbau: Dreipunkt-/Anbau-Logik als GekoPlacementModule auslagern
d448cdb ProductConfigurator-Story: Docking-Regeln aus JSON laden
be58fd3 Klassen-basierte Platzierung: Docking-Regeln pro Bauteilklasse
=== fire3d container in DOM? bottom slot? ===
packages/frontend/src/components/visual/ProductConfigurator.stories.ts:36:

packages/frontend/src/components/visual/Fire3d.stories.ts:32:        template: `<div style="height:100vh"><Fire3D ref="fire3d" :processor="processor" :config="config" /></div>`,
packages/frontend/src/components/visual/Fire3d.stories.ts:48:                fire3d.value?.setBaseline(EMPTY);
packages/frontend/src/components/visual/Fire3d.stories.ts:59: