<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Lutz Leonhardt</title>
    <description>The latest articles on DEV Community by Lutz Leonhardt (@lutz_leonhardt).</description>
    <link>https://dev.to/lutz_leonhardt</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3876255%2F3dab236f-4f54-4e36-8f07-62b7ca366cca.png</url>
      <title>DEV Community: Lutz Leonhardt</title>
      <link>https://dev.to/lutz_leonhardt</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/lutz_leonhardt"/>
    <language>en</language>
    <item>
      <title>Frankenstein Meeting Room: Three Apps in One Browser Tab</title>
      <dc:creator>Lutz Leonhardt</dc:creator>
      <pubDate>Tue, 12 May 2026 21:26:07 +0000</pubDate>
      <link>https://dev.to/lutz_leonhardt/frankenstein-meeting-room-three-apps-in-one-browser-tab-igi</link>
      <guid>https://dev.to/lutz_leonhardt/frankenstein-meeting-room-three-apps-in-one-browser-tab-igi</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;🇩🇪 &lt;strong&gt;Auf Deutsch lesen:&lt;/strong&gt; &lt;a href="https://dev.to/lutz_leonhardt/frankenstein-meeting-room-drei-apps-in-einem-browser-tab-24p7"&gt;Frankenstein Meeting Room: Drei Apps in einem Browser-Tab&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Live demo: &lt;a href="https://lutzleonhardt.de/frankenstein-meeting-room/" rel="noopener noreferrer"&gt;lutzleonhardt.de/frankenstein-meeting-room&lt;/a&gt;&lt;br&gt;
Code: &lt;a href="https://github.com/lutzleonhardt/FrankensteinMeetingRoom" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  1. Intro — Why Frankenstein?
&lt;/h2&gt;

&lt;p&gt;The 2010s were the great frontend war. Many new UI frameworks emerged in this period and disappeared again. You can see this in today's enterprises' legacy applications. Many have Angular, React, Vue, or Svelte in use for various use cases. If you want to use these applications not as islands but in their entirety, the only option is usually to migrate to a common framework. A long-running and error-prone process. A better option can be to let the apps communicate with each other via a common orchestrator platform. That is the starting point for our Frankenstein Meeting Room based on &lt;a href="https://native-federation.com" rel="noopener noreferrer"&gt;Native Federation v4&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  2. What the app does
&lt;/h2&gt;

&lt;p&gt;The setup is meant to show how a heterogeneous web app landscape (Angular, React, Svelte) can be integrated via Native Federation. The project simulates a legacy enterprise landscape in miniature. Concretely: In an Angular 21 calendar (shell), meetings can be selected. The linked information is displayed in a Svelte 5 Mermaid diagram (Remote 1) as well as a React 18 Excalidraw whiteboard (Remote 2). When switching the meeting, the data is saved and loaded (LocalStorage).&lt;/p&gt;

&lt;p&gt;The demo runs at &lt;a href="https://lutzleonhardt.de/frankenstein-meeting-room/" rel="noopener noreferrer"&gt;lutzleonhardt.de/frankenstein-meeting-room&lt;/a&gt;, the code is on &lt;a href="https://github.com/lutzleonhardt/FrankensteinMeetingRoom" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  3. Starting point and workflow
&lt;/h2&gt;

&lt;p&gt;In the &lt;a href="https://dev.to/lutz_leonhardt/-the-frankenstein-meeting-room-how-to-stitch-angular-react-and-svelte-into-one-app-351g"&gt;previous article&lt;/a&gt; I already presented the specification of the implementation and the UI mockup (Claude Design).&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F04k36yz3xanajecnc0i8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F04k36yz3xanajecnc0i8.png" alt=" " width="800" height="500"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The original UI mockup from Part 1, generated with Claude Design. The lab notebook look made it into the finished app.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For the implementation I used my &lt;a href="https://github.com/lutzleonhardt/skill-kit-agentic-workflow" rel="noopener noreferrer"&gt;Skill Kit for agentic workflows&lt;/a&gt; together with the code agents Claude Code and Codex in tandem. The agents translated the specification into individual milestone plans — in the end 18 tasks across 6 milestones. Each milestone plan was broken down into tasks. The advantage of this approach: each milestone produces a verifiable artifact that the developer can validate and review in isolation. The result then serves as the basis for the next step. For each task not only the code is produced, but also a task log: what was tried, what was discarded, which hypotheses died along the way. This negative information is almost always lost in normal commit bodies — while writing this post I had to look things up &lt;a href="https://github.com/lutzleonhardt/FrankensteinMeetingRoom/tree/main/docs/task-log" rel="noopener noreferrer"&gt;there&lt;/a&gt; most often.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The NF builder doesn't release the process.&lt;/strong&gt; At your own terminal you don't notice it. You hit Ctrl-C and move on. In the agentic workflow this becomes a problem: the agent doesn't know that the work is done and keeps waiting. The workaround was a small wrapper script: delete the artifact beforehand, start &lt;code&gt;ng build&lt;/code&gt;, poll for the artifact, then send &lt;code&gt;SIGKILL&lt;/code&gt; to the entire process group.&lt;/p&gt;
&lt;h2&gt;
  
  
  4. Native Federation: Shell, Remotes, Bus
&lt;/h2&gt;

&lt;p&gt;At the center of Native Federation is the shell (also called host), which loads the other web apps or exported UI components as remotes from remote endpoints. In our case the remotes are standalone web apps (Svelte, React), each of which exports a custom element for the shell.&lt;/p&gt;

&lt;p&gt;Since no internal binding can be used for communication between the heterogeneous frameworks, an agnostic messaging pattern must be established. In the case of Frankenstein Meeting Room I decided on a simple, self-implemented pub-sub bus. The topology is star-shaped: the remotes cannot communicate with each other.&lt;/p&gt;

&lt;p&gt;The bus implementation lies in the shared area of the monorepo, shell and remotes each import the functionality themselves. The actual bus is attached as a singleton to globalThis (window). The &lt;code&gt;BusEvents&lt;/code&gt; were typed as &lt;code&gt;DeepReadonly&amp;lt;T&amp;gt;&lt;/code&gt; to prevent accidental mutation across the framework boundary at compile time. A deep clone would be too costly: the entire Excalidraw data structure would have had to be cloned.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// packages/shared/src/bus.ts&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;BusEvents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;context:request&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;event:selected&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;meetingId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;initialData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Meeting&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;drawing:changed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;meetingId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;excalidrawData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ExcalidrawDemoData&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;diagram:changed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;meetingId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;mermaidSource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;globalThis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;frankensteinBus&lt;/span&gt; &lt;span class="o"&gt;??=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;EventTarget&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;EventTarget&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;K&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="kr"&gt;keyof&lt;/span&gt; &lt;span class="nx"&gt;BusEvents&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;K&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DeepReadonly&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;BusEvents&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;K&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;bus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dispatchEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CustomEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkmni9yiw07vwb28mf36p.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkmni9yiw07vwb28mf36p.png" alt=" " width="800" height="582"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Star-shaped topology: all bus communication goes through the center, persistence lives with the host. Rendered live in the Svelte Mermaid editor of the demo itself.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  5. M1 — Monorepo, shared package, shell skeleton
&lt;/h2&gt;

&lt;p&gt;In the first milestone I set up the pnpm monorepo and implemented the types as well as the bus in the shared package. Via a Native Federation schematic the Angular project is then converted into a Native Federation shell. At the core, the project gets an adapted build, a slightly modified bootstrap process, and a federation configuration. The bootstrap runs in two stages: the shell first loads the federation manifest, injects the import map, and only then starts the actual Angular app. The configuration defines which dependencies are shared between shell and remotes.&lt;/p&gt;

&lt;p&gt;That's another advantage of Native Federation: depending on the configuration, libraries are loaded only once, even if multiple remotes and the shell use them.&lt;/p&gt;
&lt;h2&gt;
  
  
  6. M2 — Host complete: calendar, meeting service, panels
&lt;/h2&gt;

&lt;p&gt;In milestone 2 I completed the shell by adding the calendar (&lt;a href="https://github.com/schedule-x/schedule-x" rel="noopener noreferrer"&gt;Schedule-X&lt;/a&gt;). There is also a meeting service that manages the current meetings based on signals, receives messages via the event bus, and sends data, for example to initialize the remotes with the active meeting. Initially the meetings are populated by a seed. On changes they are saved in LocalStorage and loaded from there again on start. In the shell there are two more Angular components: the detail view for the active meeting as well as an overview of the bus messages. The layout is three-column: calendar on the left, remotes in the middle, detail view and bus log on the right.&lt;/p&gt;

&lt;p&gt;A small detail in the meeting service: the remotes debounce their updates (500 ms), and without a guard a meeting switch in the middle of the debounce would lead to the old draft overwriting the new meeting. The fix: every &lt;code&gt;applyDrawingChange&lt;/code&gt;/&lt;code&gt;applyDiagramChange&lt;/code&gt; in the service checks the &lt;code&gt;meetingId&lt;/code&gt; against &lt;code&gt;currentMeeting&lt;/code&gt; and drops stale updates. One line of code, without which the bus would have had subtle data corruption.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nf"&gt;applyDrawingChange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DrawingChangedPayload&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meetingId&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;currentMeeting&lt;/span&gt;&lt;span class="p"&gt;()?.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// stale-update guard&lt;/span&gt;
  &lt;span class="c1"&gt;// ... persist&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the middle area of the shell the React and Svelte applications now still need to be integrated.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. M3 &amp;amp; M4 — Whiteboard and Mermaid as remotes
&lt;/h2&gt;

&lt;p&gt;In milestones 3 and 4 I implemented the React whiteboard (&lt;a href="https://github.com/excalidraw/excalidraw" rel="noopener noreferrer"&gt;Excalidraw&lt;/a&gt;) and the Svelte &lt;a href="https://github.com/mermaid-js/mermaid" rel="noopener noreferrer"&gt;Mermaid&lt;/a&gt; diagram each as a remote. For both I proceeded the same way: first the standalone app, then placed the federation configuration on top. That way I could develop and test Excalidraw or the Mermaid diagram in isolation before the remote was integrated into the shell. The same UI project (react, svelte) continues to run under its own port as a standalone app and can at the same time be loaded by the host as a remote — for the federation loading, however, the built JavaScript chunks plus &lt;code&gt;remoteEntry.json&lt;/code&gt; as static assets are sufficient, a running dev server is not necessary (only for the standalone test).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;@frankenstein/shared&lt;/code&gt; as &lt;code&gt;devDependency&lt;/code&gt;, not as &lt;code&gt;dependency&lt;/code&gt;.&lt;/strong&gt; That way &lt;code&gt;shareAll&lt;/code&gt; skips the package, and the globalThis singleton from Section 4 stays intact — the conceptually most important build-mode decision of the project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Excalidraw spam without real changes.&lt;/strong&gt; Excalidraw's &lt;code&gt;onChange&lt;/code&gt; fires even without real changes. When resizing the window I got ~8 &lt;code&gt;drawing:changed&lt;/code&gt; events in 11 seconds without anyone having drawn anything. I had already built in the 500 ms debounce at the sender — but it only groups the events together, it doesn't recognize whether anything has actually changed. The real fix was a fingerprint over &lt;code&gt;${element.id}:${element.version}&lt;/code&gt;: Excalidraw only bumps &lt;code&gt;version&lt;/code&gt; on real changes, not on cosmetic reflows. With that the empty events fall out before the debounce, and afterwards it only takes care of the frequency of real edits.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;elements&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;|&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fp&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;prevFingerprintRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// skip cosmetic re-renders&lt;/span&gt;
&lt;span class="nx"&gt;prevFingerprintRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fp&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// → 500ms debounce → emit('drawing:changed', …)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Firefox canvas limit.&lt;/strong&gt; Firefox caps &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt; at ~11180 px edge length. Since Excalidraw's stylesheet was only loaded after the React mount, the container was briefly large; Excalidraw computed a canvas size beyond the limit, and the first &lt;code&gt;setTransform&lt;/code&gt; call threw an exception with a stack trace in the console — Excalidraw didn't even boot. Chromium doesn't have the limit: classic "works on my machine" bug. Solved by injecting the stylesheet into the head already at module init and having the first render wait for its &lt;code&gt;load&lt;/code&gt; event.&lt;/p&gt;

&lt;p&gt;The federation configuration is set up the same way for both remotes: a &lt;code&gt;federation.config.mjs&lt;/code&gt; in which it is specified what the remote exports and which libraries are shared. Shared libraries are usually loaded as singletons — exactly one instance for shell and all remotes, even though each project brings them in as a dependency itself. Version conflicts are resolved at build time, transitive sub-dependencies are explicitly shared or excluded. With that the federation configuration ultimately also determines how many JavaScript chunks are produced per application.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;React &lt;code&gt;jsx-runtime&lt;/code&gt; as CJS.&lt;/strong&gt; React still ships some modules as CommonJS, which Native Federation converts to ESM during the build. With one specific pattern — &lt;code&gt;react/jsx-runtime&lt;/code&gt; — this translation doesn't work cleanly: the &lt;code&gt;jsx&lt;/code&gt; function was &lt;code&gt;undefined&lt;/code&gt; at runtime and Excalidraw blew up on the first render. Fixable via path mapping in the federation configuration, which skips the problematic intermediate step and points directly to React's pre-compiled CJS file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Svelte twice in the tab.&lt;/strong&gt; In the Mermaid remote it was the other way around: Svelte couldn't be shared cleanly at all. Its internal code references itself via relative paths that the federation mechanism can't reach — in the end two Svelte runtimes ended up in the tab in parallel, and Mermaid threw &lt;code&gt;effect_orphan&lt;/code&gt; at runtime. Pragmatically solved: Svelte out of the share map and bundled directly into the Mermaid bundle. With only one Svelte app the size doesn't matter — the ~160 kB ends up on the client either way, whether as a shared chunk next to the bundle or as part of it. Only from two Svelte remotes onwards would the duplication start to bite.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Islands instead of components
&lt;/h2&gt;

&lt;p&gt;In a homogeneous landscape — that is, when all apps use the same UI framework — you would export components directly and use them in the shell. In a heterogeneous landscape this doesn't work: if I only exported the React component from the React project, the Angular shell could not load it because the React runtime is simply missing in the tab. A pure component has no platform underneath it.&lt;/p&gt;

&lt;p&gt;The solution is islands: each remote exports not just the component, but the complete app including its framework. In the tab one UI framework then runs per island, fully encapsulated. Technically this happens via native custom elements: the remote defines a &lt;code&gt;&amp;lt;whiteboard-remote&amp;gt;&lt;/code&gt; (or &lt;code&gt;&amp;lt;mermaid-remote&amp;gt;&lt;/code&gt;), the shell renders the tag like any other DOM element, and in the &lt;code&gt;connectedCallback&lt;/code&gt; the embedded framework boots. That's exactly how whiteboard and Mermaid are implemented in milestones 3 and 4.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;WhiteboardRemote&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;HTMLElement&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;connectedCallback&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createRoot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unsubs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;event:selected&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;initialData&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;initialData&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}));&lt;/span&gt;
    &lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;context:request&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nf"&gt;disconnectedCallback&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unsubs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;u&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;unmount&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;customElements&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;whiteboard-remote&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;WhiteboardRemote&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0cbzjy2wtb8lue830nfq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0cbzjy2wtb8lue830nfq.png" alt=" " width="800" height="367"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;React DevTools see the full component tree (left). Wappalyzer finds no React (right). The island works.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Bus access and events
&lt;/h2&gt;

&lt;p&gt;Shell and remotes each import the bus directly from the shared package (see Section 4). The central, non-trivial part is the &lt;strong&gt;init handshake&lt;/strong&gt; between remote and host: On mount a remote doesn't yet know which meeting is currently active — the host holds the state. So after its initialization the remote sends a &lt;code&gt;context:request&lt;/code&gt; over the bus and gets back from the host an &lt;code&gt;event:selected&lt;/code&gt; with the current meeting. Only with that does it know which whiteboard or Mermaid data it should load. The two remaining events (&lt;code&gt;drawing:changed&lt;/code&gt;, &lt;code&gt;diagram:changed&lt;/code&gt;) are pure change notifications from the remotes towards the host.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ff0zwr2ui6erqu1unztfu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ff0zwr2ui6erqu1unztfu.png" alt=" " width="800" height="481"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The event bus log live: &lt;code&gt;context:request&lt;/code&gt; on mount of a remote, &lt;code&gt;event:selected&lt;/code&gt; as host response, then &lt;code&gt;drawing:changed&lt;/code&gt; for an actual whiteboard change.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Deliberately not in the setup: creating or editing meetings via the bus. That would have pulled CRUD wiring through all three frameworks without adding anything to the integration pattern itself. What is possible with Native Federation can be shown clearly with this minimal event set as well.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. M5 &amp;amp; M6 — Polish and deployment
&lt;/h2&gt;

&lt;p&gt;The last two milestones were about polish and deployment. M5 was fine-tuning: improved the CSS, did cleanups, created a representative seed. The seed has a small punchline: instead of lorem ipsum meetings, the demo meeting is an "Architecture Review" of the demo itself. The whiteboard draws three boxes plus bus, Mermaid shows the sequence diagram of the bus flow. The hero image of the post follows from that on its own.&lt;/p&gt;

&lt;p&gt;M6 was the deployment to &lt;a href="https://lutzleonhardt.de/frankenstein-meeting-room/" rel="noopener noreferrer"&gt;&lt;code&gt;https://lutzleonhardt.de/frankenstein-meeting-room/&lt;/code&gt;&lt;/a&gt; — all static under a subpath, no backend, plus the two remotes separately under &lt;code&gt;/whiteboard/&lt;/code&gt; and &lt;code&gt;/mermaid/&lt;/code&gt; as standalone apps. During development each remote ran via its own standalone dev server, the federation manifest pointed to &lt;code&gt;localhost&lt;/code&gt; URLs accordingly. After the build the remotes migrate as static assets into the subpaths of the host, and the manifest is adjusted to match.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. Take-away
&lt;/h2&gt;

&lt;p&gt;The whole setup stood in 10 to 12 hours of net effort, with the help of the agentic process and consistent curation. Extending it is easy: further React applications can be plugged in just like an additional framework, for example Vue. That makes Native Federation a good fit for bringing a grown heterogeneous legacy landscape together onto one platform without having to pull off a big migration.&lt;/p&gt;

&lt;p&gt;What the setup deliberately does not cover: authentication and authorization, cross-origin hardening, multi-user collaboration, contract versioning between host and remotes, e2e tests across island boundaries, mobile layouts. These are not the hard NF topics — but the topics that come &lt;em&gt;in addition&lt;/em&gt; to NF in a real enterprise migration. The full delimitation is in the &lt;a href="https://github.com/lutzleonhardt/FrankensteinMeetingRoom#out-of-scope-production-concerns" rel="noopener noreferrer"&gt;README&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Particular pitfalls exist mainly when multiple versions of the same framework run in parallel — for example Angular 15 and Angular 17 in the same platform. Then you have to pay close attention to shared dependencies, sub-dependencies, and transitive dependencies. The loading of dependencies runs centrally via the native import map, though, which keeps it manageable. In contrast to Webpack Module Federation, where the remote map was buried deep in the code.&lt;/p&gt;

&lt;p&gt;Conclusion: the friction between three frameworks in one tab is not where you would expect it. Bus and loading ran without problems. Complications arose with the Firefox CSS, the React CJS wrapper, and a Svelte compiler that ran twice in the same page. The code is at &lt;a href="https://github.com/lutzleonhardt/FrankensteinMeetingRoom" rel="noopener noreferrer"&gt;github.com/lutzleonhardt/FrankensteinMeetingRoom&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you have such a landscape in front of you yourself and don't know whether to migrate or federate — write to me on &lt;a href="https://www.linkedin.com/in/lutz-leonhardt-754880358/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;. The problem points that don't come up in standard talks interest me the most.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Lutz Leonhardt is a member of the &lt;a href="https://native-federation.com/teams" rel="noopener noreferrer"&gt;Native Federation Advisory Board&lt;/a&gt;. More at &lt;a href="https://lutzleonhardt.de" rel="noopener noreferrer"&gt;lutzleonhardt.de&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>microfrontends</category>
      <category>nativefederation</category>
      <category>typescript</category>
      <category>angular</category>
    </item>
    <item>
      <title>Frankenstein Meeting Room: Drei Apps in einem Browser-Tab</title>
      <dc:creator>Lutz Leonhardt</dc:creator>
      <pubDate>Tue, 12 May 2026 16:38:59 +0000</pubDate>
      <link>https://dev.to/lutz_leonhardt/frankenstein-meeting-room-drei-apps-in-einem-browser-tab-24p7</link>
      <guid>https://dev.to/lutz_leonhardt/frankenstein-meeting-room-drei-apps-in-einem-browser-tab-24p7</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;🇬🇧 &lt;strong&gt;Read in English:&lt;/strong&gt; &lt;a href="https://dev.to/lutz_leonhardt/frankenstein-meeting-room-three-apps-in-one-browser-tab-igi"&gt;Frankenstein Meeting Room: Three Apps in One Browser Tab&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Live demo: &lt;a href="https://lutzleonhardt.de/frankenstein-meeting-room/" rel="noopener noreferrer"&gt;lutzleonhardt.de/frankenstein-meeting-room&lt;/a&gt;&lt;br&gt;
Code: &lt;a href="https://github.com/lutzleonhardt/FrankensteinMeetingRoom" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  1. Einstieg - Warum Frankenstein?
&lt;/h2&gt;

&lt;p&gt;Die 2010er Jahre waren der große Frontendkrieg. Viele neue UI-Frameworks entstanden in dieser Zeit und verschwanden wieder. Das sieht man heutigen Enterprises an ihren Legacy-Anwendungen an. Viele haben Angular, React, Vue oder Svelte für verschiedene Use Cases im Einsatz. Wenn man diese Anwendungen nicht als Inseln, sondern in ihrer Gesamtheit nutzen möchte, bleibt normalerweise nur die Möglichkeit, auf ein gemeinsames Framework zu migrieren. Ein langfristiger und fehleranfälliger Prozess. Eine bessere Möglichkeit kann es sein, die Apps über eine gemeinsame Orchestrator-Plattform miteinander kommunizieren zu lassen. Das ist die Ausgangslage für unseren Frankenstein Meeting Room auf Basis von &lt;a href="https://native-federation.com" rel="noopener noreferrer"&gt;Native Federation v4&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  2. Was die App macht
&lt;/h2&gt;

&lt;p&gt;Der Aufbau soll zeigen, wie man eine heterogene Web-App-Landschaft (Angular, React, Svelte) über Native Federation integrieren kann. Das Projekt simuliert im Kleinen eine Legacy-Enterprise-Landschaft. Konkret: In einem Angular-21-Kalender (Shell) können Meetings ausgewählt werden. Die verknüpften Informationen werden in einem Svelte-5-Mermaid-Diagramm (Remote 1) sowie einem React-18-Excalidraw-Whiteboard (Remote 2) dargestellt. Beim Wechsel des Meetings werden die Daten gespeichert und geladen (LocalStorage).&lt;/p&gt;

&lt;p&gt;Die Demo läuft unter &lt;a href="https://lutzleonhardt.de/frankenstein-meeting-room/" rel="noopener noreferrer"&gt;lutzleonhardt.de/frankenstein-meeting-room&lt;/a&gt;, der Code liegt auf &lt;a href="https://github.com/lutzleonhardt/FrankensteinMeetingRoom" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  3. Ausgangslage und Workflow
&lt;/h2&gt;

&lt;p&gt;Im &lt;a href="https://dev.to/lutz_leonhardt/-the-frankenstein-meeting-room-how-to-stitch-angular-react-and-svelte-into-one-app-351g"&gt;vorherigen Artikel&lt;/a&gt; habe ich bereits die Spezifikation der Umsetzung und das UI-Mockup (Claude Design) vorgestellt. &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F04k36yz3xanajecnc0i8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F04k36yz3xanajecnc0i8.png" alt=" " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Das ursprüngliche UI-Mockup aus Teil 1, generiert mit Claude Design. Der Lab-Notebook-Look hat es bis in die fertige App geschafft.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Zur Umsetzung habe ich mein &lt;a href="https://github.com/lutzleonhardt/skill-kit-agentic-workflow" rel="noopener noreferrer"&gt;Skill-Kit für agentische Workflows&lt;/a&gt; zusammen mit den Code-Agenten Claude Code und Codex im Tandem genutzt. Die Agenten haben die Spezifikation in einzelne Meilensteinpläne überführt — am Ende 18 Tasks über 6 Meilensteine. Jeder Meilensteinplan wurde in Tasks zerlegt. Der Vorteil dieses Vorgehens: jeder Meilenstein bringt ein prüfbares Artefakt hervor, das der Entwickler isoliert validieren und reviewen kann. Das Ergebnis dient dann als Basis für den nächsten Schritt. Pro Task entsteht dabei nicht nur der Code, sondern auch ein Task-Log: was probiert wurde, was verworfen wurde, welche Hypothesen unterwegs starben. Diese Negativ-Information geht in normalen Commit-Bodies fast immer verloren — beim Schreiben dieses Posts musste ich genau &lt;a href="https://github.com/lutzleonhardt/FrankensteinMeetingRoom/tree/main/docs/task-log" rel="noopener noreferrer"&gt;dort&lt;/a&gt; am häufigsten nachlesen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;NF-Builder gibt den Prozess nicht frei.&lt;/strong&gt; Am eigenen Terminal merkt man es nicht. Man drückt Ctrl-C und macht weiter. Im agentischen Workflow wird das zum Problem: der Agent weiß nicht, dass die Arbeit fertig ist, und wartet weiter. Der Ausweg war ein kleines Wrapper-Script: Artefakt vorher löschen, &lt;code&gt;ng build&lt;/code&gt; starten, auf Artefakt pollen, dann der gesamten Prozessgruppe &lt;code&gt;SIGKILL&lt;/code&gt; schicken.&lt;/p&gt;
&lt;h2&gt;
  
  
  4. Native Federation: Shell, Remotes, Bus
&lt;/h2&gt;

&lt;p&gt;Im Zentrum von Native Federation steht die Shell (auch Host genannt), welche die anderen Web-Apps oder exportierten UI-Komponenten als Remotes von entfernten Endpunkten lädt. In unserem Fall sind die Remotes eigenständige Web-Apps (Svelte, React), welche je ein Custom Element für die Shell exportieren.&lt;/p&gt;

&lt;p&gt;Da für die Kommunikation zwischen den heterogenen Frameworks kein internes Binding genutzt werden kann, muss ein agnostisches Nachrichten-Pattern etabliert werden. Im Fall von Frankenstein Meeting Room habe ich mich für einen simplen, selbst implementierten Pub-Sub-Bus entschieden. Die Topologie ist sternförmig: die Remotes können nicht untereinander kommunizieren.&lt;/p&gt;

&lt;p&gt;Die Bus-Implementierung liegt im Shared-Bereich des Monorepos, Shell und Remotes importieren die Funktionalität jeweils selbst. Der eigentliche Bus wird als Singleton an globalThis (window) gehängt. Die &lt;code&gt;BusEvents&lt;/code&gt; wurden als &lt;code&gt;DeepReadonly&amp;lt;T&amp;gt;&lt;/code&gt; typisiert, um eine versehentliche Mutation über die Framework-Grenze zur Kompilierzeit zu verhindern. Ein Deep-Clone wäre zu kostspielig: die gesamte Excalidraw-Datenstruktur hätte geklont werden müssen.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// packages/shared/src/bus.ts&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;BusEvents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;context:request&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;event:selected&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;meetingId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;initialData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Meeting&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;drawing:changed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;meetingId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;excalidrawData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ExcalidrawDemoData&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;diagram:changed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;meetingId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;mermaidSource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;globalThis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;frankensteinBus&lt;/span&gt; &lt;span class="o"&gt;??=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;EventTarget&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;EventTarget&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;K&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="kr"&gt;keyof&lt;/span&gt; &lt;span class="nx"&gt;BusEvents&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;K&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DeepReadonly&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;BusEvents&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;K&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;bus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dispatchEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CustomEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkmni9yiw07vwb28mf36p.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkmni9yiw07vwb28mf36p.png" alt=" " width="800" height="582"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Sternförmige Topologie: alle Bus-Kommunikation läuft durchs Zentrum, Persistenz liegt beim Host. Live gerendert im Svelte-Mermaid-Editor der Demo selbst.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  5. M1 — Monorepo, Shared-Package, Shell-Skeleton
&lt;/h2&gt;

&lt;p&gt;Im ersten Meilenstein habe ich das pnpm-Monorepo aufgesetzt und im Shared-Package die Typen sowie den Bus implementiert. Über ein Native-Federation-Schematic wird das Angular-Projekt anschließend in eine Native-Federation-Shell überführt. Im Kern bekommt das Projekt dabei einen angepassten Build, einen leicht abgewandelten Bootstrap-Prozess und eine Federation-Konfiguration. Der Bootstrap läuft zweistufig: die Shell lädt zuerst das Federation-Manifest, injiziert die Import-Map und startet erst dann die eigentliche Angular-App. Die Konfiguration legt fest, welche Abhängigkeiten zwischen Shell und Remotes geteilt werden.&lt;/p&gt;

&lt;p&gt;Das ist ein weiterer Vorteil von Native Federation: je nach Konfiguration werden Libraries nur einmal geladen, auch wenn mehrere Remotes und die Shell sie nutzen.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. M2 — Host komplett: Kalender, Meeting-Service, Panels
&lt;/h2&gt;

&lt;p&gt;Im Meilenstein 2 habe ich die Shell komplettiert, indem ich den Kalender (&lt;a href="https://github.com/schedule-x/schedule-x" rel="noopener noreferrer"&gt;Schedule-X&lt;/a&gt;) hinzugefügt habe. Außerdem gibt es einen Meeting-Service, der auf Basis von Signals die aktuellen Meetings managt, über den Event-Bus Nachrichten empfängt und Daten sendet, zum Beispiel um die Remotes mit dem aktiven Meeting zu initialisieren. Initial werden die Meetings durch ein Seed befüllt. Bei Änderungen werden sie im LocalStorage gespeichert und beim Start wieder von dort geladen. In der Shell gibt es noch zwei weitere Angular-Komponenten: die Detailanzeige für das aktive Meeting sowie eine Übersicht der Bus-Nachrichten. Das Layout ist dreispaltig: Kalender links, Remotes in der Mitte, Detailanzeige und Bus-Log rechts.&lt;/p&gt;

&lt;p&gt;Ein kleines Detail im Meeting-Service: die Remotes debouncen ihre Updates (500 ms), und ohne Guard würde ein Meeting-Wechsel mitten im Debounce dazu führen, dass der alte Draft das neue Meeting überschreibt. Der Fix: jede &lt;code&gt;applyDrawingChange&lt;/code&gt;/&lt;code&gt;applyDiagramChange&lt;/code&gt; im Service prüft die &lt;code&gt;meetingId&lt;/code&gt; gegen &lt;code&gt;currentMeeting&lt;/code&gt; und droppt stale Updates. Eine Zeile Code, ohne die der Bus eine subtile Daten-Korruption hätte.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nf"&gt;applyDrawingChange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DrawingChangedPayload&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meetingId&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;currentMeeting&lt;/span&gt;&lt;span class="p"&gt;()?.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// stale-update guard&lt;/span&gt;
  &lt;span class="c1"&gt;// ... persist&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Im mittleren Bereich der Shell müssen jetzt noch die React- und Svelte-Anwendungen integriert werden.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. M3 &amp;amp; M4 — Whiteboard und Mermaid als Remotes
&lt;/h2&gt;

&lt;p&gt;Im Meilenstein 3 und 4 habe ich das React-Whiteboard (&lt;a href="https://github.com/excalidraw/excalidraw" rel="noopener noreferrer"&gt;Excalidraw&lt;/a&gt;) und das Svelte-&lt;a href="https://github.com/mermaid-js/mermaid" rel="noopener noreferrer"&gt;Mermaid&lt;/a&gt;-Diagramm jeweils als Remote implementiert. Bei beiden bin ich gleich vorgegangen: zuerst die Standalone-App, dann die Federation-Konfiguration darauf gesetzt. So konnte ich Excalidraw beziehungsweise das Mermaid-Diagramm in Isolation entwickeln und testen, bevor das Remote in die Shell eingebunden wurde. Dasselbe UI Projekt (react, svelte) läuft weiterhin unter eigenem Port als eigenständige App und kann zugleich vom Host als Remote geladen werden — fürs Federation-Loading reichen aber die gebauten JavaScript-Chunks plus &lt;code&gt;remoteEntry.json&lt;/code&gt; als statische Assets, ein laufender Dev-Server ist nicht nötig (nur für den Standalone Test). &lt;br&gt;
&lt;strong&gt;&lt;code&gt;@frankenstein/shared&lt;/code&gt; als &lt;code&gt;devDependency&lt;/code&gt;, nicht als &lt;code&gt;dependency&lt;/code&gt;.&lt;/strong&gt; So überspringt &lt;code&gt;shareAll&lt;/code&gt; das Package, und der globalThis-Singleton aus Abschnitt 4 bleibt intakt — der konzeptionell wichtigste Build-Mode-Entscheid des Projekts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Excalidraw-Spam ohne echte Änderungen.&lt;/strong&gt; Excalidraws &lt;code&gt;onChange&lt;/code&gt; feuert auch ohne echte Änderungen. Beim Resizen des Fensters bekam ich ~8 &lt;code&gt;drawing:changed&lt;/code&gt;-Events in 11 Sekunden, ohne dass jemand etwas gezeichnet hatte. Den 500-ms-Debounce am Sender hatte ich schon eingebaut — der fasst die Events aber nur zusammen, er erkennt nicht, ob sich überhaupt etwas geändert hat. Der eigentliche Fix war ein Fingerprint über &lt;code&gt;${element.id}:${element.version}&lt;/code&gt;: Excalidraw bumpt &lt;code&gt;version&lt;/code&gt; nur bei echten Änderungen, nicht bei kosmetischen Reflows. Damit fallen die leeren Events vor dem Debounce raus, und der kümmert sich danach nur noch um die Frequenz echter Edits.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;elements&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;|&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fp&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;prevFingerprintRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// skip cosmetic re-renders&lt;/span&gt;
&lt;span class="nx"&gt;prevFingerprintRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fp&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// → 500ms debounce → emit('drawing:changed', …)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Firefox-Canvas-Limit.&lt;/strong&gt; Firefox kappt &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt; bei ~11180 px Kantenlänge. Da Excalidraws Stylesheet erst nach dem React-Mount geladen wurde, war der Container kurz groß; Excalidraw rechnete eine Canvas-Größe jenseits des Limits aus, und der erste &lt;code&gt;setTransform&lt;/code&gt;-Call warf eine Exception mit Stacktrace in der Konsole — Excalidraw bootete gar nicht erst. Chromium hat das Limit nicht: klassischer „funktioniert bei mir"-Bug. Gelöst, indem das Stylesheet schon beim Modul-Init in den Head injiziert wird und der erste Render auf dessen &lt;code&gt;load&lt;/code&gt;-Event wartet.&lt;/p&gt;

&lt;p&gt;Die Federation-Konfiguration ist bei beiden Remotes gleich aufgebaut: eine &lt;code&gt;federation.config.mjs&lt;/code&gt;, in der spezifiziert ist, was das Remote exportiert und welche Bibliotheken geteilt werden. Geteilte Bibliotheken werden in der Regel als Singleton geladen — genau eine Instanz für Shell und alle Remotes, auch wenn jedes Projekt sie selbst als Dependency mitbringt. Versionskonflikte werden zur Build-Zeit aufgelöst, transitive Sub-Dependencies explizit geteilt oder ausgeschlossen. Damit bestimmt die Federation-Konfiguration am Ende auch, wie viele JavaScript-Chunks pro Anwendung entstehen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;React &lt;code&gt;jsx-runtime&lt;/code&gt; als CJS.&lt;/strong&gt; React liefert einige Module noch als CommonJS, die Native Federation beim Build in ESM überführt. Bei einem bestimmten Muster — &lt;code&gt;react/jsx-runtime&lt;/code&gt; — klappt diese Übersetzung nicht sauber: die &lt;code&gt;jsx&lt;/code&gt;-Funktion war zur Laufzeit &lt;code&gt;undefined&lt;/code&gt; und Excalidraw flog beim ersten Render. Behebbar per Pfad-Mapping in der Federation-Konfiguration, das den problematischen Zwischenschritt überspringt und direkt auf Reacts fertig kompilierte CJS-Datei zeigt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Svelte zweimal im Tab.&lt;/strong&gt; Im Mermaid-Remote war es umgekehrt: Svelte ließ sich gar nicht sauber teilen. Sein interner Code referenziert sich über relative Pfade, an die der Federation-Mechanismus nicht herankommt — am Ende landeten zwei Svelte-Runtimes parallel im Tab, und Mermaid warf zur Laufzeit &lt;code&gt;effect_orphan&lt;/code&gt;. Pragmatisch gelöst: Svelte aus der Share-Map raus und direkt ins Mermaid-Bundle gebündelt. Bei nur einer Svelte-App ist das größenmäßig egal — die ~160 kB landen so oder so beim Client, ob als geteilter Chunk neben dem Bundle oder als Bestandteil davon. Erst ab zwei Svelte-Remotes käme die Duplikation zum Tragen.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Inseln statt Komponenten
&lt;/h2&gt;

&lt;p&gt;In einer homogenen Landschaft — also wenn alle Apps dasselbe UI-Framework nutzen — würde man Komponenten direkt exportieren und in der Shell verwenden. In einer heterogenen Landschaft funktioniert das nicht: würde ich aus dem React-Projekt nur die React-Komponente exportieren, könnte die Angular-Shell sie nicht laden, weil die React-Runtime im Tab schlicht fehlt. Eine reine Komponente hat keine Plattform unter sich.&lt;/p&gt;

&lt;p&gt;Die Lösung sind Inseln: jedes Remote exportiert nicht nur die Komponente, sondern die komplette App inklusive ihres Frameworks. Im Tab läuft dann pro Insel ein eigenes UI-Framework, vollständig gekapselt. Technisch passiert das über native Custom Elements: das Remote definiert ein &lt;code&gt;&amp;lt;whiteboard-remote&amp;gt;&lt;/code&gt; (bzw. &lt;code&gt;&amp;lt;mermaid-remote&amp;gt;&lt;/code&gt;), die Shell rendert das Tag wie jedes andere DOM-Element, und im &lt;code&gt;connectedCallback&lt;/code&gt; bootet das eingebettete Framework. Genau so sind Whiteboard und Mermaid in Meilenstein 3 und 4 umgesetzt.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;WhiteboardRemote&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;HTMLElement&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;connectedCallback&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createRoot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unsubs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;event:selected&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;initialData&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;initialData&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}));&lt;/span&gt;
    &lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;context:request&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;disconnectedCallback&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unsubs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;u&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;unmount&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;customElements&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;whiteboard-remote&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;WhiteboardRemote&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0cbzjy2wtb8lue830nfq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0cbzjy2wtb8lue830nfq.png" alt=" " width="800" height="367"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;React DevTools sehen den vollständigen Komponentenbaum (links). Wappalyzer findet kein React (rechts). Die Insel funktioniert.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Bus-Zugriff und Events
&lt;/h2&gt;

&lt;p&gt;Shell und Remotes importieren den Bus jeweils direkt aus dem Shared-Package (siehe Abschnitt 4). Der zentrale, nicht-triviale Teil ist der &lt;strong&gt;Init-Handshake&lt;/strong&gt; zwischen Remote und Host: Beim Mount weiß ein Remote noch nicht, welches Meeting gerade aktiv ist — der Host hält den Zustand. Also schickt das Remote nach seiner Initialisierung ein &lt;code&gt;context:request&lt;/code&gt; über den Bus und bekommt vom Host ein &lt;code&gt;event:selected&lt;/code&gt; mit dem aktuellen Meeting zurück. Erst damit weiß es, welche Whiteboard- oder Mermaid-Daten es laden soll. Die beiden übrigen Events (&lt;code&gt;drawing:changed&lt;/code&gt;, &lt;code&gt;diagram:changed&lt;/code&gt;) sind reine Change-Notifications der Remotes Richtung Host.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ff0zwr2ui6erqu1unztfu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ff0zwr2ui6erqu1unztfu.png" alt=" " width="800" height="481"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Der Event-Bus-Log live: &lt;code&gt;context:request&lt;/code&gt; beim Mount eines Remotes, &lt;code&gt;event:selected&lt;/code&gt; als Host-Antwort, dann &lt;code&gt;drawing:changed&lt;/code&gt; für eine echte Whiteboard-Änderung.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Bewusst nicht im Setup: das Anlegen oder Editieren von Meetings über den Bus. Das hätte CRUD-Wiring durch alle drei Frameworks gezogen, ohne am Integrationspattern selbst etwas hinzuzufügen. Was mit Native Federation möglich ist, lässt sich auch mit diesem minimalen Event-Set übersichtlich zeigen.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. M5 &amp;amp; M6 — Polish und Deployment
&lt;/h2&gt;

&lt;p&gt;In den letzten beiden Meilensteinen ging es um Polish und Deployment. M5 war Feinschliff: das CSS verbessert, Cleanups gemacht, einen repräsentativen Seed angelegt. Der Seed hat eine kleine Pointe: statt Lorem-Ipsum-Meetings ist das Demo-Meeting ein „Architecture Review" der Demo selbst. Das Whiteboard zeichnet drei Boxen plus Bus, Mermaid zeigt das Sequenzdiagramm des Bus-Flows. Das Hero-Bild des Posts ergibt sich daraus von allein.&lt;/p&gt;

&lt;p&gt;M6 war das Deployment auf &lt;a href="https://lutzleonhardt.de/frankenstein-meeting-room/" rel="noopener noreferrer"&gt;&lt;code&gt;https://lutzleonhardt.de/frankenstein-meeting-room/&lt;/code&gt;&lt;/a&gt; — alles statisch unter einem Subpath, kein Backend, plus die beiden Remotes separat unter &lt;code&gt;/whiteboard/&lt;/code&gt; und &lt;code&gt;/mermaid/&lt;/code&gt; als eigenständige Apps. Während der Entwicklung lief jedes Remote über seinen eigenen Standalone-Dev-Server, das Federation-Manifest verwies entsprechend auf &lt;code&gt;localhost&lt;/code&gt;-URLs. Nach dem Build wandern die Remotes als statische Assets in die Unterpfade des Hosts, und das Manifest wird passend dazu angepasst.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. Take-away
&lt;/h2&gt;

&lt;p&gt;Das ganze Setup stand in 10 bis 12 Stunden Nettoaufwand, mit Hilfe des agentischen Prozesses und konsequenter Kuratierung. Erweitern lässt es sich leicht: weitere React-Anwendungen lassen sich genauso einbinden wie ein zusätzliches Framework, zum Beispiel Vue. Damit eignet sich Native Federation gut, um eine gewachsene heterogene Legacy-Landschaft auf einer Plattform zusammenzuführen, ohne eine große Migration durchziehen zu müssen.&lt;/p&gt;

&lt;p&gt;Was das Setup bewusst nicht abdeckt: Authentifizierung und Autorisierung, Cross-Origin-Hardening, Mehrbenutzer-Kollaboration, Contract-Versioning zwischen Host und Remotes, e2e-Tests über Insel-Grenzen, mobile Layouts. Das sind nicht die schweren NF-Themen — sondern die Themen, die in einer echten Enterprise-Migration &lt;em&gt;zusätzlich&lt;/em&gt; zu NF kommen. Die vollständige Abgrenzung steht im &lt;a href="https://github.com/lutzleonhardt/FrankensteinMeetingRoom#out-of-scope-production-concerns" rel="noopener noreferrer"&gt;README&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Besondere Fallstricke gibt es vor allem dann, wenn mehrere Versionen desselben Frameworks parallel laufen — etwa Angular 15 und Angular 17 in derselben Plattform. Dann muss man genau auf geteilte Dependencies, Sub-Dependencies und transitive Dependencies achten. Das Laden der Abhängigkeiten läuft aber zentral über die native Import-Map, was es überschaubar hält. Im Gegensatz zu Webpack Module Federation, wo die Remote-Map tief im Code vergraben war.&lt;/p&gt;

&lt;p&gt;Fazit: die Reibung zwischen drei Frameworks in einem Tab befindet sich nicht da, wo man sie erwarten würde. Bus und Loading liefen problemlos. Komplikationen gab es beim Firefox-CSS, dem React-CJS-Wrapper und einem Svelte-Compiler, der zweimal in derselben Seite lief. Der Code liegt unter &lt;a href="https://github.com/lutzleonhardt/FrankensteinMeetingRoom" rel="noopener noreferrer"&gt;github.com/lutzleonhardt/FrankensteinMeetingRoom&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Falls ihr selbst gerade so eine Landschaft vor euch habt und nicht wisst, ob ihr migrieren oder föderieren sollt — schreibt mir auf &lt;a href="[https://www.linkedin.com/in/lutz-leonhardt/](https://www.linkedin.com/in/lutz-leonhardt-754880358/)"&gt;LinkedIn&lt;/a&gt;. Die Problempunkte, welche in Standard-Talks nicht vorkommen, interessieren mich am meisten.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Lutz Leonhardt ist Mitglied im &lt;a href="https://native-federation.com/teams" rel="noopener noreferrer"&gt;Native Federation Advisory Board&lt;/a&gt;. Mehr unter &lt;a href="https://lutzleonhardt.de" rel="noopener noreferrer"&gt;lutzleonhardt.de&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>microfrontends</category>
      <category>nativefederation</category>
      <category>typescript</category>
      <category>angular</category>
    </item>
    <item>
      <title>I built the same MVP twice. The autonomous agent wrote 4.6x more tests — none caught two stubbed core methods.</title>
      <dc:creator>Lutz Leonhardt</dc:creator>
      <pubDate>Sat, 09 May 2026 22:05:21 +0000</pubDate>
      <link>https://dev.to/lutz_leonhardt/i-built-the-same-mvp-twice-the-autonomous-agent-wrote-46x-more-tests-none-caught-two-stubbed-24ho</link>
      <guid>https://dev.to/lutz_leonhardt/i-built-the-same-mvp-twice-the-autonomous-agent-wrote-46x-more-tests-none-caught-two-stubbed-24ho</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Side-post in the &lt;a href="https://dev.to/lutz_leonhardt/i-stopped-maintaining-my-gtd-system-thats-why-it-finally-works-mno"&gt;keppt build-in-public series&lt;/a&gt; — an interlude before the Phase 1 implementation write-up lands.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;While building Phase 1 of &lt;a href="https://getkeppt.com" rel="noopener noreferrer"&gt;keppt&lt;/a&gt;, I ran a side experiment. Same architecture spec, two builds. One I curated over a day with Claude Code and Codex — plan first, tasks derived, agent-implemented per task, adversarial review, iterate. One I handed off to &lt;a href="https://factory.ai/news/missions" rel="noopener noreferrer"&gt;Factory.ai's Droid&lt;/a&gt; in their new Mission Control mode. Spec in, walk away, come back when the budget runs out.&lt;/p&gt;

&lt;p&gt;Here is what came back.&lt;/p&gt;

&lt;h2&gt;
  
  
  the numbers
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Curated (Claude Code + Codex)&lt;/th&gt;
&lt;th&gt;Autonomous (Droid Mission Control)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Wall-clock effort&lt;/td&gt;
&lt;td&gt;~1 day&lt;/td&gt;
&lt;td&gt;~4 hours autonomous&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Source LOC&lt;/td&gt;
&lt;td&gt;1,367&lt;/td&gt;
&lt;td&gt;2,370&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test LOC&lt;/td&gt;
&lt;td&gt;1,317&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6,015&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test cases&lt;/td&gt;
&lt;td&gt;69&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;339&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Working CLI?&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LocalFileRepository.edit()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;real, CAS + audit&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;stub, ignores edits&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LocalFileRepository.search()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;real full-text + scope&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;stub, returns &lt;code&gt;[]&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Path-safety vectors&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The autonomous build wrote 4.6× more test LOC than the curated build, with roughly five times as many test cases. None of those tests caught that two of the repository's core methods do nothing. The CLI entry point is one line.&lt;/p&gt;

&lt;h2&gt;
  
  
  the same starting point
&lt;/h2&gt;

&lt;p&gt;Both builds started from the same spec — a &lt;a href="https://github.com/lutzleonhardt/keppt-app-droid/blob/master/keppt/specs/architecture.md" rel="noopener noreferrer"&gt;1,700-line &lt;code&gt;architecture.md&lt;/code&gt;&lt;/a&gt; describing the tool boundary, vault rules, prompt rules, and model routing for keppt's Phase 1: a single-process CLI that hits a real Obsidian vault with a mocked LLM. Both had the same TypeScript stack, the Vercel AI SDK, Vitest, and the same target: prove the engine end-to-end before any UI sits on top.&lt;/p&gt;

&lt;p&gt;The difference was the workflow, not the input.&lt;/p&gt;

&lt;p&gt;This is not a benchmark of Factory.ai, Droid, Claude Code, or Codex. It is one frozen run on one non-trivial MVP. The tools all move fast; specifics will shift. But the failure mode it exposed is one I now actively design around.&lt;/p&gt;

&lt;h2&gt;
  
  
  what the autonomous build actually produced
&lt;/h2&gt;

&lt;p&gt;The Droid is impressive on the surface. Before writing a line of code, it generated a 129-line &lt;a href="https://github.com/lutzleonhardt/keppt-app-droid/blob/master/session/mission.md" rel="noopener noreferrer"&gt;&lt;code&gt;mission.md&lt;/code&gt;&lt;/a&gt;, an 815-line &lt;a href="https://github.com/lutzleonhardt/keppt-app-droid/blob/master/session/validation-contract.md" rel="noopener noreferrer"&gt;&lt;code&gt;validation-contract.md&lt;/code&gt;&lt;/a&gt;, and 1,373 lines of behavioral specifications across five milestones in &lt;a href="https://github.com/lutzleonhardt/keppt-app-droid/tree/master/session/contract-work" rel="noopener noreferrer"&gt;&lt;code&gt;contract-work/&lt;/code&gt;&lt;/a&gt; — &lt;strong&gt;154 testable assertions&lt;/strong&gt; (&lt;code&gt;VAL-FOUND-*&lt;/code&gt;, &lt;code&gt;VAL-TOOLS-*&lt;/code&gt;, &lt;code&gt;VAL-LIFECYCLE-*&lt;/code&gt;, &lt;code&gt;VAL-AGENT-*&lt;/code&gt;, &lt;code&gt;VAL-CLI-*&lt;/code&gt;, &lt;code&gt;VAL-CROSS-*&lt;/code&gt;) distributed over 29 features, each with preconditions, expected behavior and verification steps. It set up specialized worker skills (&lt;code&gt;core-implementer&lt;/code&gt;, &lt;code&gt;cli-implementer&lt;/code&gt;), checked tooling readiness, and started executing milestone-by-milestone with quality gates between each one. It ran for ~4 hours of pure autonomous time on a $200/month Droid Core plan before its weekly budget hit the wall.&lt;/p&gt;

&lt;p&gt;The full mission directory is in the repo at &lt;a href="https://github.com/lutzleonhardt/keppt-app-droid/tree/master/session" rel="noopener noreferrer"&gt;&lt;code&gt;session/&lt;/code&gt;&lt;/a&gt; — every claim below can be cross-referenced against the actual artifacts.&lt;/p&gt;

&lt;p&gt;When the budget hit, the mission was paused at &lt;strong&gt;25 of 41 planned features&lt;/strong&gt;. Three of five milestones — Foundation, Tools-and-Edits, Vault-Lifecycle — had been sealed against their contracts. The fourth (Agent Runtime) was mid-execution. The fifth (CLI) had not been started. For context: Factory's own data shows 14% of missions run longer than 24 hours, with the longest at 16 days. A 41-feature Phase-1 build was not going to fit inside one $200 weekly budget at any plan tier.&lt;/p&gt;

&lt;p&gt;So the interesting question isn't &lt;em&gt;"why didn't it finish"&lt;/em&gt;. It's &lt;em&gt;"what was inside the milestones it did seal"&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The CLI entry, &lt;a href="https://github.com/lutzleonhardt/keppt-app-droid/blob/master/apps/cli/src/index.ts" rel="noopener noreferrer"&gt;&lt;code&gt;apps/cli/src/index.ts&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cliPackageName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@keppt/cli&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the entire file. The &lt;code&gt;package.json&lt;/code&gt; declares &lt;code&gt;"bin": { "keppt": "./src/index.ts" }&lt;/code&gt;. So the binary entrypoint exists, points to a real file, and that file is a one-line constant. Nothing runs. To be fair, M5 (the CLI milestone) was the last one in the plan — the budget exhausted before any of it was implemented. The CLI's absence is a budget fact. The next file is the more interesting one.&lt;/p&gt;

&lt;p&gt;The local file repository, &lt;a href="https://github.com/lutzleonhardt/keppt-app-droid/blob/master/packages/core/src/repository/local-file-repository.ts#L90" rel="noopener noreferrer"&gt;&lt;code&gt;packages/core/src/repository/local-file-repository.ts:90&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;edit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;filePath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;_edits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SearchReplaceEdit&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;
  &lt;span class="nx"&gt;_changeSummary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;_options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;WriteOptions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;EditResult&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;validateRelativePath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filePath&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filePath&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;newContent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;_scope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SearchScope&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;SearchResult&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;edit()&lt;/code&gt; reads the file, ignores the edits argument, returns the unchanged content with &lt;code&gt;ok: true&lt;/code&gt;. &lt;code&gt;search()&lt;/code&gt; returns an empty array. Both are reachable from the tool layer. &lt;strong&gt;Neither would survive a single real user turn.&lt;/strong&gt; And both shipped inside a &lt;strong&gt;sealed&lt;/strong&gt; milestone — M2 (Tools and Edits) passed all 38 of its assertions with these stubs in place.&lt;/p&gt;

&lt;p&gt;The 339 tests don't catch it because they run against an &lt;code&gt;InMemoryFileRepository&lt;/code&gt; that &lt;em&gt;does&lt;/em&gt; have working implementations. A contract-style test that exercised both implementations against the same expectations would have flagged this — but that test was never written, because the contract docs that drove the milestone were satisfied at the spec level. Green tests, no behavior. More budget might have finished M5. It would not automatically reopen M2.&lt;/p&gt;

&lt;h2&gt;
  
  
  why this happens
&lt;/h2&gt;

&lt;p&gt;The framing I want to push back on is &lt;em&gt;"the agent was sloppy"&lt;/em&gt;. It wasn't sloppy. It was extremely systematic. The artifacts are some of the most rigorous test plans I have seen come out of an automated process. Two structural reasons explain the gap, neither has to do with model quality.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The contract has to exist before the work begins.&lt;/strong&gt; Mission Control runs work as a sequence of milestone gates, each one checking the implementation against pre-written assertions. Behavioral-validation generation runs in parallel — one validator per feature — but the implementation workers per milestone are sequential. Factory's own &lt;a href="https://factory.ai/news/missions#open-questions" rel="noopener noreferrer"&gt;open-questions section&lt;/a&gt; is explicit about this: &lt;em&gt;"Serial execution with targeted parallelization has worked better than broad parallelism."&lt;/em&gt; Every gate still consumes the contract as input, though, so all 154 assertions had to be in place before any feature implementation began. Hence 815 lines of validation contract before the first commit. This is rational gate-cost, not theatre. But it means the contract is frozen early, so whatever it missed — like &lt;em&gt;"the CLI must actually run end-to-end against real files"&lt;/em&gt; — never recovers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No iteration loop.&lt;/strong&gt; Manual curation lets discoveries feed back. My plan grew Task 3.5 (a missing scope-policy gate) and Task 3.7 (path-safety hardening) &lt;em&gt;after&lt;/em&gt; I'd built Task 3 and an adversarial Codex review found gaps. The autonomous mission cannot do that kind of reframing. Mid-mission self-correction does happen — when a review found that TDD red-phase evidence was missing, the orchestrator tightened worker rules and went back; an ESLint task was inserted late. But these are tactical patches inside the existing plan. Architectural reframing — &lt;em&gt;"wait, the user-facing CLI is the actual product"&lt;/em&gt; — does not happen. The autonomous build kept building downward into infrastructure and abstractions. I built upward, toward a thing a user can run. Factory's own diagnosis of this gap is honest: &lt;em&gt;"workers get stuck on edge cases a human would navigate easily."&lt;/em&gt; Edge cases are exactly where iteration matters most.&lt;/p&gt;

&lt;p&gt;This is not "agents bad". This is &lt;strong&gt;a different cost surface&lt;/strong&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The autonomous build paid up front, in contract surface.&lt;br&gt;
The curated build paid continuously, in attention.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  two patterns I stole anyway
&lt;/h2&gt;

&lt;p&gt;Walking away with "manual curation wins" would be the lazy take. The Droid did real work, and two of its patterns are now in &lt;a href="https://github.com/lutzleonhardt/skill-kit-agentic-workflow" rel="noopener noreferrer"&gt;my skill kit&lt;/a&gt; — concrete diffs, not vibes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Stable acceptance-criteria IDs.&lt;/strong&gt; &lt;code&gt;T2-AC-01&lt;/code&gt;...&lt;code&gt;T2-AC-07&lt;/code&gt; per task. Lets a wrap-up reference exactly which criteria a session covered. Lets adversarial review tag findings against a specific AC — and a finding without an AC reference is the loudest possible signal that the plan has a gap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Cross-cutting acceptance.&lt;/strong&gt; A short table at the end of the plan for invariants no single task can prove on its own. &lt;em&gt;"History-log has exactly one entry per mutating turn across the whole session."&lt;/em&gt; &lt;em&gt;"Single-clock invariant: prompt date, scope-check date, search &lt;code&gt;today&lt;/code&gt; parameter all match within a turn."&lt;/em&gt; Touches: &lt;code&gt;{T2, T3, T6}&lt;/code&gt;. The Droid produced &lt;a href="https://github.com/lutzleonhardt/keppt-app-droid/blob/master/session/contract-work/cross-area.md" rel="noopener noreferrer"&gt;17 of these as &lt;code&gt;VAL-CROSS-*&lt;/code&gt;&lt;/a&gt;. Five to eight is enough for a sequential build.&lt;/p&gt;

&lt;h2&gt;
  
  
  the honest closer
&lt;/h2&gt;

&lt;p&gt;The real question I went in with was simpler than &lt;em&gt;"which workflow is better"&lt;/em&gt;: could the answer be &lt;em&gt;"just hand it over and lean back"&lt;/em&gt;? If yes, we're close to &lt;em&gt;replaces us&lt;/em&gt; on this kind of work, and I'd take it. Three things stopped me from that conclusion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost.&lt;/strong&gt; $200 bought ~25 of 41 features. Finishing the same mission would likely have required multiple budget cycles. That doesn't make the tool bad — but it changes the ROI calculation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Clock.&lt;/strong&gt; ~4 hours of mission time plus the upfront planning is in the same ballpark as my curated day. The autonomy advantage is real (I can step away), but not enormous.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bloat.&lt;/strong&gt; 1.7× more source code and 4.6× more test code before the build had even reached the user-facing CLI is a long-term maintainability tax, not a saving. keppt keeps growing past Phase 1, and someone has to read all of that.&lt;/p&gt;

&lt;p&gt;So no, I'm not going to sell you "manual curation always wins" or "agents will replace us next quarter". Both are useful, neither alone is enough. The lesson is dimensional:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Autonomous missions are good at:&lt;/strong&gt; generating contract surface, parallelizable subsystems, defensive scaffolding, exhaustive parametrized rejection tables.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Curated workflows are good at:&lt;/strong&gt; sequencing, end-to-end focus, mid-build pivots, and asking &lt;em&gt;"is this still the actual product?"&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The hybrid I'm running on keppt now: human-curated plan, agent-generated behavioral contracts on top of it, human contract review before implementation, agent implementation per task, adversarial review by a different agent. It costs more attention than fire-and-forget. The output exists and runs.&lt;/p&gt;

&lt;p&gt;The frozen experiment — 25 of 41 features completed, 3 of 5 milestones sealed (M2 with two stubbed core methods inside), 8.4k total LOC, 339 green tests — sits at &lt;a href="https://github.com/lutzleonhardt/keppt-app-droid" rel="noopener noreferrer"&gt;keppt-app-droid&lt;/a&gt; for anyone who wants to verify the claims above. The maintained build is &lt;a href="https://github.com/lutzleonhardt/keppt-app" rel="noopener noreferrer"&gt;keppt-app&lt;/a&gt;; Phase 1 MVP is done, Phase 2 starts now.&lt;/p&gt;

&lt;p&gt;If you've handed a non-trivial build to an autonomous agent and gotten it back beautifully tested but unusable — what was the missing constraint?&lt;/p&gt;




&lt;p&gt;&lt;em&gt;keppt is a chat-driven task &amp;amp; note system. You talk; it stays kept in plain markdown. Beta list and method page at getkeppt.com. Next in the main series: the Phase 1 implementation write-up.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>automation</category>
      <category>contextengineering</category>
    </item>
    <item>
      <title>I stopped maintaining my GTD system. That's why it finally works.</title>
      <dc:creator>Lutz Leonhardt</dc:creator>
      <pubDate>Thu, 07 May 2026 15:03:28 +0000</pubDate>
      <link>https://dev.to/lutz_leonhardt/i-stopped-maintaining-my-gtd-system-thats-why-it-finally-works-mno</link>
      <guid>https://dev.to/lutz_leonhardt/i-stopped-maintaining-my-gtd-system-thats-why-it-finally-works-mno</guid>
      <description>&lt;p&gt;For something like fifteen years I tried to make Getting Things Done stick. Evernote, Everdo, OmniFocus, Things, Todoist, Notion — same pattern every time: read the book, build the inbox, schedule the weekly review, three weeks of discipline, then drift. Once the lists stop reflecting reality, you stop trusting them. Once you stop trusting them, you stop using them. The system dies, you blame yourself, and a year later you try the next app.&lt;/p&gt;

&lt;p&gt;The method was never the problem. The maintenance was.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqaciz369thhxqfl15uml.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqaciz369thhxqfl15uml.png" alt="Screenshot of Everdo task manager: 79 items in Inbox, 13 in Waiting, 97 in Focus, dozens of unprocessed generic tasks listed below." width="800" height="1380"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Inbox: 79. Focus: 97. Focus is supposed to be 3–5. This is what "I'll review it on Sunday" looks like after a year of Sundays.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Sorting the inbox. Reviewing lists. Re-tagging things by context, energy, location, project. Filling in metadata that the system needs to be useful but that you, sitting on the couch at 9pm, do not want to fill in. The barrier to capture a task in a "proper" GTD app is absurdly high. And that's before the weekly review you're already three weeks behind on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The accidental hack that held
&lt;/h2&gt;

&lt;p&gt;A few months ago I tried something stupid. I made a flat folder in Obsidian — &lt;code&gt;inbox.md&lt;/code&gt;, &lt;code&gt;focus.md&lt;/code&gt;, &lt;code&gt;next-actions.md&lt;/code&gt;, &lt;code&gt;waiting.md&lt;/code&gt;, &lt;code&gt;someday-maybe.md&lt;/code&gt;, a &lt;code&gt;daily/&lt;/code&gt; directory — and a &lt;code&gt;CLAUDE.md&lt;/code&gt; next to them with the rules: where things live, how they move, what "sync" means, when to push back. Then I pointed Claude Cowork at the folder and just talked.&lt;/p&gt;

&lt;p&gt;That's it. No app. No plugin. No fancy schema. Markdown files plus a rulebook.&lt;/p&gt;

&lt;p&gt;The conversation looks like this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"What's on for tomorrow?"&lt;/em&gt;&lt;br&gt;
&lt;em&gt;"New task: send the client quote, due Friday."&lt;/em&gt;&lt;br&gt;
&lt;em&gt;"Push the dentist thing to next week."&lt;/em&gt;&lt;br&gt;
&lt;em&gt;"Sync my daily note."&lt;/em&gt;&lt;br&gt;
&lt;em&gt;"It's Friday — time for the weekly review?"&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Claude moves the files, keeps the daily notes in sync, flags inconsistencies ("you have task X in Focus but it's not in today's plan — should I add it?"), and reminds me about the review. I haven't opened a task list manually in months. I just talk. The system stays alive without me feeling I have to keep it alive.&lt;/p&gt;

&lt;p&gt;The full ruleset is published. It's the &lt;a href="https://getkeppt.com/method/" rel="noopener noreferrer"&gt;method page on getkeppt.com&lt;/a&gt;. Drop the &lt;code&gt;CLAUDE.md&lt;/code&gt; into a folder, add the markdown files, point any decent agent at it. Works.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual product is trust, not voice
&lt;/h2&gt;

&lt;p&gt;The obvious framing for this is "voice + AI + markdown." That framing is wrong, and I only saw it after a few weeks of real use.&lt;/p&gt;

&lt;p&gt;What makes this work is not voice. Voice is a delivery mechanism. What makes this work is that the system behaves like a &lt;em&gt;conservative bookkeeper&lt;/em&gt;, not a creative assistant. It moves files. It flags drift. It asks before it invents. If it ever silently misfiles a task or creatively interprets what I meant, the trust is gone, and a GTD system you don't trust is dead inventory. I've killed enough of those to know.&lt;/p&gt;

&lt;p&gt;So the design rule that fell out of the experience is simple: no creative interpretation, no silent edits, no "helpful" reorganization. Show the diff. Ask when unclear. Keep the user in the loop. The boring answer is the right answer. That single principle ends up shaping the architecture more than any model choice does.&lt;/p&gt;

&lt;h2&gt;
  
  
  The catch, and why this is becoming an app
&lt;/h2&gt;

&lt;p&gt;Cowork is a developer tool. Without a Claude Code setup and a willingness to write a &lt;code&gt;CLAUDE.md&lt;/code&gt;, you can't reproduce any of this. That's a small audience for something that, for me at least, fixed a fifteen-year-old problem.&lt;/p&gt;

&lt;p&gt;So I'm turning it into an app — and finding out whether the thing that fixed my own system survives outside my own setup. &lt;strong&gt;One chat surface. Voice or text, whichever has less friction in the moment. The maintenance running in the engine room&lt;/strong&gt; — inbox triage, daily plan, weekly review, consistency checks — all maintained by the model, not by you. Tasks live as plain markdown that you can always open and read. No dashboards. No tagging UI. No second system to maintain.&lt;/p&gt;

&lt;p&gt;The pitch and the static prototype are on &lt;a href="https://getkeppt.com" rel="noopener noreferrer"&gt;getkeppt.com&lt;/a&gt;. The mockup on the landing page is exactly that: a mockup. The real app is being built now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building this in the open
&lt;/h2&gt;

&lt;p&gt;I'm shipping the build publicly — repo, specs, task log, prompts, what breaks. The roadmap is split into phases that each end on a real validation checkpoint, not a calendar date.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phase 1 (in progress):&lt;/strong&gt; a CLI that proves the prompts and the tool loop against a real Obsidian vault. No server. No auth. No payments. Just the engine, hardened against my own files and my own usage. If that works, the rest has a foundation. If it doesn't, no UI on top is going to save it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phase 2:&lt;/strong&gt; Express backend, Angular + Capacitor mobile shell, voice input, hosted storage. The actual product.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phase 3:&lt;/strong&gt; everything I'm deliberately not doing yet.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/lutzleonhardt/keppt-app" rel="noopener noreferrer"&gt;github.com/lutzleonhardt/keppt-app&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Beta list: &lt;a href="https://getkeppt.com" rel="noopener noreferrer"&gt;getkeppt.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The agentic workflow behind the build is public too — the &lt;a href="https://github.com/lutzleonhardt/skill-kit-agentic-workflow" rel="noopener noreferrer"&gt;Skill Kit Adjutant&lt;/a&gt; — but that's a separate rabbit hole.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next post
&lt;/h2&gt;

&lt;p&gt;Phase 1 is almost shipped. Post #2 is the post-mortem — the prompts that survived, the tool loop that didn't crash, the things I had to harden after a week of using the engine on my own files.&lt;/p&gt;

&lt;p&gt;If you've ever bailed on a productivity system not because capture was hard, but because maintenance slowly drifted away from reality — I'm curious what kept you going, or what finally broke it.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>webdev</category>
      <category>gtd</category>
    </item>
    <item>
      <title>The Frankenstein Meeting Room: How to Stitch Angular, React, and Svelte Into One App</title>
      <dc:creator>Lutz Leonhardt</dc:creator>
      <pubDate>Wed, 06 May 2026 14:25:24 +0000</pubDate>
      <link>https://dev.to/lutz_leonhardt/-the-frankenstein-meeting-room-how-to-stitch-angular-react-and-svelte-into-one-app-351g</link>
      <guid>https://dev.to/lutz_leonhardt/-the-frankenstein-meeting-room-how-to-stitch-angular-react-and-svelte-into-one-app-351g</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Part 1 of 2. Part 2 walks through the actual build, milestone by milestone: &lt;a href="https://dev.to/lutz_leonhardt/frankenstein-meeting-room-three-apps-in-one-browser-tab-igi"&gt;Frankenstein Meeting Room: Three Apps in One Browser Tab&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fq8eag7obcwxxpgn2g6tk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fq8eag7obcwxxpgn2g6tk.png" alt="Hero — Frankenstein Meeting Room mockup" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three frontend frameworks in the same business domain is the rule, not the exception. One team adopted Angular years ago. Another fell in love with React. The M&amp;amp;A team brought a Vue app along. The standard answer is &lt;em&gt;Rewrite&lt;/em&gt; — years, millions, often failing.&lt;/p&gt;

&lt;p&gt;There is another answer: let them live together. This post walks through the architectural spec for a small but real demo that does exactly that — Angular, React, and Svelte inside a single workspace, sharing one business context. Call it Frankenstein-Driven Architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Frankenstein Reality
&lt;/h2&gt;

&lt;p&gt;Heterogeneity in enterprise frontends isn't a temporary mess to be cleaned up. It's a permanent condition. Acquisitions bring new stacks. Teams pick what they know. Industry tides shift; once-favored frameworks fall out of fashion long before the apps written in them stop earning money.&lt;/p&gt;

&lt;p&gt;The rewrite-first culture treats this as a problem to be eliminated. Two years, ten engineers, one framework to rule them all. By the time the rewrite ships, the dominant framework has changed again, the original team has left, and the business questions whether any of it was worth doing.&lt;/p&gt;

&lt;p&gt;The alternative is to design &lt;em&gt;with&lt;/em&gt; the heterogeneity instead of against it. Stop asking &lt;em&gt;how do we make everything one framework?&lt;/em&gt; and start asking &lt;em&gt;how do we let multiple frameworks share one product?&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architectural Principle
&lt;/h2&gt;

&lt;p&gt;The whole spec rests on one sentence:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Remote owns capability. Host owns business context and persistence.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Each remote is responsible for &lt;em&gt;what it does best&lt;/em&gt; — drawing, diagramming, reporting, scheduling. The host is responsible for &lt;em&gt;what the business is about&lt;/em&gt; — meetings, customers, orders, claims. Remotes do not own state. They do not own routing. They do not own the user. They render a capability when handed a context, and they emit changes when the user does something.&lt;/p&gt;

&lt;p&gt;In this demo, the Angular host owns the meeting context. The React remote owns whiteboarding via Excalidraw. The Svelte remote owns diagram editing via Mermaid. The principle scales: replace whiteboard with reporting, diagrams with scheduling, the structure stays the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  Islands, Not Components
&lt;/h2&gt;

&lt;p&gt;When the host runs Angular and a remote runs React, there is no shared component model, no shared hook system, no shared reactivity. There is no React-component-inside-Angular-template trick that survives contact with reality.&lt;/p&gt;

&lt;p&gt;So each remote is a complete, self-contained application — an island. What it exposes to the host is not a React component or a Svelte component, but a Custom Element that wraps the entire app and boots it on mount.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;WhiteboardRemote&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;HTMLElement&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;connectedCallback&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createRoot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;App&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nf"&gt;disconnectedCallback&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;unmount&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;customElements&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;whiteboard-remote&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;WhiteboardRemote&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The host then consumes the remote like any other DOM element:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;whiteboard-remote&amp;gt;&amp;lt;/whiteboard-remote&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Web Components are the boundary because Web Components are a browser standard. Angular, React, Svelte, Vue — all four know how to render and listen to a Custom Element. The browser, not the framework, owns the integration contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  One Channel, Four Events
&lt;/h2&gt;

&lt;p&gt;If the host is the only orchestrator, communication runs through one channel: an event bus. No initial state via attributes. No properties that have to be set before mount. Remotes are &lt;em&gt;dumb on mount&lt;/em&gt; — they know nothing until the bus tells them.&lt;/p&gt;

&lt;p&gt;Four events cover the entire cross-framework communication:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;context:request&lt;/code&gt; — Remote → Host, &lt;em&gt;"I just mounted, what's the current context?"&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;event:selected&lt;/code&gt; — Host → Remotes, &lt;em&gt;"the user is now looking at meeting X, here's its data"&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;drawing:changed&lt;/code&gt; — React → Host, &lt;em&gt;"the whiteboard changed, here's the new payload"&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;diagram:changed&lt;/code&gt; — Svelte → Host, &lt;em&gt;"the diagram changed, here's the new source"&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The bus itself is fifteen lines of TypeScript wrapping a &lt;code&gt;globalThis&lt;/code&gt;-pinned &lt;code&gt;EventTarget&lt;/code&gt;. No library. No broker. The wrapper provides typed &lt;code&gt;emit&lt;/code&gt; and &lt;code&gt;on&lt;/code&gt; so neither end has to remember which payload belongs to which event.&lt;/p&gt;

&lt;p&gt;The flow for the most important interaction — &lt;em&gt;the user clicks a meeting in the calendar and both remotes update&lt;/em&gt; — is one round-trip:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv2ec4z3k02lgyo4nrt5h.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv2ec4z3k02lgyo4nrt5h.png" alt=" " width="800" height="244"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The host is the hub, the remotes are spokes. Spokes never talk to each other directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Native Federation as the Vehicle
&lt;/h2&gt;

&lt;p&gt;The plumbing that lets the host actually load the React and Svelte bundles at runtime is &lt;strong&gt;Native Federation v4&lt;/strong&gt; — Manfred Steyer's framework-agnostic, ESM- and import-map-native successor to Webpack Module Federation.&lt;/p&gt;

&lt;p&gt;Two adapters do the work. The Angular adapter (&lt;code&gt;@angular-architects/native-federation-v4&lt;/code&gt;) wires the host: a &lt;code&gt;dynamic-host&lt;/code&gt; schematic generates a two-phase bootstrap (init federation first, bootstrap Angular second), a &lt;code&gt;federation.manifest.json&lt;/code&gt; listing remote URLs, and a builder that splits shared dependencies into separate chunks. The esbuild adapter (&lt;code&gt;@softarc/native-federation-esbuild&lt;/code&gt;) builds the remotes: a small &lt;code&gt;build.mjs&lt;/code&gt; script drives &lt;code&gt;runEsBuildBuilder&lt;/code&gt; and produces a &lt;code&gt;remoteEntry.json&lt;/code&gt; plus its bundle. No Vite involved — the official remote adapter is esbuild-based and framework-agnostic.&lt;/p&gt;

&lt;p&gt;The runtime is the &lt;strong&gt;Orchestrator&lt;/strong&gt; (&lt;code&gt;@softarc/native-federation-orchestrator&lt;/code&gt;), v4's recommended replacement for the classic runtime. It does semver-aware version resolution for shared dependencies, caches &lt;code&gt;remoteEntry.json&lt;/code&gt; data across reloads, and handles share scopes for multi-team setups.&lt;/p&gt;

&lt;p&gt;The same machinery is what makes this pattern interesting for &lt;strong&gt;migration&lt;/strong&gt;. A team running an Angular monolith can carve a new feature out as a federated remote in any framework — React, Svelte, whatever the team picks — without touching the existing app. Old code keeps shipping, new capabilities arrive as islands. There is no all-or-nothing rewrite gate.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Demo
&lt;/h2&gt;

&lt;p&gt;The demo is deliberately small. A meeting room app where the user picks a meeting from a calendar, and the meeting opens with two artifacts side by side: a whiteboard sketch (React + Excalidraw) and a sequence diagram (Svelte + Mermaid). Both are real, iconic open-source applications, embedded as full islands.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9bqvm1kivrfl3mvflwwl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9bqvm1kivrfl3mvflwwl.png" alt="Mockup of the demo layout" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three columns. The Angular calendar (Schedule-X) on the left. The two remotes stacked in the middle. Meeting details and a live event-bus log on the right. Click a meeting, both remotes load that meeting's data. Draw on the whiteboard, the host persists. Switch to a different meeting, both remotes follow the context. Open DevTools and the Network tab shows three frameworks loaded — Angular, React, Svelte — talking through one event bus.&lt;/p&gt;

&lt;p&gt;The full spec is in the repo: &lt;a href="https://github.com/lutzleonhardt/FrankensteinMeetingRoom/blob/main/docs/specs/SPEC.md" rel="noopener noreferrer"&gt;https://github.com/lutzleonhardt/FrankensteinMeetingRoom/blob/main/docs/specs/SPEC.md&lt;/a&gt;. Read it if you want the actual &lt;code&gt;Meeting&lt;/code&gt; type, the &lt;code&gt;MeetingService&lt;/code&gt; skeleton with stale-update guards, the &lt;code&gt;bus.ts&lt;/code&gt; wrapper, the &lt;code&gt;federation.config.mjs&lt;/code&gt; for both remotes, the workspace layout, and the milestones the build will follow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two Notes from the Spec'ing Process
&lt;/h2&gt;

&lt;p&gt;A spec rarely arrives clean on the first pass. Two corrections from this one are worth sharing because they were genuine surprises during the design conversation, not lessons from a textbook.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The "Vite Adapter" doesn't exist.&lt;/strong&gt; Going in, the assumption was that Vite-based remotes were the standard path — Vite is everyone's modern build tool, after all. Reading the actual Native Federation docs revealed that the official adapter is &lt;code&gt;@softarc/native-federation-esbuild&lt;/code&gt;. Vite is &lt;em&gt;not&lt;/em&gt; officially supported. The adapter is framework-agnostic and runs from a hand-written &lt;code&gt;build.mjs&lt;/code&gt;, which initially feels backward but turns out to be cleaner: no Vite-Federation interop magic, no plugin ecosystem assumptions, just esbuild plus your framework's source-transform plugin.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One channel, not two.&lt;/strong&gt; The first instinct was to send initial meeting data via Custom Element properties (the standard Web Components idiom) and use the bus only for ongoing changes. Two channels, two mental models, two places to look when something doesn't render. The spec collapsed this into a single channel: the bus carries everything, including the initial context that a freshly-mounted remote requests via &lt;code&gt;context:request&lt;/code&gt;. The remotes become dumber, the architecture clearer, and the workshop pitch tightens to one line: &lt;em&gt;"the only thing crossing the framework boundary is a bus event."&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 2: The Build
&lt;/h2&gt;

&lt;p&gt;The build is live in part 2: &lt;a href="https://dev.to/lutz_leonhardt/frankenstein-meeting-room-three-apps-in-one-browser-tab-igi"&gt;Frankenstein Meeting Room: Three Apps in One Browser Tab&lt;/a&gt; — all six milestones in one go, plus the friction points that didn't make this spec: the Firefox canvas limit, the React &lt;code&gt;jsx-runtime&lt;/code&gt; CJS wrapper, and Svelte running twice in the same tab.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/lutzleonhardt/FrankensteinMeetingRoom" rel="noopener noreferrer"&gt;https://github.com/lutzleonhardt/FrankensteinMeetingRoom&lt;/a&gt;&lt;br&gt;
Spec: &lt;a href="https://github.com/lutzleonhardt/FrankensteinMeetingRoom/blob/main/docs/specs/SPEC.md" rel="noopener noreferrer"&gt;https://github.com/lutzleonhardt/FrankensteinMeetingRoom/blob/main/docs/specs/SPEC.md&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you have such a landscape in front of you yourself and don't know whether to migrate or federate — write to me on &lt;a href="https://www.linkedin.com/in/lutz-leonhardt-754880358/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;. The problem points that don't come up in standard talks interest me the most.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Lutz Leonhardt is a member of the &lt;a href="https://native-federation.com/teams" rel="noopener noreferrer"&gt;Native Federation Advisory Board&lt;/a&gt;. More at &lt;a href="https://lutzleonhardt.de" rel="noopener noreferrer"&gt;lutzleonhardt.de&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>angular</category>
      <category>architecture</category>
      <category>frontend</category>
      <category>react</category>
    </item>
    <item>
      <title>I Used AI Agents to Migrate 44 Angular Components. The Review Changed My Mind.</title>
      <dc:creator>Lutz Leonhardt</dc:creator>
      <pubDate>Mon, 13 Apr 2026 10:01:47 +0000</pubDate>
      <link>https://dev.to/lutz_leonhardt/i-used-ai-agents-to-migrate-44-angular-components-the-review-changed-my-mind-4pop</link>
      <guid>https://dev.to/lutz_leonhardt/i-used-ai-agents-to-migrate-44-angular-components-the-review-changed-my-mind-4pop</guid>
      <description>&lt;p&gt;I used AI agents to migrate 44 Angular components in SAP Spartacus from Reactive Forms to Signal Forms.&lt;/p&gt;

&lt;p&gt;On the first run, 34 looked successful.&lt;/p&gt;

&lt;p&gt;The review phase showed that "successful" did not mean what I thought it meant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AI scales transformation. It does not guarantee equivalence.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This article covers the initial migration run, what the follow-up review exposed, and how I would structure a large AI-assisted refactoring in a real client project today.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Migration Target: Signal Forms Stage 1
&lt;/h2&gt;

&lt;p&gt;Angular 21.2 ships &lt;code&gt;SignalFormControl&lt;/code&gt; — a bridge between Reactive Forms and Signal Forms. &lt;a href="https://www.angulararchitects.io/blog/migrating-to-angular-signal-forms-interop-with-reactive-forms/" rel="noopener noreferrer"&gt;Manfred Steyer's blog post&lt;/a&gt; describes the interop pattern: replace individual &lt;code&gt;FormControl&lt;/code&gt; instances with &lt;code&gt;SignalFormControl&lt;/code&gt;, keep &lt;code&gt;FormGroup&lt;/code&gt; and templates largely intact, swap &lt;code&gt;Validators.*&lt;/code&gt; for signal-based validators.&lt;/p&gt;

&lt;p&gt;I call this &lt;strong&gt;Stage 1&lt;/strong&gt;: a drop-in replacement with minimal blast radius. No full template rewrite. No &lt;code&gt;FormArray&lt;/code&gt; migration (there's no &lt;code&gt;SignalFormArray&lt;/code&gt; yet).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MyComponent&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;form&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;UntypedFormGroup&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;fb&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;UntypedFormBuilder&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

  &lt;span class="nf"&gt;ngOnInit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;Validators&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;required&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Validators&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;
      &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;Validators&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;required&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// After&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;SignalFormControl&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@angular/forms/signals/compat&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;required&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@angular/forms/signals&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MyComponent&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;emailControl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SignalFormControl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;required&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;passwordControl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SignalFormControl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;required&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;form&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FormGroup&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;emailControl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;passwordControl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No more &lt;code&gt;FormBuilder&lt;/code&gt; injection. No more &lt;code&gt;ngOnInit&lt;/code&gt; initialization. Validators live in a schema function. Templates keep working because &lt;code&gt;SignalFormControl&lt;/code&gt; extends &lt;code&gt;AbstractControl&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Independent components, repetitive steps, an existing test suite — this looked like a near-perfect fit for agentic refactoring. That was true for the transformation itself. It was not true for validation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The First Two Manual Migrations
&lt;/h2&gt;

&lt;p&gt;I migrated &lt;code&gt;CartCouponComponent&lt;/code&gt; and &lt;code&gt;CartQuickOrderFormComponent&lt;/code&gt; by hand. Simple forms, straightforward validators. But even on these, I hit an edge case that became one of the most important migration rules:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;required&lt;/code&gt; HTML attribute trap.&lt;/strong&gt; If your template has:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;required=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt; &lt;span class="na"&gt;formControlName=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Angular's built-in &lt;code&gt;RequiredValidator&lt;/code&gt; directive activates automatically and calls &lt;code&gt;setValidators()&lt;/code&gt; on the bound control. &lt;code&gt;SignalFormControl&lt;/code&gt; does not support dynamic validator mutation and throws:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NG01920: Dynamically adding and removing validators is not supported in signal forms.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix: remove the &lt;code&gt;required&lt;/code&gt; attribute from the template — the validator already lives in the &lt;code&gt;SignalFormControl&lt;/code&gt; schema. Even a migration that looks like a drop-in replacement has hidden edge cases.&lt;/p&gt;

&lt;p&gt;After those two manual migrations, I extracted a reusable process and wrote it down.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Three Artifacts
&lt;/h2&gt;

&lt;p&gt;The entire orchestration rested on three markdown files:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;goal.md&lt;/code&gt; — The Orchestration Protocol.&lt;/strong&gt; Startup sequence, branch strategy, sub-agent loop, abort criteria. When to spawn, when to merge, when to give up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;SignalMigration.md&lt;/code&gt; — The Playbook.&lt;/strong&gt; Step-by-step migration rules, validator mapping, import paths, special cases, verification commands. This was not "prompt engineering" — it was a technical playbook written the way I would document the task for another developer on a team.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Plan.md&lt;/code&gt; — The Bill of Materials.&lt;/strong&gt; All 44 target components with Nx library, file paths, and status. The orchestrator used it as a state machine: &lt;code&gt;TODO&lt;/code&gt; → &lt;code&gt;IN_PROGRESS&lt;/code&gt; → &lt;code&gt;SUCCESS&lt;/code&gt; / &lt;code&gt;FAILED&lt;/code&gt; / &lt;code&gt;SKIP&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Orchestrator Architecture
&lt;/h2&gt;

&lt;p&gt;The orchestrator was a Claude Code agent. It read &lt;code&gt;goal.md&lt;/code&gt;, followed the protocol, and spawned one sub-agent per component using isolated git worktrees:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;subagent_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;general-purpose&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;isolation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;worktree&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`
    You are migrating CheckoutLoginComponent from Reactive Forms
    to SignalFormControl.

    Read first: /SignalFormMigration/SignalMigration.md

    Files to migrate:
    - feature-libs/checkout/base/components/checkout-login/
        checkout-login.component.ts

    Nx library for tests: @spartacus/checkout/base

    After migration, run the verification build.
    Report: SUCCESS with commit hash, or FAILURE with error description.
  `&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;isolation: "worktree"&lt;/code&gt; parameter was critical. Each sub-agent got its own copy of the repository, branched from &lt;code&gt;feature/signal-forms-migration&lt;/code&gt;. It could change files, run tests, and commit without interfering with other migrations.&lt;/p&gt;

&lt;p&gt;On success, the orchestrator merged the worktree branch back into the feature branch using &lt;code&gt;--no-ff&lt;/code&gt;. On failure, the worktree was discarded and the failure documented.&lt;/p&gt;

&lt;p&gt;In total, the migration PR ended up with 94 commits. The initial pipeline ran in a single evening — roughly two to three hours of agent time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Initial Results
&lt;/h2&gt;

&lt;p&gt;Out of 44 target components:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;34&lt;/strong&gt; completed the initial migration pipeline and were merged automatically&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;5&lt;/strong&gt; failed during migration&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;5&lt;/strong&gt; were skipped because they used &lt;code&gt;FormArray&lt;/code&gt;, which has no Stage 1 equivalent yet&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is a &lt;strong&gt;77% initial automation rate&lt;/strong&gt; across all 44 targets (34/44). If you exclude the 5 components that were intentionally skipped because &lt;code&gt;FormArray&lt;/code&gt; has no compatible Stage 1 migration path, the initial run reached &lt;strong&gt;87%&lt;/strong&gt; across attempted components (34/39).&lt;/p&gt;

&lt;p&gt;That initial result was real and useful. It proved that the mechanical part of the migration could be scaled across a large Angular codebase.&lt;/p&gt;

&lt;p&gt;But the review phase changed how I interpret those numbers.&lt;/p&gt;

&lt;p&gt;In the first pipeline, "SUCCESS" meant: the migration completed and no immediate TypeScript-level blocker remained. It did &lt;strong&gt;not&lt;/strong&gt; reliably mean that unit tests had run, that runtime behavior was unchanged, or that validation semantics were preserved.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Failure Taxonomy from the Initial Run
&lt;/h2&gt;

&lt;p&gt;The 5 explicit failures were already interesting because they clustered around one API boundary: &lt;strong&gt;&lt;code&gt;SignalFormControl&lt;/code&gt; does not support imperative validator or error mutation.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure 1: &lt;code&gt;CsagentLoginFormComponent&lt;/code&gt;&lt;/strong&gt; — The template had &lt;code&gt;required="true"&lt;/code&gt; on inputs. Angular's &lt;code&gt;RequiredValidator&lt;/code&gt; directive called &lt;code&gt;setValidators()&lt;/code&gt;, which triggered &lt;code&gt;NG01920&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure 2: &lt;code&gt;OrderGuestRegisterFormComponent&lt;/code&gt;&lt;/strong&gt; — Used &lt;code&gt;CustomFormValidators.passwordsMustMatch&lt;/code&gt;, a cross-field validator that called &lt;code&gt;setErrors()&lt;/code&gt; on another control.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failures 3 &amp;amp; 4: &lt;code&gt;DeliveryModeDatePickerComponent&lt;/code&gt; and &lt;code&gt;DateRangeModalComponent&lt;/code&gt;&lt;/strong&gt; — Both routed controls through a shared date picker component using &lt;code&gt;[formControl]&lt;/code&gt;. Internally, Angular's form setup path called &lt;code&gt;setValidators()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure 5: &lt;code&gt;VerifyRegisterVerificationTokenFormComponent&lt;/code&gt;&lt;/strong&gt; — A combination of &lt;code&gt;setErrors()&lt;/code&gt; in error handlers, &lt;code&gt;form.enable()&lt;/code&gt; in tests, and cross-field validators using imperative patterns.&lt;/p&gt;

&lt;p&gt;These failures were useful because they revealed a consistent incompatibility pattern: code that reaches into the control and mutates validation or error state imperatively does not map cleanly to Signal Forms.&lt;/p&gt;

&lt;p&gt;That alone would already have made for a decent migration story. But the later review surfaced a more important lesson.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Review Changed
&lt;/h2&gt;

&lt;p&gt;I ran an adversarial code review against the migration diff, and it found problems that the initial pipeline had missed entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Example 1: Tests never ran.&lt;/strong&gt; In several worktrees, &lt;code&gt;npm install&lt;/code&gt; had not been executed. The &lt;code&gt;@types/jasmine&lt;/code&gt; package was missing, so &lt;code&gt;nx test&lt;/code&gt; could not run at all. The sub-agents noted this as a warning — and then reported SUCCESS anyway, because the TypeScript compiler showed no errors. This meant that a significant number of "successful" migrations were never actually test-verified.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Example 2: Silently changed email validation semantics.&lt;/strong&gt; In &lt;code&gt;AsmCreateCustomerFormComponent&lt;/code&gt;, the agent replaced Spartacus's &lt;code&gt;CustomFormValidators.emailValidator&lt;/code&gt; with Angular's built-in signal &lt;code&gt;email()&lt;/code&gt; validator. But these use different regular expressions. The Spartacus validator accepts addresses like &lt;code&gt;email@[123.123.123.123]&lt;/code&gt; and rejects &lt;code&gt;email@example&lt;/code&gt; — Angular's does the opposite. The migration silently changed which email addresses the form accepts, with no test catching the difference.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Example 3: Validator side effects triggered at wrong time.&lt;/strong&gt; In &lt;code&gt;AsmBindCartComponent&lt;/code&gt;, a custom validator contained a side effect (&lt;code&gt;resetDeeplinkCart()&lt;/code&gt;) that cleared UI state. After migration to &lt;code&gt;SignalFormControl&lt;/code&gt;, the timing of validator execution changed. The review found that this could reset a deeplink alert immediately after it was set — a regression invisible in unit tests.&lt;/p&gt;

&lt;p&gt;These are not mechanical errors. They are semantic changes that require domain knowledge to detect. No import check or template scan would have caught them.&lt;/p&gt;

&lt;p&gt;The honest conclusion: &lt;strong&gt;automated transformation is not the same as validated correctness.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Would Do Differently Today
&lt;/h2&gt;

&lt;p&gt;If I were running this in a real client project, I would use the same core idea — playbook-driven agentic migration — but change the process significantly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Migrate in waves of five.&lt;/strong&gt; Instead of pushing 44 targets through one autonomous pipeline, group them into waves of 4–5 components. Mix each wave deliberately: 2 simple components alongside 2–3 with known edge cases like template &lt;code&gt;required&lt;/code&gt; attributes or custom validators.&lt;/p&gt;

&lt;p&gt;After each wave, the orchestrator writes a summary and &lt;strong&gt;stops&lt;/strong&gt;. The human reviews the results, decides whether to update the playbook, and approves the next wave. This is a hard constraint in the orchestration protocol. Agents that are allowed to cross wave boundaries without human approval will repeat systematic mistakes across dozens of components before anyone notices.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep the worktrees alive.&lt;/strong&gt; Do not discard worktrees after the agent reports success. The worktree is the crime scene. Keep it around so you can inspect the diff, run tests manually, and trace what the agent actually did versus what it claimed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lock unit tests before and after.&lt;/strong&gt; Run the full test suite &lt;em&gt;before&lt;/em&gt; the migration as a baseline. Run it again &lt;em&gt;after&lt;/em&gt;. If tests cannot run at all — missing dependencies, broken setup — the migration gets status &lt;code&gt;ABORT&lt;/code&gt;, not &lt;code&gt;SUCCESS&lt;/code&gt;. Green tests confirm syntactic correctness. They say nothing about semantic equivalence. That is where review starts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Enforce hard constraints outside the prompt.&lt;/strong&gt; The orchestration protocol limits each sub-agent to three test runs after the migration. I enforced this through the prompt. Some agents ignored it. In a production workflow, I would wrap the agent invocation in a deterministic harness — a script that counts test executions externally and terminates the process after the limit. Prompting is a request. A wrapper script is a mechanism. Any constraint that the agent must not violate belongs outside the agent's control.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use a second model for adversarial review.&lt;/strong&gt; A different frontier model reading the migration diff with adversarial intent catches a different class of errors than the model that wrote the code. Tools like &lt;a href="https://github.com/openai/codex-plugin-cc" rel="noopener noreferrer"&gt;codex-plugin-cc&lt;/a&gt; or Windsurf's Codemaps can help here — the principle is what matters: structural overview and adversarial challenge from a separate perspective.&lt;/p&gt;

&lt;p&gt;In this migration, the adversarial review flagged two real issues the pipeline had missed: a validator side effect that could reset active-cart deeplink state on every template subscription, and an email validator replacement that silently changed which addresses the form accepts. Both were invisible to unit tests. Both would have shipped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Human review closes the loop.&lt;/strong&gt; After the adversarial model review, a senior developer reviews the wave. The human decides whether a semantic difference is acceptable, whether a validator change matches business intent, and whether the migration is truly done.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The resulting workflow per wave:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Agent transforms 4–5 components in isolated worktrees&lt;/li&gt;
&lt;li&gt;Unit tests gate syntactic correctness&lt;/li&gt;
&lt;li&gt;Second model challenges semantic equivalence&lt;/li&gt;
&lt;li&gt;Human reviews and decides&lt;/li&gt;
&lt;li&gt;Update the playbook with new edge cases&lt;/li&gt;
&lt;li&gt;Next wave&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Real Takeaway
&lt;/h2&gt;

&lt;p&gt;At scale, the problem is not transformation. &lt;strong&gt;The problem is verification.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The honest result of this experiment: agentic migration works — but only inside a structured process with explicit quality gates. Run it in small waves. Keep the evidence. Let the agent do the mechanical work. Let a second model challenge it. Let a human decide.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Agent transforms. Second model challenges. Human decides.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is the workflow. Not "fully autonomous migration." Not "human reviews everything manually." A layered process where each step catches what the previous one cannot.&lt;/p&gt;

&lt;p&gt;Context engineering &amp;gt; prompt engineering. The hard part was never writing a clever prompt. It was doing the first migrations manually, extracting the right rules, documenting the edge cases, deciding what counts as "done," and designing a process where speed and trust are not in conflict.&lt;/p&gt;

&lt;p&gt;The AI did not invent the strategy. A senior developer defined the migration model, the playbook, and the quality gates. The AI scaled the repetitive part. The review caught what scaling missed.&lt;/p&gt;

&lt;p&gt;That is where the real leverage is: not asking an agent to "do the migration," but designing a process that makes automation fast, inspectable, and safe.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This migration builds on &lt;a href="https://www.angulararchitects.io/blog/migrating-to-angular-signal-forms-interop-with-reactive-forms/" rel="noopener noreferrer"&gt;Manfred Steyer's blog post on SignalFormControl interop&lt;/a&gt;. The initial migration run is at &lt;a href="https://github.com/lutzleonhardt/spartacus/pull/1" rel="noopener noreferrer"&gt;github.com/lutzleonhardt/spartacus/pull/1&lt;/a&gt;. The refined wave-based orchestration protocol and migration logs are on the &lt;a href="https://github.com/lutzleonhardt/spartacus/tree/feature/signal-forms-migration-v2" rel="noopener noreferrer"&gt;signal-forms-migration-v2 branch&lt;/a&gt;, including the &lt;a href="https://github.com/lutzleonhardt/spartacus/blob/feature/signal-forms-migration-v2/SignalFormMigration/goal.md" rel="noopener noreferrer"&gt;full orchestration protocol&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>angular</category>
      <category>ai</category>
      <category>contextengineering</category>
      <category>refactoring</category>
    </item>
  </channel>
</rss>
