<?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: yuelinghuashu</title>
    <description>The latest articles on DEV Community by yuelinghuashu (@yuelinghuashu).</description>
    <link>https://dev.to/yuelinghuashu</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3817907%2F1c2400a0-aa1e-4f4a-987c-7f8bb69ff818.png</url>
      <title>DEV Community: yuelinghuashu</title>
      <link>https://dev.to/yuelinghuashu</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/yuelinghuashu"/>
    <language>en</language>
    <item>
      <title>MCP stdio Protocol's 3 Hidden Traps: When All Unit Tests Pass but the MCP Server Won't Respond</title>
      <dc:creator>yuelinghuashu</dc:creator>
      <pubDate>Sun, 23 Aug 2026 10:22:22 +0000</pubDate>
      <link>https://dev.to/yuelinghuashu/mcp-stdio-protocols-3-hidden-traps-when-all-unit-tests-pass-but-the-mcp-server-wont-respond-53l6</link>
      <guid>https://dev.to/yuelinghuashu/mcp-stdio-protocols-3-hidden-traps-when-all-unit-tests-pass-but-the-mcp-server-wont-respond-53l6</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;This article records a real MCP Server debugging session: every automated test of &lt;code&gt;story-cli&lt;/code&gt; passed, yet in a real environment the MCP Server couldn't respond to any request at all. The root cause turned out to be 3 bugs, each touching low-level details of the Node.js process model and the stdio protocol.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;If you're building an MCP Server (or any long-running process that speaks a stdio protocol), remember three iron rules:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Never call &lt;code&gt;process.exit()&lt;/code&gt; inside a &lt;code&gt;run()&lt;/code&gt; function&lt;/strong&gt; — MCP Servers, &lt;code&gt;--watch&lt;/code&gt; modes, and any other long-running command are not one-shot CLI tools. &lt;code&gt;process.exit()&lt;/code&gt; kills the process before it even starts listening. If you must make an exception, extract the "long-running" abstraction (e.g. &lt;code&gt;isLongRunning&lt;/code&gt;) instead of enumerating specific commands.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never print debug logs to stdout&lt;/strong&gt; — stdout is the MCP protocol channel. Any output that isn't JSON-RPC pollutes the message stream and makes the client unable to parse any response. Diagnostics belong on stderr.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always wait for all async work in the &lt;code&gt;close&lt;/code&gt; event&lt;/strong&gt; — &lt;code&gt;close&lt;/code&gt; only means the input stream closed, not that your callbacks have finished. You need to wait for all in-flight Promises before exiting.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Background: story-cli's MCP Server
&lt;/h2&gt;

&lt;p&gt;First, a quick introduction to the project. &lt;code&gt;story-cli&lt;/code&gt; is a &lt;strong&gt;zero-deployment, Git-native Markdown content management CLI&lt;/strong&gt;. It manages stories/papers/notes/tutorials with a simple directory convention (&lt;code&gt;NN-名称/&lt;/code&gt; — "NN-name/" — containing &lt;code&gt;config.json&lt;/code&gt; + &lt;code&gt;text.md&lt;/code&gt;), auto-generates READMEs, exports EPUB, and is bilingual (Chinese/English).&lt;/p&gt;

&lt;p&gt;On our roadmap, &lt;strong&gt;the MCP Server was a P0-level strategic task&lt;/strong&gt; — the gateway to the AI era. The design principle: &lt;strong&gt;"AI does the thinking, the CLI does the governance."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We exposed 6 tools over JSON-RPC 2.0 over stdio:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;MCP tool&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;scan_stories&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;List all stories and their metadata&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;read_chapter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Read a chapter's content from a story&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;write_chapter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Write body text to a story (atomic write)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;validate&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Validate the config.json of every story&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;build&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Trigger a README rebuild&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;import_json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Bulk-import stories from structured JSON&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The code structure was clean:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/mcp/
├── protocol.ts   # JSON-RPC 2.0 protocol parsing/serialization (pure functions, fully tested)
├── tools.ts      # MCP tool registration (reuses shared logic from core/loader.ts)
└── server.ts     # stdio server startup and request dispatch
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything looked perfect — &lt;strong&gt;until we actually called it&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Symptom: All Automated Tests Green, But Real Requests Get No Response
&lt;/h2&gt;

&lt;p&gt;At the time we had &lt;strong&gt;404 automated tests, 401 passing&lt;/strong&gt;. &lt;code&gt;tests/mcp.test.ts&lt;/code&gt; covered protocol parsing, serialization, tool registration, and every tool handler — &lt;strong&gt;all passing&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So I started the MCP Server against a real story repository and sent a JSON-RPC request through a pipe:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{"jsonrpc":"2.0","id":1,"method":"tools/list"}'&lt;/span&gt; | node bin/index.ts mcp-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;💀 &lt;strong&gt;Empty output.&lt;/strong&gt; No response at all.&lt;/p&gt;

&lt;p&gt;I thought my pipe syntax was wrong. I tried several variations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# approach 1: printf&lt;/span&gt;
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'{"jsonrpc":"2.0","id":1,"method":"tools/list"}\n'&lt;/span&gt; | node bin/index.ts mcp-server

&lt;span class="c"&gt;# approach 2: file redirection&lt;/span&gt;
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'{"jsonrpc":"2.0","id":1,"method":"tools/list"}\n'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/req.json &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; node bin/index.ts mcp-server &amp;lt; /tmp/req.json

&lt;span class="c"&gt;# approach 3: keep stdin open&lt;/span&gt;
&lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'{"jsonrpc":"2.0","id":1,"method":"tools/list"}\n'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;sleep &lt;/span&gt;2&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt; | node bin/index.ts mcp-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Still nothing.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Even weirder: when sending the request through Node.js's &lt;code&gt;spawnSync&lt;/code&gt;, the process exit code was 0 (it looked "successful"), but both stdout and stderr were empty.&lt;/p&gt;

&lt;p&gt;At that moment I realized: &lt;strong&gt;this isn't a calling convention problem — our MCP Server has a bug.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;But 404 tests were green! How could there be a bug?&lt;/p&gt;




&lt;h2&gt;
  
  
  Bug #1: The Ghost of process.exit()
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Root Cause Investigation
&lt;/h3&gt;

&lt;p&gt;I first looked at the CLI entry file &lt;code&gt;bin/index.ts&lt;/code&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="cp"&gt;#!/usr/bin/env node
&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;run&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="s2"&gt;../src/cli.ts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;exitCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;exitCode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The problem was obvious at a glance.&lt;/p&gt;

&lt;p&gt;When a user runs &lt;code&gt;story mcp-server&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;run(process.argv)&lt;/code&gt; is invoked&lt;/li&gt;
&lt;li&gt;Inside &lt;code&gt;run()&lt;/code&gt;, &lt;code&gt;runMcpServer(rootDir)&lt;/code&gt; is called → &lt;code&gt;startMcpServer()&lt;/code&gt; starts listening on stdin&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;run()&lt;/code&gt; returns 0 immediately&lt;/strong&gt; (because &lt;code&gt;startMcpServer()&lt;/code&gt; is an async pattern that "registers listeners and returns" — it doesn't block)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;process.exit(0)&lt;/code&gt; executes immediately&lt;/strong&gt; → the process terminates&lt;/li&gt;
&lt;li&gt;The JSON-RPC request sitting in stdin never gets read by readline&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;The MCP Server died the moment it was born.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The Fix
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cp"&gt;#!/usr/bin/env node
&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;run&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="s2"&gt;../src/cli.ts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;exitCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// An MCP server needs to stay alive and keep listening on stdin.&lt;/span&gt;
&lt;span class="c1"&gt;// Process exit is handled by the close/SIGINT events inside server.ts.&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;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mcp-server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mcp&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;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;exitCode&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;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Note&lt;/strong&gt;: this fix looked fine at the time, but later that same day testing exposed its &lt;strong&gt;limitation&lt;/strong&gt; — see "Bug #1.5: The Same Bug Returns" below.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  The Deeper Lesson
&lt;/h3&gt;

&lt;p&gt;This is the &lt;strong&gt;first trap&lt;/strong&gt; when turning a CLI tool into a service:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Lifecycle&lt;/th&gt;
&lt;th&gt;When to exit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CLI tool&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Exits after the command finishes&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;process.exit(exitCode)&lt;/code&gt; is the right thing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Long-running process&lt;/strong&gt; (MCP Server / daemon)&lt;/td&gt;
&lt;td&gt;Keeps listening for input until EOF/signal&lt;/td&gt;
&lt;td&gt;Exit must be driven by a callback triggered by the &lt;strong&gt;input source&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;process.exit()&lt;/code&gt; is unconditional, immediate, and uninterruptible. It doesn't wait for pending I/O, timers, or Promises. In the MCP Server scenario, that "feature" killed our server outright.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bug #1.5: The Same Bug Returns — process.exit()'s Second Ghost
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Symptom
&lt;/h3&gt;

&lt;p&gt;After fixing Bug #1, I kept testing the MCP Server. That same day, I wanted to check the performance of &lt;code&gt;story build --watch&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;story build &lt;span class="nt"&gt;--watch&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output said 「👀 监听模式已启动，文件变更自动重建...」 ("👀 watch mode started, auto-rebuilding on file changes..."), but &lt;strong&gt;the process exited immediately&lt;/strong&gt; — &lt;code&gt;--watch&lt;/code&gt; mode never actually started watching files.&lt;/p&gt;

&lt;p&gt;I tried modifying a story file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"新内容"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"01-测试故事/text.md"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing happened. The README was never updated.&lt;/p&gt;

&lt;h3&gt;
  
  
  Root Cause: The Whitelist-of-Commands Flaw
&lt;/h3&gt;

&lt;p&gt;I looked back at the fix in &lt;code&gt;bin/index.ts&lt;/code&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mcp-server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mcp&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;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;exitCode&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;What this logic says is: &lt;strong&gt;"for every command except &lt;code&gt;mcp-server&lt;/code&gt; and &lt;code&gt;mcp&lt;/code&gt;, call &lt;code&gt;process.exit()&lt;/code&gt;."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;But &lt;code&gt;build --watch&lt;/code&gt; is also a &lt;strong&gt;long-running process&lt;/strong&gt;! It needs to keep watching files until it receives &lt;code&gt;SIGINT&lt;/code&gt;. Only the two MCP Server commands were exempted — &lt;code&gt;build --watch&lt;/code&gt; wasn't on the whitelist, so it got killed by &lt;code&gt;process.exit()&lt;/code&gt; immediately too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The first MCP Server bug was fixed, and the same ghost reappeared on &lt;code&gt;build --watch&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The Fix: Extract the "Long-Running" Abstraction
&lt;/h3&gt;

&lt;p&gt;The right fix isn't to enumerate even more commands — it's to extract the essential property of "&lt;strong&gt;which commands are long-running&lt;/strong&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="cp"&gt;#!/usr/bin/env node
&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;run&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="s2"&gt;../src/cli.ts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;exitCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// Long-running processes need to stay alive; exit is handled by internal close/SIGINT events:&lt;/span&gt;
&lt;span class="c1"&gt;// - MCP server: keeps listening on stdin; exit is controlled by server.ts's close/SIGINT&lt;/span&gt;
&lt;span class="c1"&gt;// - build --watch: keeps watching file changes; exit is controlled by build.ts's SIGINT&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isLongRunning&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mcp-server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mcp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;build&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;--watch&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;b&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;--watch&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isLongRunning&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;exitCode&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;h3&gt;
  
  
  The Deeper Lesson: Fix Bugs by Extracting an "Abstraction", Not Enumerating "Instances"
&lt;/h3&gt;

&lt;p&gt;This was the &lt;strong&gt;biggest lesson&lt;/strong&gt; of the whole session:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Fix approach&lt;/th&gt;
&lt;th&gt;Code shape&lt;/th&gt;
&lt;th&gt;Problem&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Enumerate instances&lt;/strong&gt; (at the time)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;if (cmd !== "mcp-server" &amp;amp;&amp;amp; cmd !== "mcp")&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Adding one more long-running command means coming back to edit this line&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Extract an abstraction&lt;/strong&gt; (final)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;const isLongRunning = ...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Any new command just expresses its property inside this set&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;When you see an "exclusion list" in code (&lt;code&gt;if (cmd !== "A" &amp;amp;&amp;amp; cmd !== "B")&lt;/code&gt;), it means you're &lt;strong&gt;enumerating specific commands&lt;/strong&gt; instead of expressing the &lt;strong&gt;essential property&lt;/strong&gt; of "which commands are long-running". The moment a new long-running command appears (like &lt;code&gt;--watch&lt;/code&gt;), the same bug returns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Checklist&lt;/strong&gt;: if your CLI is ever going to add a "keep-listening" feature (watch / serve / daemon), check the &lt;code&gt;isLongRunning&lt;/code&gt; list in &lt;code&gt;bin/index.ts&lt;/code&gt; first — it must include the new command.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bug #2: The Fatal Pollution of console.log
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Surprise: After Fixing Bug #1, Some Responses Appeared
&lt;/h3&gt;

&lt;p&gt;After fixing Bug #1, I was pleasantly surprised to see &lt;code&gt;tools/list&lt;/code&gt; respond! But only these responded:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;tools/list&lt;/code&gt; ✅&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;initialize&lt;/code&gt; ✅&lt;/li&gt;
&lt;li&gt;Error responses for unknown tools ✅&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Meanwhile, the &lt;strong&gt;async &lt;code&gt;tools/call&lt;/code&gt; still got no response&lt;/strong&gt; (&lt;code&gt;scan_stories&lt;/code&gt; / &lt;code&gt;read_chapter&lt;/code&gt; / &lt;code&gt;validate&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;I tested &lt;code&gt;scan_stories&lt;/code&gt; on its own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"scan_stories","arguments":{}}}'&lt;/span&gt; | node bin/index.ts mcp-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Still empty.&lt;/p&gt;

&lt;p&gt;I tried a different angle — calling &lt;code&gt;loadStories()&lt;/code&gt; directly in Node:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node &lt;span class="nt"&gt;--experimental-strip-types&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"
import { loadStories } from './src/core/loader.ts';
const { stories } = await loadStories('/tmp/test-story-cli');
console.log('STORIES:', stories.length);
"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;📊 01-测试故事: 自动计算字数为 约 13 字（未写回，使用 --save-counts 持久化）
📊 02-二创故事: 自动计算字数为 约 13 字（未写回，使用 --save-counts 持久化）
📊 03-English-Story: 自动计算字数为 ~7 words（未写回，使用 --save-counts 持久化）
STORIES: 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Found it!&lt;/strong&gt; Inside &lt;code&gt;loadStories()&lt;/code&gt;, a &lt;code&gt;console.log&lt;/code&gt; was printing "auto-computed word count" diagnostic lines.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Can a Single &lt;code&gt;console.log&lt;/code&gt; Kill MCP?
&lt;/h3&gt;

&lt;p&gt;MCP's stdio transport spec says &lt;strong&gt;stdout is the protocol-dedicated channel&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;├── stdin  ← client sends JSON-RPC requests
├── stdout → server returns JSON-RPC responses (protocol-dedicated, the only legal output)
└── stderr → logs/warnings/errors (for humans, not for the protocol)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When an MCP client sends a &lt;code&gt;scan_stories&lt;/code&gt; request, the server calls &lt;code&gt;loadStories()&lt;/code&gt; while handling it, and &lt;code&gt;console.log&lt;/code&gt; dumps a &lt;code&gt;📊 01-测试故事: ...&lt;/code&gt; line to stdout. Now stdout looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;📊 01-测试故事: 自动计算字数为 约 13 字...      ← pollution!
{"jsonrpc":"2.0","id":3,"result":{...}}        ← the real response
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MCP clients (VSCode / Claude Desktop / Cursor) expect every line of stdout to be a valid JSON-RPC message when parsing. The first line isn't JSON at all —&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;the client gives up on parsing, which looks like "no response".&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;As a side note, MCP's stdio transport also has a hard requirement about newlines: &lt;strong&gt;every JSON-RPC message must end with &lt;code&gt;\n&lt;/code&gt;&lt;/strong&gt;. If your server outputs JSON without a trailing newline, the client also fails to parse it. That's why the official MCP docs' Debugging page states it plainly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"Local MCP servers should not log messages to stdout (standard out), as this will interfere with protocol operation."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;— The docs warned us all along; we only truly understood it after stepping on it in a real environment.&lt;/p&gt;

&lt;p&gt;And this kind of bug is especially sneaky:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;In unit tests, &lt;code&gt;scan_stories&lt;/code&gt;'s handler is called directly and nobody parses stdout → tests pass&lt;/li&gt;
&lt;li&gt;In a real environment, the MCP client strictly parses stdout → immediate breakage&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The Fix
&lt;/h3&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;wordCount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;autoWordCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;folder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;story&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;wordCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;saveCounts&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// After&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;wordCount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Use stderr for diagnostics so we don't pollute the stdout channel of the MCP stdio protocol.&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;autoWordCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;folder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;story&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;wordCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;saveCounts&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;The &lt;code&gt;console.log(locale.generatedText(...))&lt;/code&gt; inside &lt;code&gt;loadStoryContentAsync&lt;/code&gt; was changed the same way.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Deeper Lesson
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;In a stdio protocol, stdout is not for logging.&lt;/strong&gt; It's the protocol channel between two processes. Any extra output — even a single seemingly harmless log line — breaks protocol parsing.&lt;/p&gt;

&lt;p&gt;This is a &lt;strong&gt;silent runtime failure&lt;/strong&gt;: the code doesn't throw, tests don't fail, and only real clients mysteriously stop working.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;In an MCP Server, &lt;code&gt;stdout = protocol&lt;/code&gt;, &lt;code&gt;stderr = logs&lt;/code&gt;. Never mix them.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Bug #3: The Async Race on readline close
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Another Surprise
&lt;/h3&gt;

&lt;p&gt;After fixing Bug #2, I thought everything was done. But testing showed &lt;code&gt;tools/call&lt;/code&gt; still responded &lt;strong&gt;intermittently&lt;/strong&gt;: sometimes a response came back, sometimes not.&lt;/p&gt;

&lt;p&gt;I stared at the old code in &lt;code&gt;src/mcp/server.ts&lt;/code&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;function&lt;/span&gt; &lt;span class="nf"&gt;startMcpServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rootDir&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;tools&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RegisteredTool&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createInterface&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stdin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;terminal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="nx"&gt;rl&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="s2"&gt;line&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&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="c1"&gt;// ... parse and handle the request&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;handleRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rootDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tools&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;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;serializeMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="nx"&gt;rl&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="s2"&gt;close&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Wait for stdout to flush before exiting (avoid truncated output)&lt;/span&gt;
    &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In pipe mode (&lt;code&gt;echo '...' | node bin/index.ts mcp-server&lt;/code&gt;), stdin closes immediately after all lines are read, which fires the &lt;code&gt;close&lt;/code&gt; event. &lt;strong&gt;When &lt;code&gt;close&lt;/code&gt; fires, the async &lt;code&gt;await handleRequest()&lt;/code&gt; inside &lt;code&gt;rl.on("line")&lt;/code&gt; hasn't finished yet!&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here's the timing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;t0:  stdin receives the JSON-RPC request line
t1:  rl fires the "line" event and enters the async callback
t2:  the async callback hits await handleRequest() and suspends (shaded area = waiting for the async result)
t3:  stdin finishes reading all lines → rl fires the "close" event
t4:  the "close" callback runs process.stdout.write("", () =&amp;gt; process.exit(0))
t5:  the process exits while await handleRequest() is still suspended → the response is lost forever
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is an &lt;strong&gt;async race&lt;/strong&gt;: &lt;code&gt;close&lt;/code&gt; says "the input stream is closed", but it doesn't wait for your Promises to finish.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Fix
&lt;/h3&gt;

&lt;p&gt;Track all in-flight requests with a &lt;code&gt;pending&lt;/code&gt; Set, and wait for all of them on &lt;code&gt;close&lt;/code&gt; before exiting:&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;function&lt;/span&gt; &lt;span class="nf"&gt;startMcpServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rootDir&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;tools&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RegisteredTool&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createInterface&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stdin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;terminal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&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;pending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

  &lt;span class="nx"&gt;rl&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="s2"&gt;line&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;line&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;trimmed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;trimmed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="na"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JsonRpcRequest&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trimmed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &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="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;JsonRpcErrorCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;InternalError&lt;/span&gt;
      &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;serializeMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;makeErrorResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&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="c1"&gt;// Track in-flight requests so we know the async handler has finished when stdin closes.&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;task&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;handleRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rootDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tools&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;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;serializeMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;})()&lt;/span&gt;
    &lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="nx"&gt;rl&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="s2"&gt;close&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Wait for all in-flight requests to finish, then flush stdout before exiting (avoid truncated output).&lt;/span&gt;
    &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;allSettled&lt;/span&gt;&lt;span class="p"&gt;([...&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;then&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="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&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;process&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="s2"&gt;SIGINT&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;rl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&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;h3&gt;
  
  
  The Deeper Lesson
&lt;/h3&gt;

&lt;p&gt;In Node.js's event loop, &lt;strong&gt;readline's &lt;code&gt;close&lt;/code&gt; event only means "the input stream closed", not "your async callbacks have run"&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This is a universal problem for every stdio protocol server: when stdin hits EOF, you may still have queued Promises. You need to track and wait for them explicitly:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Maintain a set of all in-flight operations&lt;/li&gt;
&lt;li&gt;On &lt;code&gt;close&lt;/code&gt; or &lt;code&gt;SIGINT&lt;/code&gt;, wait with &lt;code&gt;Promise.allSettled&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Only then call &lt;code&gt;process.exit&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The Takeaway: Test Layering
&lt;/h2&gt;

&lt;p&gt;The biggest insight from this session was &lt;strong&gt;the value of layered testing&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test layer&lt;/th&gt;
&lt;th&gt;Our previous coverage&lt;/th&gt;
&lt;th&gt;What it would catch&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Unit tests&lt;/strong&gt; (calling handler functions directly)&lt;/td&gt;
&lt;td&gt;✅ 401 all green&lt;/td&gt;
&lt;td&gt;Can't catch Bug #1 / #2 / #3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Integration tests&lt;/strong&gt; (calling &lt;code&gt;startMcpServer&lt;/code&gt; without a real process)&lt;/td&gt;
&lt;td&gt;❌ none&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;End-to-end tests&lt;/strong&gt; (spawnSync a real child process + real stdin/stdout)&lt;/td&gt;
&lt;td&gt;❌ none&lt;/td&gt;
&lt;td&gt;All 3 bugs at once&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Green unit tests don't mean the system works.&lt;/strong&gt; You need to start the server in a real process, send requests through real pipes, and parse real stdout — because only end-to-end tests can catch problems at the "process lifecycle" and "protocol integrity" levels.&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;// tests/mcp-server.test.ts (the end-to-end test we added)&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sendRequests&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&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;requests&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;input&lt;/span&gt; &lt;span class="o"&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;requests&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="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;\n`&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;spawnSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;execPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;binPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mcp-server&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;cwd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;encoding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf-8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&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="na"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stdout&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stderr&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&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;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;MCP server responds to async tools/call (scan_stories)&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;stderr&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sendRequests&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&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;{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"scan_stories","arguments":{}}}&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="c1"&gt;// stderr must not contain JsonRpcResponse content → guards against console.log/stdout pollution&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;jsonrpc&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

  &lt;span class="c1"&gt;// Split by lines and filter empty lines instead of JSON.parse(stdout.trim()).&lt;/span&gt;
  &lt;span class="c1"&gt;// If stdout contains multiple lines, trim() only strips leading/trailing whitespace&lt;/span&gt;
  &lt;span class="c1"&gt;// and the inner newlines make JSON.parse fail.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stdout&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&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="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;there should be at least one JSON-RPC response&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="c1"&gt;// Take the last line (or look up the line by id when multiple responses are requested)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;{}&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This test starts the MCP Server in a &lt;strong&gt;real child process&lt;/strong&gt;, sends JSON-RPC requests through &lt;strong&gt;real pipes&lt;/strong&gt;, and validates the stdout contents. If anyone ever adds a &lt;code&gt;console.log&lt;/code&gt; to &lt;code&gt;loadStories&lt;/code&gt;, this test fails immediately.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Follow-up (same day)&lt;/strong&gt;: after fixing Bug #1.5, we added an end-to-end regression test for &lt;code&gt;build --watch&lt;/code&gt; (&lt;code&gt;tests/watch.test.ts&lt;/code&gt;) — it uses spawnSync to start a real child process and asserts "the process stays alive" + "the README is rebuilt within 5 seconds of editing a story". If we'd had that test back then, Bug #1.5 would have been caught the day it was fixed, instead of surfacing by accident during a later performance check. That's test layering proven once again: &lt;strong&gt;unit tests can't cover process lifecycle — only end-to-end tests can.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Appendix 1: The Complete Debugging Flow (for Reference)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Create a test repository&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /tmp/test-story-cli &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; /tmp/test-story-cli
node /path/to/story-cli/bin/index.ts init
node /path/to/story-cli/bin/index.ts new &lt;span class="s2"&gt;"测试故事"&lt;/span&gt;

&lt;span class="c"&gt;# 2. Start the MCP Server (find the problem)&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{"jsonrpc":"2.0","id":1,"method":"tools/list"}'&lt;/span&gt; | node /path/to/story-cli/bin/index.ts mcp-server
&lt;span class="c"&gt;# → empty output (Bug #1)&lt;/span&gt;

&lt;span class="c"&gt;# 3. After fixing #1 → tools/list responds, but scan_stories doesn't (Bug #2's stdio pollution)&lt;/span&gt;

&lt;span class="c"&gt;# 4. Verify loadStories behavior in isolation&lt;/span&gt;
node &lt;span class="nt"&gt;--experimental-strip-types&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"
import { loadStories } from './src/core/loader.ts';
await loadStories('/tmp/test-story-cli');
"&lt;/span&gt;
&lt;span class="c"&gt;# → see the 📊 logs appearing on stdout&lt;/span&gt;

&lt;span class="c"&gt;# 5. After fixing #2 → sometimes responds, sometimes not (Bug #3's async race)&lt;/span&gt;

&lt;span class="c"&gt;# 6. Verify repeatedly via the end-to-end test&lt;/span&gt;
node &lt;span class="nt"&gt;--test&lt;/span&gt; tests/mcp-server.test.ts
&lt;span class="c"&gt;# → 7 tests pass&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Appendix 2: The MCP Inspector Debugging Tool
&lt;/h2&gt;

&lt;p&gt;Everything above is "post-mortem" debugging. If you integrate &lt;strong&gt;&lt;a href="https://github.com/modelcontextprotocol/inspector" rel="noopener noreferrer"&gt;MCP Inspector&lt;/a&gt;&lt;/strong&gt; (MCP's official debugging tool) during development, many of these problems can be caught before release:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @modelcontextprotocol/inspector node /path/to/story-cli/bin/index.ts mcp-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MCP Inspector launches a visual web UI that lets you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;View the full tool list / parameter schemas&lt;/strong&gt; (catch registration problems)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Call each tool one by one and inspect the raw responses&lt;/strong&gt; (catch stdout pollution)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the protocol-layer communication logs&lt;/strong&gt; (catch handshake failures / newline issues)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's the "X-ray machine" of MCP Server development — I recommend every MCP Server developer run everything through the Inspector before CI/CD.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;There are also third-party helpers in the community (like &lt;code&gt;mcp-stdio-guard&lt;/code&gt; for catching stdout pollution), but the Inspector as the official tool covers most scenarios.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Appendix 3: Format Drift in the AI Interaction Layer
&lt;/h2&gt;

&lt;p&gt;An MCP Server has to handle not only protocol traps (#1 / #2 / #3) but also traps in the &lt;strong&gt;AI interaction layer&lt;/strong&gt;:&lt;/p&gt;

&lt;p&gt;When &lt;code&gt;create_story&lt;/code&gt; creates a directory, it converts spaces in the title to hyphens (e.g. &lt;code&gt;"AI 创作的故事"&lt;/code&gt; → &lt;code&gt;02-AI-创作的故事&lt;/code&gt;), but the LLM may pass back the original space form (&lt;code&gt;"02-AI 创作的故事"&lt;/code&gt;) — &lt;code&gt;safeFolder&lt;/code&gt; has to match both variants to hit the right directory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Protocol-layer traps and interaction-layer traps — we stepped on all of them the same day.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Summary: Three Iron Rules + One Meta-Lesson
&lt;/h2&gt;

&lt;p&gt;If you only take away three sentences, plus one lesson about fixing bugs themselves:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;process.exit()&lt;/code&gt; belongs only to one-shot CLI commands.&lt;/strong&gt; Long-running processes (MCP Servers / watch mode / daemons) must have their exit controlled by input/signal callbacks. When making exceptions, extract the "long-running" abstraction — don't enumerate specific commands.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;stdout is a protocol channel, not a log channel.&lt;/strong&gt; In any stdio protocol server, non-protocol output is pollution. Diagnostics belong on stderr.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;close&lt;/code&gt; ≠ all work finished.&lt;/strong&gt; Use a &lt;code&gt;pending&lt;/code&gt; Set + &lt;code&gt;Promise.allSettled&lt;/code&gt; to explicitly wait for async work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix bugs by extracting an "abstraction", not by enumerating "instances".&lt;/strong&gt; When you see an exclusion list like &lt;code&gt;if (cmd !== "A" &amp;amp;&amp;amp; cmd !== "B")&lt;/code&gt;, you're enumerating specific commands — when a new long-running command appears, the same bug recurs somewhere new.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What these four problems have in common: &lt;strong&gt;none of them can be caught by unit tests&lt;/strong&gt;; they only surface in real process environments. So — after writing your handlers, don't forget to write a &lt;code&gt;spawnSync&lt;/code&gt; end-to-end test.&lt;/p&gt;

&lt;p&gt;Note that these four iron rules are &lt;strong&gt;language-agnostic&lt;/strong&gt; — whether you build a stdio server in Node.js, Python, or Go, the same four traps exist: &lt;code&gt;process.exit()&lt;/code&gt; / stdout pollution / un-awaited async work / enumerating instead of abstracting. This article uses Node.js only because our project happens to be on the Node stack.&lt;/p&gt;




&lt;p&gt;This article is based on a real debugging session from the story-cli project. Repository: &lt;a href="https://github.com/yuelinghuashu/story-cli" rel="noopener noreferrer"&gt;story-cli&lt;/a&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>typescript</category>
      <category>llm</category>
      <category>node</category>
    </item>
    <item>
      <title>Flutter Streaming UI: How the Typewriter Experience of AI Replies Is Built</title>
      <dc:creator>yuelinghuashu</dc:creator>
      <pubDate>Tue, 18 Aug 2026 12:44:59 +0000</pubDate>
      <link>https://dev.to/yuelinghuashu/flutter-streaming-ui-how-the-typewriter-experience-of-ai-replies-is-built-371l</link>
      <guid>https://dev.to/yuelinghuashu/flutter-streaming-ui-how-the-typewriter-experience-of-ai-replies-is-built-371l</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;The typewriter effect looks simple: characters appear one by one. But behind "skip animation", "no truncation", and "no performance regression" lies a whole set of engineering decisions.&lt;/p&gt;

&lt;p&gt;The implementation in this article is Flutter/Dart based, but the core semantic decisions — "skip ≠ abort" and "buffer and batch" — are &lt;strong&gt;framework-agnostic&lt;/strong&gt;: Web's EventSource, and native/RN SSE clients, face the same choices.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Prologue: a "skip typewriter" button that kept breaking
&lt;/h2&gt;

&lt;p&gt;In an AI narrative app (where the user influences an AI-driven interactive story by entering fate instructions), I built a "⏩ skip typewriter" button — users click it to see the full AI reply immediately instead of waiting for the text to appear character by character.&lt;/p&gt;

&lt;p&gt;The button went through three stages in the dev log:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;V1&lt;/strong&gt;: clicking does nothing — the callback fires, but the user experiences no change&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;V2&lt;/strong&gt;: clicking truncates the content — the animation is gone, but the reply is incomplete too&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Final&lt;/strong&gt;: clicking reveals the partial text immediately, while the LLM keeps generating the full reply in the background, which appears all at once when done&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Behind these three versions lie the three most common pitfalls in "streaming UI". This article breaks them down.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. From SSE to screen: the streaming rendering pipeline
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why the LLM "pops" text out
&lt;/h3&gt;

&lt;p&gt;The LLM's reply comes back in chunks via HTTP SSE (Server-Sent Events). A typical chunk looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;data:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"choices"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"delta"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Mephistopheles appears"&lt;/span&gt;&lt;span class="p"&gt;}}]}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;data:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"choices"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"delta"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"at the study door."&lt;/span&gt;&lt;span class="p"&gt;}}]}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;data:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;DONE&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The interval between chunks is determined by the model's generation speed — tens of milliseconds when fast, possibly a full second when slow. That "character-by-character appearance" is what the user perceives as the typewriter animation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why you can't update the UI on every chunk
&lt;/h3&gt;

&lt;p&gt;If you trigger a state update on every chunk, a reply of a few hundred characters can cause dozens or hundreds of UI rebuilds, which visibly lags on longer texts. So you need &lt;strong&gt;batching&lt;/strong&gt; — reading the chunks into a buffer and committing them together:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;_buffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;StringBuffer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;Timer&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;_timer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;onChunk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;_buffer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// Batch chunks within a 50ms window; flush once when the window closes&lt;/span&gt;
  &lt;span class="n"&gt;_timer&lt;/span&gt; &lt;span class="o"&gt;??=&lt;/span&gt; &lt;span class="n"&gt;Timer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;milliseconds:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;_timer&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="na"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="n"&gt;_timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;copyWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;content:&lt;/span&gt; &lt;span class="n"&gt;_buffer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toString&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;A 50ms batching window reduces "dozens of notifications" to "one notification per few hundred milliseconds". This number is not arbitrary: the human eye perceives smooth animation at 60fps and above (~16.6ms/frame), but the &lt;strong&gt;typewriter's "popping" interval is usually 100-300ms&lt;/strong&gt; — a 50ms merging window is far smaller than the typewriter's perceptual granularity, has zero visible impact, yet effectively batches high-frequency chunk frames and significantly reduces UI overhead.&lt;/p&gt;

&lt;h3&gt;
  
  
  StringBuffer: avoiding O(n²) concatenation
&lt;/h3&gt;

&lt;p&gt;If you store the complete content every time and then do &lt;code&gt;state.streamingContent = current + pending&lt;/code&gt;, longer replies cause repeated full concatenation — &lt;strong&gt;O(n²) complexity&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The right approach is to maintain a &lt;code&gt;StringBuffer&lt;/code&gt; accumulator and build the result once via &lt;code&gt;toString()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;_streamingBuffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;StringBuffer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;applyAndGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;_streamingBuffer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;_streamingBuffer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// O(total length), not O(n²)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. The correct semantics of "skip animation": silent accumulation ≠ aborting generation
&lt;/h2&gt;

&lt;p&gt;This is the most subtle point in the whole streaming experience, and the easiest to get backwards.&lt;/p&gt;

&lt;h3&gt;
  
  
  V1 mistake: only skipping the UI batching window
&lt;/h3&gt;

&lt;p&gt;My initial implementation was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;revealStreaming&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;_revealInstant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;// subsequent chunks skip the 50ms batching window&lt;/span&gt;
  &lt;span class="n"&gt;_flushStreamBuffer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;     &lt;span class="c1"&gt;// immediately commit buffered content&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After &lt;code&gt;_revealInstant = true&lt;/code&gt;, subsequent chunks no longer go through the batching window and are committed straight to the UI. &lt;strong&gt;But this doesn't solve the user's problem at all&lt;/strong&gt; — because the real source of the typewriter effect is the &lt;strong&gt;LLM returning chunks one by one&lt;/strong&gt;, not the UI's 50ms batching window.&lt;/p&gt;

&lt;p&gt;The LLM still spends seconds emitting the whole reply. What the user sees: after clicking, text still appears one character at a time. "Skip typewriter" became "skip batching", which is equivalent to doing nothing from the user's perspective.&lt;/p&gt;

&lt;h3&gt;
  
  
  V2 mistake: triggering the cancel signal
&lt;/h3&gt;

&lt;p&gt;To make "skip" actually effective, I added a cancel signal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;revealStreaming&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;_revealInstant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;_flushStreamBuffer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="n"&gt;cancelGeneration&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// make the LLM stop generating&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This time it was indeed "immediate" — because the LLM was aborted, the accumulated content was returned as the final reply together with &lt;code&gt;[DONE]&lt;/code&gt;, and all "content not yet generated" was lost forever.&lt;/p&gt;

&lt;p&gt;The user's report: "after clicking skip, the content is truncated, and the rest never shows up."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The problem&lt;/strong&gt;: I interpreted "skip the animation" as "skip the generation". But the user only wanted "not to watch the animation", not "to stop the AI from finishing its response".&lt;/p&gt;

&lt;h3&gt;
  
  
  The right answer: silent accumulation, let the LLM finish
&lt;/h3&gt;

&lt;p&gt;The correct semantics of "skip animation" is: &lt;strong&gt;stop the UI from updating character by character&lt;/strong&gt; — the cursor disappears and text no longer pops in one at a time — but &lt;strong&gt;the LLM continues generating fully in the background&lt;/strong&gt;, and when done, the complete reply is written into the message list all at once.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;_appendStreamChunk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&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="n"&gt;_revealInstant&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Skip typewriter: silently ignore subsequent chunks, no UI rebuilds&lt;/span&gt;
    &lt;span class="c1"&gt;// The complete content is written by ReplySucceeded once finished&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;_streaming&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_applyStreamChunk&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;After clicking ⏩:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Immediately flush&lt;/strong&gt; the content received so far into the message bubble (the user instantly sees the existing partial text)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;_revealInstant = true&lt;/code&gt;, subsequent chunks are &lt;strong&gt;silently ignored&lt;/strong&gt; — the UI no longer updates character by character&lt;/li&gt;
&lt;li&gt;The LLM finishes generating in the background; &lt;code&gt;ReplySucceeded&lt;/code&gt; carries the &lt;strong&gt;complete &lt;code&gt;reply&lt;/code&gt;&lt;/strong&gt; and writes it into the message list all at once&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The key distinction, in one sentence:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Stopping character-by-character UI updates ≠ aborting LLM generation&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  A UX gap worth acknowledging
&lt;/h3&gt;

&lt;p&gt;Silent accumulation brings an extreme scenario: if the model is very slow and the reply is very long, by the time the user clicks ⏩ the LLM may have only generated the first ten characters, with the remaining 2000 queued up — there can be a &lt;strong&gt;10-20 second window with no UI feedback at all&lt;/strong&gt; between clicking skip and &lt;code&gt;ReplySucceeded&lt;/code&gt;. At that point, "cursor gone" is not enough; the user may think the app has frozen.&lt;/p&gt;

&lt;p&gt;A product-level solution: in skip mode, show a very subtle "generating in background…" indicator (low visual distraction, but lets the user know the system is still working). This is the balance between "stabilizing immediate experience" and "protecting user patience" — behind every technical decision, there is often an experience decision as well.&lt;/p&gt;

&lt;h3&gt;
  
  
  A real fallback flaw
&lt;/h3&gt;

&lt;p&gt;Silent accumulation has a risk that must be acknowledged: &lt;strong&gt;if the LLM generation fails (network timeout, server 5xx), &lt;code&gt;ReplySucceeded&lt;/code&gt; never arrives&lt;/strong&gt;. At that point you can't be stuck on "loading" forever.&lt;/p&gt;

&lt;p&gt;My approach relies on the global fallback of the generation flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;debugPrint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'generation failed: &lt;/span&gt;&lt;span class="si"&gt;$e&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="si"&gt;$st&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="n"&gt;_dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;GenerationFailed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;narrativeErrorGenFailed&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// reset generation state&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;endGeneration&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// release re-entry guard, allow next message&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But this exposes a real UX flaw: &lt;code&gt;GenerationFailed&lt;/code&gt; clears &lt;code&gt;streamingContent&lt;/code&gt; — &lt;strong&gt;the partial content the user saw after clicking ⏩ disappears&lt;/strong&gt;, leaving only an error message.&lt;/p&gt;

&lt;p&gt;This can actually be &lt;strong&gt;fixed immediately&lt;/strong&gt;, not deferred to the next version. Since &lt;code&gt;_revealInstant = true&lt;/code&gt; means "silently ignore" subsequent chunks, the fallback on failure should be: &lt;strong&gt;if already in skip mode, flush the accumulated buffer first and archive the generated part as an "incomplete reply" before marking the failure&lt;/strong&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: &lt;code&gt;_flushStreamBuffer()&lt;/code&gt; commits the buffered content to the UI (i.e., &lt;code&gt;streamingContent&lt;/code&gt;); &lt;code&gt;_streamingContent&lt;/code&gt; is the streaming text currently displayed. In the failure fallback, archive this part as an "incomplete reply" first, then set the error.&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;debugPrint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'generation failed: &lt;/span&gt;&lt;span class="si"&gt;$e&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="si"&gt;$st&lt;/span&gt;&lt;span class="s"&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="n"&gt;_revealInstant&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Skip mode: archive the accumulated content as an "incomplete reply"&lt;/span&gt;
    &lt;span class="c1"&gt;// to preserve what the user has seen.&lt;/span&gt;
    &lt;span class="c1"&gt;// _flushStreamBuffer() commits the buffer → _streamingContent is the displayed text&lt;/span&gt;
    &lt;span class="n"&gt;_flushStreamBuffer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;partial&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_streamingContent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;_dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ReplySucceeded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nl"&gt;reply:&lt;/span&gt; &lt;span class="n"&gt;partial&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="c1"&gt;// keep current state/memories...&lt;/span&gt;
    &lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="n"&gt;_dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;GenerationFailed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;narrativeErrorGenFailed&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;endGeneration&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;This only touches the failure branch, not the main framework, and achieves "even if generation fails after skip, the content the user already saw is not lost".&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Data consistency: interrupted streaming never loses content
&lt;/h2&gt;

&lt;p&gt;Mephisto has two "interrupt generation" entry points:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;⏹ Stop&lt;/strong&gt;: the user explicitly wants to interrupt, keeping the generated content&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;⏩ Skip&lt;/strong&gt;: the user doesn't want to watch the animation, but wants the LLM to finish&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They share the same "cooperative cancellation" signal underneath — when &lt;code&gt;LlmClient&lt;/code&gt; receives the cancel signal, it stops reading at the next SSE data line and returns &lt;strong&gt;the complete accumulated content&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Inside LlmClient's SSE loop&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="p"&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="n"&gt;isCancelled&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// cooperative: stop at the next data line&lt;/span&gt;
  &lt;span class="c1"&gt;// ...parse delta, write fullContent, call onChunk&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fullContent&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// return accumulated content, not throw&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;⏹ &lt;code&gt;stopGenerating()&lt;/code&gt;: trigger cancel &lt;code&gt;+&lt;/code&gt; flush displayed content → generation flow wraps up normally with "accumulated content"&lt;/li&gt;
&lt;li&gt;⏩ &lt;code&gt;revealStreaming()&lt;/code&gt;: only set &lt;code&gt;_revealInstant&lt;/code&gt;, &lt;strong&gt;don't trigger cancel&lt;/strong&gt; → LLM keeps generating, final full commit&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both guarantee &lt;strong&gt;no loss of existing data&lt;/strong&gt; through "return accumulated content after cancel"; the difference is "whether the LLM continues".&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Terminology clarification&lt;/strong&gt;: the "cancel signal" here is the &lt;strong&gt;client actively stopping reading the SSE stream&lt;/strong&gt; (breaking at the next data line), i.e., the client disconnects its reception — &lt;strong&gt;the server/model may still be generating in the background and still consuming compute&lt;/strong&gt;. It is not an "instruct the server to abort" operation; LLM APIs usually don't support client-side cancellation. This matters: when you hit "stop", you simply stop receiving; the server-side cost doesn't end immediately.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  4. How to test streaming scenarios
&lt;/h2&gt;

&lt;p&gt;Streaming UI is among the trickiest things to test: timing-sensitive, involves real network IO, and is mixed with buffered batching. Fortunately, in Flutter tests you can use &lt;code&gt;MockClient.streaming&lt;/code&gt; + &lt;code&gt;StreamController&lt;/code&gt; to control the timing precisely.&lt;/p&gt;

&lt;h3&gt;
  
  
  Controllable chunked stream
&lt;/h3&gt;

&lt;p&gt;The core idea: use a &lt;code&gt;StreamController&lt;/code&gt; to push SSE chunks manually, injecting the "user clicks ⏩" call at any point in time and verifying subsequent behavior.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;controller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;StreamController&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;();&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;streamingClient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;MockClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;streaming&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bodyStream&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;StreamedResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;controller&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;headers:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;'content-type'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;'text/event-stream; charset=utf-8'&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;h3&gt;
  
  
  The three key steps for testing "skip animation without truncation"
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 1. First chunk arrives&lt;/span&gt;
&lt;span class="n"&gt;controller&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;utf8&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sseChunk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'Mephistopheles appears at the study door.'&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;delayed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;milliseconds:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;// 2. User clicks ⏩ skip typewriter&lt;/span&gt;
&lt;span class="n"&gt;notifier&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;revealStreaming&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;streamingContent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'Mephistopheles appears at the study door'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;// 3. More content keeps arriving (reveal must not abort generation)&lt;/span&gt;
&lt;span class="n"&gt;controller&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;utf8&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sseChunk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'He quietly proposes a bargain.'&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;span class="n"&gt;controller&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;utf8&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'data: [DONE]&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;controller&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// waitForGeneration: polls state.isGenerating until it resets,&lt;/span&gt;
&lt;span class="c1"&gt;// ensuring the async generation flow (including auto-save) fully wraps up before asserting&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;waitForGeneration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;container&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Final reply = complete content (not truncated)&lt;/span&gt;
&lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;last&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;'Mephistopheles appears at the study door. He quietly proposes a bargain.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This test verifies three key invariants at once:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;After clicking ⏩, &lt;strong&gt;the existing content is displayed immediately&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;After reveal, subsequent chunks &lt;strong&gt;are still received&lt;/strong&gt; (not aborted)&lt;/li&gt;
&lt;li&gt;When generation ends, the message list contains the &lt;strong&gt;fully concatenated content&lt;/strong&gt; (not truncated)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If this test had been written first, the "cancel signal causes truncation" bug in V2 would have surfaced in the test immediately — instead of waiting for a user report.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The "smoothness" of streaming UI is not luck; it comes from a clear set of semantic decisions. Three takeaways:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Batching is not animation&lt;/strong&gt;: the animation comes from the LLM's chunk-by-chunk output; batching is only an optimization to reduce UI rebuilds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skip is not abort&lt;/strong&gt;: the semantics of skipping the animation is "stop character-by-character updates", not "stop generation" — let the LLM finish, then fill in everything at once&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Interruptions must be testable&lt;/strong&gt;: use &lt;code&gt;StreamController&lt;/code&gt; to simulate a chunked stream controllably, turning the most fragile timing scenario — "interrupting mid-stream" — into a repeatable test&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Glossary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Term&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SSE (Server-Sent Events)&lt;/td&gt;
&lt;td&gt;HTTP long-connection server push protocol, the carrier for LLM token/chunk streaming&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Batching (Debounce)&lt;/td&gt;
&lt;td&gt;Merging multiple arrivals within a time window into one commit, reducing UI rebuild count&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;StringBuffer&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Dart's mutable string accumulator; &lt;code&gt;toString()&lt;/code&gt; builds once, avoiding O(n²) concatenation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;StreamController&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Dart's controllable stream controller; in tests, push chunks manually to simulate server timing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cooperative cancellation&lt;/td&gt;
&lt;td&gt;The mechanism where the client stops reading the SSE stream: break at the next data line rather than aborting the underlying connection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MockClient.streaming&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The streaming response mock from &lt;code&gt;package:http/testing&lt;/code&gt;, used with &lt;code&gt;StreamedResponse&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;p&gt;Project: &lt;a href="https://github.com/yuelinghuashu/mephisto-gui" rel="noopener noreferrer"&gt;Mephisto&lt;/a&gt; (MIT License)&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>dart</category>
      <category>streaming</category>
      <category>ai</category>
    </item>
    <item>
      <title>Flutter Desktop Input Design — Where Does the Enter Key Actually Go?</title>
      <dc:creator>yuelinghuashu</dc:creator>
      <pubDate>Sat, 15 Aug 2026 09:37:15 +0000</pubDate>
      <link>https://dev.to/yuelinghuashu/flutter-desktop-input-design-where-does-the-enter-key-actually-go-1d0i</link>
      <guid>https://dev.to/yuelinghuashu/flutter-desktop-input-design-where-does-the-enter-key-actually-go-1d0i</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;From "pressing Enter does nothing" to "the Enter on the arrow-key area still inserts a newline", these desktop input field pitfalls ultimately trace back to a Focus model problem.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Prologue: a bug reported by a user
&lt;/h2&gt;

&lt;p&gt;"After typing in the input field, the first Enter inserts a newline, and only the second one actually submits."&lt;/p&gt;

&lt;p&gt;This is an extremely representative problem in Flutter desktop development: &lt;strong&gt;mobile input logic cannot be directly transplanted to desktop&lt;/strong&gt;. On mobile, the "send" button on the soft keyboard naturally triggers &lt;code&gt;onSubmitted&lt;/code&gt;; on desktop, there's a physical keyboard where Enter, Shift, and arrow keys are independent visible physical events whose semantics must be defined by the developer.&lt;/p&gt;

&lt;p&gt;(Background: this input field comes from an AI-driven interactive narrative app, where the user enters instructions as a "Fate" and the AI unfolds the story. The input field and the streaming reply are the two core interaction entry points of this app, so their details deserve careful polishing.)&lt;/p&gt;

&lt;p&gt;My initial approach was very "intuitive": wrap the TextField with an outer &lt;code&gt;Focus&lt;/code&gt; and intercept the Enter key inside it. That produced the exact bug at the start of this article — the first Enter became a newline.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The real propagation path of keyboard events
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why the intuition is wrong
&lt;/h3&gt;

&lt;p&gt;Most people (including me) write it like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;Expanded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nl"&gt;child:&lt;/span&gt; &lt;span class="n"&gt;Focus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nl"&gt;onKeyEvent:&lt;/span&gt; &lt;span class="n"&gt;_handleKeyEvent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// outer Focus intercepts&lt;/span&gt;
    &lt;span class="nl"&gt;child:&lt;/span&gt; &lt;span class="n"&gt;TextField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nl"&gt;focusNode:&lt;/span&gt; &lt;span class="n"&gt;_focusNode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;maxLines:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// desktop: multiline&lt;/span&gt;
      &lt;span class="nl"&gt;textInputAction:&lt;/span&gt; &lt;span class="n"&gt;TextInputAction&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;newline&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It looks like &lt;code&gt;onKeyEvent&lt;/code&gt; should receive every key press. But in reality, &lt;strong&gt;a keyboard event first reaches the node that actually has focus&lt;/strong&gt; — the &lt;code&gt;EditableText&lt;/code&gt; inside the TextField — not the &lt;code&gt;Focus&lt;/code&gt; wrapper you put around it.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;maxLines: null&lt;/code&gt; + &lt;code&gt;textInputAction: newline&lt;/code&gt;, when &lt;code&gt;EditableText&lt;/code&gt; receives Enter it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Inserts a newline internally&lt;/li&gt;
&lt;li&gt;Returns &lt;code&gt;KeyEventResult.handled&lt;/code&gt; (marking the event as consumed)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Once an event is &lt;code&gt;handled&lt;/code&gt;, it &lt;strong&gt;no longer bubbles up&lt;/strong&gt; to the outer &lt;code&gt;Focus&lt;/code&gt;. Your &lt;code&gt;_handleKeyEvent&lt;/code&gt; never receives the event and obviously can't intercept it. The first Enter becomes a newline; the second one "happens" to submit.&lt;/p&gt;

&lt;p&gt;The word "bubbling" naturally makes frontend readers think of &lt;strong&gt;JS DOM event bubbling&lt;/strong&gt;. The two do share a commonality: the event starts at a point, propagates up a chain, and can be stopped midway if consumed. But the details of "propagation path" and "midway stop" are &lt;strong&gt;completely different&lt;/strong&gt;:&lt;/p&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;JS DOM events&lt;/th&gt;
&lt;th&gt;Flutter keyboard events&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What determines the propagation path&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;DOM tree&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Focus Chain&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Is visual containment = propagation path?&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No (focus relation ≠ containment relation)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Propagation direction&lt;/td&gt;
&lt;td&gt;capture down → target → bubble up&lt;/td&gt;
&lt;td&gt;focus node → up the focus chain&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Midway stop&lt;/td&gt;
&lt;td&gt;&lt;code&gt;stopPropagation()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;return &lt;code&gt;KeyEventResult.handled&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Key difference&lt;/td&gt;
&lt;td&gt;any DOM ancestor receives the event&lt;/td&gt;
&lt;td&gt;inner node can &lt;strong&gt;consume early&lt;/strong&gt;; the event is cut off before bubbling reaches ancestors&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In JS, an outer &lt;code&gt;div&lt;/code&gt; wrapping an inner &lt;code&gt;input&lt;/code&gt; &lt;strong&gt;always&lt;/strong&gt; receives the event — visual containment is the propagation path, so intercepting at the outer layer is natural. But in Flutter, &lt;strong&gt;the event travels along the focus chain, not the widget containment tree&lt;/strong&gt;: the &lt;code&gt;EditableText&lt;/code&gt; inside the TextField is the current focus node, and the event starts there and propagates up the focus chain. The outer &lt;code&gt;Focus&lt;/code&gt;, as an ancestor of &lt;code&gt;EditableText&lt;/code&gt;, &lt;strong&gt;is indeed on the focus chain&lt;/strong&gt; — but the problem is that &lt;code&gt;EditableText&lt;/code&gt; returns &lt;code&gt;KeyEventResult.handled&lt;/code&gt; when handling Enter, so &lt;strong&gt;the event bubble is cut off before it reaches the outer &lt;code&gt;Focus&lt;/code&gt;&lt;/strong&gt;. That's the real reason "wrapping the TextField with an outer Focus fails to intercept Enter": it's not that the node is off the chain, but that the event is already consumed before it arrives.&lt;/p&gt;

&lt;h3&gt;
  
  
  The correct mounting point
&lt;/h3&gt;

&lt;p&gt;Bind the keyboard event handler &lt;strong&gt;directly to the TextField's own &lt;code&gt;FocusNode&lt;/code&gt;&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;late&lt;/span&gt; &lt;span class="n"&gt;FocusNode&lt;/span&gt; &lt;span class="n"&gt;_focusNode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nd"&gt;@override&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;initState&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;initState&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="n"&gt;_focusNode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;FocusNode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;onKeyEvent:&lt;/span&gt; &lt;span class="n"&gt;_handleKeyEvent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// No outer Focus wrapper needed in build&lt;/span&gt;
&lt;span class="n"&gt;Expanded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nl"&gt;child:&lt;/span&gt; &lt;span class="n"&gt;TextField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nl"&gt;focusNode:&lt;/span&gt; &lt;span class="n"&gt;_focusNode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&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;This way &lt;code&gt;_handleKeyEvent&lt;/code&gt; runs before &lt;code&gt;EditableText&lt;/code&gt; processes the event. Enter (without Shift) returns &lt;code&gt;handled&lt;/code&gt; to prevent the newline and send; Shift+Enter returns &lt;code&gt;ignored&lt;/code&gt; to let the TextField insert a newline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson&lt;/strong&gt;: in Flutter, "wrapping a widget" is not the same as "being able to intercept keyboard events from descendant widgets". If you want to intercept something, mount the listener on the node the event actually passes through.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The same "Enter", two key codes
&lt;/h2&gt;

&lt;p&gt;After fixing the "first Enter creates a newline" bug, another user reported: "&lt;strong&gt;the Enter on the arrow-key area still inserts a newline&lt;/strong&gt;."&lt;/p&gt;

&lt;p&gt;Same Enter key — why does the letter area work but the arrow-key area doesn't?&lt;/p&gt;

&lt;p&gt;Because in Flutter, these two "Enters" are &lt;strong&gt;different key codes&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Key&lt;/th&gt;
&lt;th&gt;&lt;code&gt;LogicalKeyboardKey&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Main keyboard Enter&lt;/td&gt;
&lt;td&gt;&lt;code&gt;enter&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enter above the arrow-key area / on the numpad&lt;/td&gt;
&lt;td&gt;&lt;code&gt;numpadEnter&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And my check was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;logicalKey&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;LogicalKeyboardKey&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;enter&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;numpadEnter&lt;/code&gt; doesn't match, so &lt;code&gt;_handleKeyEvent&lt;/code&gt; returns &lt;code&gt;ignored&lt;/code&gt; for it, the event passes through to the TextField, and a newline is inserted as usual.&lt;/p&gt;

&lt;p&gt;The fix is simply to match both key codes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;logicalKey&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;LogicalKeyboardKey&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;enter&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;logicalKey&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;LogicalKeyboardKey&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;numpadEnter&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;strong&gt;Lesson&lt;/strong&gt;: a desktop keyboard is not "one key = one semantic". The same physical action (pressing Enter) can map to different key codes in different areas — especially when matching keys, think about the existence of areas beyond the main keyboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The Shift+Enter semantics must not be lost
&lt;/h2&gt;

&lt;p&gt;Desktop has a common convention: &lt;strong&gt;Enter to send, Shift+Enter for a newline&lt;/strong&gt;. This is nearly universal in chat apps, terminals, and editors.&lt;/p&gt;

&lt;p&gt;The implementation detail is that Shift+Enter should &lt;strong&gt;pass through&lt;/strong&gt; to &lt;code&gt;EditableText&lt;/code&gt; rather than constructing a newline yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HardwareKeyboard&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;instance&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isShiftPressed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Shift+Enter → let the TextField insert a newline&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;KeyEventResult&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ignored&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;Why is "passing through" more reliable than "constructing a newline yourself"?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Letting EditableText handle the newline correctly maintains the cursor position, selection, and IME composition state&lt;/li&gt;
&lt;li&gt;Pushing &lt;code&gt;\n&lt;/code&gt; into the controller yourself can corrupt the cursor context during input method (e.g., Chinese pinyin) composition&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Shift state check uses &lt;code&gt;HardwareKeyboard.instance.isShiftPressed&lt;/code&gt; — the global hardware keyboard state query Flutter currently provides. Worth noting: &lt;code&gt;KeyDownEvent&lt;/code&gt; itself &lt;strong&gt;does not carry modifier state&lt;/strong&gt; (&lt;code&gt;KeyEvent&lt;/code&gt; only has fields like &lt;code&gt;physicalKey&lt;/code&gt; / &lt;code&gt;logicalKey&lt;/code&gt; / &lt;code&gt;character&lt;/code&gt; / &lt;code&gt;timeStamp&lt;/code&gt;, no &lt;code&gt;modifiers&lt;/code&gt;), so checking Shift must rely on the &lt;code&gt;HardwareKeyboard&lt;/code&gt; global singleton.&lt;/p&gt;

&lt;p&gt;The global state has a boundary worth noticing: it reflects the hardware state "&lt;strong&gt;right now&lt;/strong&gt;", not "at the instant of that event". In scenarios like rapid successive key presses, or releasing a modifier key right after a dialog steals focus, it could theoretically read a lagged state. Flutter's future &lt;code&gt;KeyEvent&lt;/code&gt; API direction is to have events carry a &lt;code&gt;modifiers&lt;/code&gt; snapshot (like Web's &lt;code&gt;KeyboardEvent&lt;/code&gt;), at which point event-level checks will be more reliable than global state — but in the current Flutter version, &lt;code&gt;HardwareKeyboard.instance.isShiftPressed&lt;/code&gt; is the standard, usable approach.&lt;/p&gt;

&lt;p&gt;Also worth mentioning: here you &lt;strong&gt;neither need nor should&lt;/strong&gt; build your own "modifier state cache" (manually setting true on KeyDown and false on KeyUp) — because &lt;code&gt;HardwareKeyboard&lt;/code&gt; itself is a global state maintained by the Flutter framework: it keeps its state strictly consistent with the event stream through KeyDown/KeyUp events plus a synthesized-event synchronization mechanism. For example, when focus switching causes a Shift release event to be lost, Flutter injects a synthesized event to correct the state. A hand-rolled cache is actually more likely to fail in edge cases like focus switching and synthesized events — that's exactly the complexity the framework handles for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Input history: Widget lifecycle ≠ data lifecycle
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Problem: ↑ / ↓ stops working after leaving and re-entering
&lt;/h3&gt;

&lt;p&gt;After adding the "↑ / ↓ to recall the last 5 inputs" shortcuts on desktop, the first round of testing was fine — send a few messages, press ↑ to recall them one by one. But a user said: "after leaving and re-entering, the ↑ key doesn't work."&lt;/p&gt;

&lt;p&gt;The reason is simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;_InputBarState&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;InputBar&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_history&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt; &lt;span class="c1"&gt;// ← pure memory, cleared when the widget is destroyed&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The input history lives in &lt;code&gt;State&lt;/code&gt;. While playing, &lt;code&gt;InputBar&lt;/code&gt; stays alive and history accumulates normally; once you leave the narrative page and &lt;code&gt;InputBar&lt;/code&gt; is destroyed and rebuilt, &lt;code&gt;_history&lt;/code&gt; is reset to empty.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Widget lifecycle ≠ data lifecycle&lt;/strong&gt;. &lt;code&gt;State&lt;/code&gt; exists for "UI state" (scroll position, current input-box content), not for "user data" (input history that must survive across sessions). Putting persistent data in &lt;code&gt;State&lt;/code&gt; is an anti-pattern.&lt;/p&gt;

&lt;h3&gt;
  
  
  The right approach: state lifting + persistence
&lt;/h3&gt;

&lt;p&gt;Following Riverpod's &lt;code&gt;Notifier&lt;/code&gt; pattern, lift the input history to a global Provider and persist it with &lt;code&gt;SharedPreferences&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;InputHistoryNotifier&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;Notifier&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;maxHistory&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;'mephisto_input_history'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

  &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;async&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="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isNotEmpty&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;last&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;text&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;// adjacent dedup&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[..&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&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="n"&gt;next&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;maxHistory&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;next&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;removeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;next&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;prefs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;SharedPreferences&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getInstance&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;prefs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jsonEncode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;next&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;// An optional initializer: restore from persistence&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After changing &lt;code&gt;InputBar&lt;/code&gt; from &lt;code&gt;State&lt;/code&gt; to &lt;code&gt;ConsumerState&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;_history&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inputHistoryProvider&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Write to the Provider on send, read from the Provider after rebuild — history survives across sessions.&lt;/p&gt;

&lt;h3&gt;
  
  
  A design decision: global sharing, or per-contract isolation?
&lt;/h3&gt;

&lt;p&gt;A user raised a very reasonable concern: "if I have multiple sub-versions in progress, are all their histories saved? Does it affect performance?"&lt;/p&gt;

&lt;p&gt;I ultimately chose a &lt;strong&gt;global single list&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Constant storage: one &lt;code&gt;SharedPreferences&lt;/code&gt; key, at most 5 short text entries (a few KB), &lt;strong&gt;does not grow with the number of sub-version files&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Reasonable semantics: different scripts/branches often use similar directional words ("investigate", "ask", "go to"), so global sharing is actually more convenient&lt;/li&gt;
&lt;li&gt;Simple to implement: no &lt;code&gt;Map&amp;lt;fileName, List&amp;lt;String&amp;gt;&amp;gt;&lt;/code&gt; serialization&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Per-sub-version isolation (&lt;code&gt;Map&lt;/code&gt; structure) would pose no performance pressure either (each sub-version is just a few KB), but it's more complex to implement for limited benefit. For a personal project, a global single list is the right "good enough and simple" trade-off.&lt;/p&gt;

&lt;p&gt;A forward-looking risk: the global single list's write is an &lt;strong&gt;async &lt;code&gt;setString&lt;/code&gt;&lt;/strong&gt;; if you ever support &lt;strong&gt;multi-window / multi-tab simultaneous editing&lt;/strong&gt;, there's a theoretical chance of concurrent write clobbering (two windows each push and overwrite each other). The current design fits single-window serial scenarios; if multi-window arrives, writes need debounce merging, or switch to file locks / a database (e.g., &lt;code&gt;sqlite&lt;/code&gt;) for atomicity.&lt;/p&gt;

&lt;p&gt;And one more extreme-scenario trade-off: &lt;code&gt;SharedPreferences.setString&lt;/code&gt; is an async write. If the user closes the app or the system hard-kills the process before the &lt;code&gt;await&lt;/code&gt; completes, the last write can be lost. Since input history is &lt;strong&gt;"auxiliary convenience" rather than "core asset"&lt;/strong&gt; (losing it only means the ↑ key recalls one less entry; it doesn't corrupt narrative data), this extremely-low-probability loss is acceptable — hence no double-write or transaction log over-engineering.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. How to test these interaction boundaries
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Keyboard events: simulating the desktop platform
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;testWidgets&lt;/code&gt; runs under FakeAsync by default, and you can use &lt;code&gt;sendKeyEvent&lt;/code&gt; to simulate key presses directly. The key is &lt;strong&gt;specifying the platform&lt;/strong&gt; — &lt;code&gt;InputBar._isDesktop&lt;/code&gt; is determined by &lt;code&gt;Theme.of(context).platform&lt;/code&gt;, and by default it's Android, not desktop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tester&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;pumpWidget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buildInputBar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;onSend:&lt;/span&gt; &lt;span class="n"&gt;sent&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// internally sets ThemeData(platform: linux)&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tester&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;enterText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;find&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;byType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TextField&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s"&gt;'fate instruction'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tester&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;sendKeyEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LogicalKeyboardKey&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;enter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;platform:&lt;/span&gt; &lt;span class="s"&gt;'linux'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tester&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;pump&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;'fate instruction'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt; &lt;span class="c1"&gt;// submits on the first Enter&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same applies to testing Numpad Enter and ↑ / ↓ recall.&lt;/p&gt;

&lt;h3&gt;
  
  
  The FakeAsync limitation: real async IO doesn't complete automatically
&lt;/h3&gt;

&lt;p&gt;When a test involves "persist → rebuild → restore", I hit a snag: inside &lt;code&gt;testWidgets&lt;/code&gt;' FakeAsync, &lt;strong&gt;the SharedPreferences read Future doesn't complete automatically&lt;/strong&gt; — &lt;code&gt;pumpAndSettle&lt;/code&gt; only drives scheduled frames, not pure async IO.&lt;/p&gt;

&lt;p&gt;My initial "input history persistence" widget test never passed: write history in the first session → destroy and rebuild → press ↑ and get nothing. I tried &lt;code&gt;runAsync&lt;/code&gt;, multi-stage &lt;code&gt;pump&lt;/code&gt;, and there was always a timing contradiction between the two.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Conclusion: don't force "persistence round-trip" and "UI recall" into a single widget test&lt;/strong&gt;. Splitting the tests is more stable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Provider-level unit test&lt;/strong&gt;: verify &lt;code&gt;push&lt;/code&gt; writes, restore after recreating the container (round-trip), dedup, cap, and JSON-corruption tolerance&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Widget-level test&lt;/strong&gt;: verify ↑ / ↓ recall interaction behavior (on top of an already-mocked persistent Provider)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each focuses on its own concern, and neither is affected by the FakeAsync-vs-real-IO timing contradiction of the combined test.&lt;/p&gt;

&lt;p&gt;The Provider-level test skeleton looks like this — &lt;code&gt;SharedPreferences.setMockInitialValues&lt;/code&gt; handles the in-memory mock in one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'round-trip: push then restore after recreating container'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;SharedPreferences&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setMockInitialValues&lt;/span&gt;&lt;span class="p"&gt;({});&lt;/span&gt;

  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;container1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ProviderContainer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;container1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inputHistoryProvider&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;notifier&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'test history'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="n"&gt;container1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;dispose&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// Recreate the container (simulating an app restart) → AutoLoadNotifier restores from the in-memory mock&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;container2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ProviderContainer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;container2&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inputHistoryProvider&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;notifier&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;load&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;container2&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inputHistoryProvider&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;'test history'&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;Note: the Provider-level &lt;code&gt;load()&lt;/code&gt; is a pure async method that you can &lt;code&gt;await&lt;/code&gt; directly in a normal &lt;code&gt;test()&lt;/code&gt; without touching &lt;code&gt;testWidgets&lt;/code&gt;' FakeAsync — this is the testability dividend of extracting persistence logic out of widgets.&lt;/p&gt;

&lt;p&gt;A further architectural direction: abstract persistence behind an interface (e.g., &lt;code&gt;InputHistoryStore&lt;/code&gt;), letting the Provider depend on the interface instead of directly on &lt;code&gt;SharedPreferences&lt;/code&gt; — tests inject an in-memory implementation, &lt;strong&gt;completely escaping the FakeAsync-vs-real-IO timing contradiction&lt;/strong&gt;. &lt;code&gt;setMockInitialValues&lt;/code&gt; is Flutter's built-in lightweight mock, sufficient for the current scenario; interface injection is the upgrade path when you need stricter isolation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The "boundary sense" of a desktop input field comes from understanding three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The propagation path of keyboard events&lt;/strong&gt;: the interceptor must be mounted on the real focus node (&lt;code&gt;FocusNode&lt;/code&gt;), not an outer wrapping widget — events don't bubble after &lt;code&gt;handled&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The physical identity of key codes&lt;/strong&gt;: when matching physical keys (Enter), consider &lt;code&gt;numpadEnter&lt;/code&gt;; when modifier state (Shift) isn't carried by the event, query it via &lt;code&gt;HardwareKeyboard&lt;/code&gt; global state (and be aware of its "right now, not event-instant" boundary)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The data lifecycle&lt;/strong&gt;: whether data goes in &lt;code&gt;State&lt;/code&gt; or is lifted to a Provider + persistence depends on whether it must survive across Widget lifecycles — and verify with layered tests (round-trip at the Provider layer, UI interaction at the widget layer)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;These details almost never appear on mobile — mobile has only one soft keyboard Enter, and no concept of "files whose state must survive leaving and re-entering". But once you build for desktop, "functionally correct" and "experientially correct" diverge into a boundary that demands careful thought.&lt;/p&gt;

&lt;h2&gt;
  
  
  Glossary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Term&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LogicalKeyboardKey&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Flutter's "logical key" abstraction (after key-position + layout mapping), e.g., &lt;code&gt;enter&lt;/code&gt; / &lt;code&gt;numpadEnter&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PhysicalKeyboardKey&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Physical key position (USB HID code), independent of keyboard layout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;KeyEventResult&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Keyboard event handler result: &lt;code&gt;handled&lt;/code&gt; (consumed, no longer propagates) / &lt;code&gt;ignored&lt;/code&gt; (passed through, continues propagating)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Focus Chain&lt;/td&gt;
&lt;td&gt;The path along which keyboard events propagate from "focus node → ancestors", unrelated to widget containment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;HardwareKeyboard&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Flutter's maintained global keyboard state (keys / modifiers / lock keys) query entry&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;p&gt;Project: &lt;a href="https://github.com/yuelinghuashu/mephisto-gui" rel="noopener noreferrer"&gt;Mephisto&lt;/a&gt; (MIT License)&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>dart</category>
      <category>desktop</category>
      <category>keyboard</category>
    </item>
    <item>
      <title>Three Attempts to Refactor a Large Flutter State Class — and the Final Solution</title>
      <dc:creator>yuelinghuashu</dc:creator>
      <pubDate>Mon, 10 Aug 2026 17:42:22 +0000</pubDate>
      <link>https://dev.to/yuelinghuashu/three-attempts-to-refactor-a-large-flutter-state-class-and-the-final-solution-544f</link>
      <guid>https://dev.to/yuelinghuashu/three-attempts-to-refactor-a-large-flutter-state-class-and-the-final-solution-544f</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;A step-by-step record of refactoring an 800-line &lt;code&gt;State&lt;/code&gt; class.&lt;br&gt;&lt;br&gt;
What do you do when your State grows beyond maintainability?&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  1. When Do You Know It's Time to Split?
&lt;/h2&gt;

&lt;p&gt;When a &lt;strong&gt;State class&lt;/strong&gt; grows to &lt;strong&gt;several hundred lines&lt;/strong&gt;, it's usually a sign that it's taken on too many responsibilities.&lt;/p&gt;

&lt;p&gt;In a real-world home screen, the 800+ line &lt;code&gt;_HomeScreenState&lt;/code&gt; mixed together:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Responsibility&lt;/th&gt;
&lt;th&gt;Example Methods&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Contract tree actions&lt;/td&gt;
&lt;td&gt;Master/subcontract menus, rename, delete confirm&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stage actions&lt;/td&gt;
&lt;td&gt;Stage card menu, multi-select, character row actions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Navigation&lt;/td&gt;
&lt;td&gt;Navigate to narrative / stage pages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;File operations&lt;/td&gt;
&lt;td&gt;Import / create new / restore built-in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pure rendering&lt;/td&gt;
&lt;td&gt;Contract tree list, stage dashboard, recent edits&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Rule of thumb:&lt;/strong&gt; If the same class contains more than &lt;strong&gt;3 unrelated concerns&lt;/strong&gt;, it's time to split.&lt;br&gt;&lt;br&gt;
Line count is a symptom, not the root cause — &lt;strong&gt;responsibility mixing&lt;/strong&gt; is.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  2. Attempt 1: Extract Mixins → Blocked by Library-Private Visibility
&lt;/h2&gt;

&lt;p&gt;The most intuitive move: extract methods into Mixins, then &lt;code&gt;with&lt;/code&gt; them in.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// home_screen.dart&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;_HomeScreenState&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;ConsumerState&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;HomeScreen&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;HomeContractActions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HomeStageActions&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;// home_contract_actions.dart (separate file)&lt;/span&gt;
&lt;span class="kd"&gt;mixin&lt;/span&gt; &lt;span class="nc"&gt;HomeContractActions&lt;/span&gt; &lt;span class="kd"&gt;on&lt;/span&gt; &lt;span class="n"&gt;ConsumerState&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;HomeScreen&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_handleMasterMenu&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;// ❌ Not accessible from host!&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Error:&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
&lt;code&gt;The method '_handleMasterMenu' isn't defined for the type '_HomeScreenState'&lt;/code&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  Why?
&lt;/h3&gt;

&lt;p&gt;Dart's &lt;code&gt;_&lt;/code&gt; prefix is &lt;strong&gt;library-level private&lt;/strong&gt;, not class-level private.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;In many languages (Java, C++, TS), &lt;code&gt;private&lt;/code&gt; means "visible within the class" → mixin members are naturally visible.&lt;/li&gt;
&lt;li&gt;In Dart, &lt;code&gt;_&lt;/code&gt; means "&lt;strong&gt;visible only within the same library file&lt;/strong&gt;" — across files, even if mixed into the same class, the host can't see the mixin's private members.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's a "workaround" that looks plausible at first: declare abstract getters in the mixin and implement them in the host.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;mixin&lt;/span&gt; &lt;span class="nc"&gt;HomeStageActions&lt;/span&gt; &lt;span class="kd"&gt;on&lt;/span&gt; &lt;span class="n"&gt;ConsumerState&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;HomeScreen&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;HomeSelectionController&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;selection&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// abstract getter&lt;/span&gt;
  &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="n"&gt;refreshLists&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_handleStageMenu&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;// ❌ Still private, host can't see it&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The data bridge works (&lt;code&gt;selection&lt;/code&gt; / &lt;code&gt;refreshLists&lt;/code&gt;), but &lt;strong&gt;method visibility&lt;/strong&gt; remains broken — &lt;code&gt;_handleStageMenu&lt;/code&gt; is still private to a different library.&lt;/p&gt;

&lt;h3&gt;
  
  
  Clarification: The Problem Isn't Mixins — It's Cross-File
&lt;/h3&gt;

&lt;p&gt;If you define the mixin &lt;strong&gt;in the same file&lt;/strong&gt;, it works perfectly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// home_screen.dart (same file)&lt;/span&gt;
&lt;span class="kd"&gt;mixin&lt;/span&gt; &lt;span class="nc"&gt;HomeStageActions&lt;/span&gt; &lt;span class="kd"&gt;on&lt;/span&gt; &lt;span class="n"&gt;ConsumerState&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;HomeScreen&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_handleStageMenu&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;// ✅ Visible, same file&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;_HomeScreenState&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;ConsumerState&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;HomeScreen&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;HomeStageActions&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;So the intuition "use Mixins to split State" is &lt;strong&gt;not wrong&lt;/strong&gt;. The real hard constraint is: &lt;strong&gt;Dart's library-private visibility + files as library boundaries&lt;/strong&gt;. Once you move methods to another file, &lt;code&gt;_&lt;/code&gt; becomes a hard wall.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;📌 &lt;strong&gt;Key takeaway:&lt;/strong&gt; Dart's private isn't "class-private" — it's "&lt;strong&gt;library-private&lt;/strong&gt;".&lt;br&gt;&lt;br&gt;
Across files, &lt;code&gt;_&lt;/code&gt; members are isolated by default; mixins are only safe if they stay in the same file.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  3. Attempt 2: Use &lt;code&gt;part&lt;/code&gt; → Blocked by Missing &lt;code&gt;this&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;If &lt;code&gt;_&lt;/code&gt; is library-private, then using &lt;code&gt;part&lt;/code&gt; to split one library across multiple files should let private members be shared — right?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// home_screen.dart&lt;/span&gt;
&lt;span class="kn"&gt;part&lt;/span&gt; &lt;span class="s"&gt;'home_screen_contract.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;part&lt;/span&gt; &lt;span class="s"&gt;'home_screen_stage.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// home_screen_contract.dart&lt;/span&gt;
&lt;span class="kn"&gt;part of&lt;/span&gt; &lt;span class="s"&gt;'home_screen.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_handleMasterMenu&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;// ✅ Private now visible&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;part of&lt;/code&gt; does make private members visible (I can see &lt;code&gt;_selection&lt;/code&gt; / &lt;code&gt;_refreshLists&lt;/code&gt;), &lt;strong&gt;but a new problem appears:&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Top-level functions have no &lt;code&gt;this&lt;/code&gt; context.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Code inside &lt;code&gt;part of&lt;/code&gt; is &lt;strong&gt;library-level top-level functions&lt;/strong&gt;, not methods of the host class. So:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kn"&gt;part of&lt;/span&gt; &lt;span class="s"&gt;'home_screen.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;editContract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ContractInfo&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;editContractFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;// ❌ Undefined name 'context'&lt;/span&gt;
    &lt;span class="nl"&gt;onRefreshLists:&lt;/span&gt; &lt;span class="n"&gt;_refreshLists&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// ❌ Undefined name '_refreshLists'&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;context&lt;/code&gt;, &lt;code&gt;ref&lt;/code&gt;, &lt;code&gt;mounted&lt;/code&gt;, &lt;code&gt;_selection&lt;/code&gt; are all &lt;strong&gt;instance members&lt;/strong&gt; of &lt;code&gt;_HomeScreenState&lt;/code&gt;. Top-level functions can't access them.&lt;/p&gt;

&lt;h3&gt;
  
  
  So When Is &lt;code&gt;part&lt;/code&gt; Actually the Right Tool?
&lt;/h3&gt;

&lt;p&gt;The project already had a successful precedent — &lt;strong&gt;freezed-generated code&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// narrative_state.dart&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:freezed_annotation/freezed_annotation.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;part&lt;/span&gt; &lt;span class="s"&gt;'narrative_state.freezed.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// ✅ Pure generated code, no this dependency&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;narrative_state.freezed.dart&lt;/code&gt; is generated by the freezed tool, depending only on the &lt;code&gt;@freezed&lt;/code&gt; annotated &lt;code&gt;_NarrativeState&lt;/code&gt; class. It &lt;strong&gt;doesn't need to access any host instance members&lt;/strong&gt;. That kind of "pure declaration + generated implementation" split is what &lt;code&gt;part&lt;/code&gt; is actually for.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;📌 &lt;strong&gt;Key takeaway:&lt;/strong&gt; &lt;code&gt;part&lt;/code&gt; shares &lt;strong&gt;library-private visibility&lt;/strong&gt;, not &lt;strong&gt;host instance context&lt;/strong&gt;.&lt;br&gt;&lt;br&gt;
If your goal is to split methods that need to access &lt;code&gt;this&lt;/code&gt;, &lt;code&gt;part&lt;/code&gt; is not the answer.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  4. Final Solution: Widget Composition — Back to the Framework Philosophy (and a Reasonable Boundary)
&lt;/h2&gt;

&lt;p&gt;After two detours, the answer was the classic &lt;strong&gt;Flutter Composition&lt;/strong&gt; model all along:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Extract rendering into independent Widgets, and inject interactions via callbacks.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Core Principle
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;Where It Belongs&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Interaction orchestration&lt;/strong&gt; depending on &lt;code&gt;ref&lt;/code&gt; / &lt;code&gt;context&lt;/code&gt; / &lt;code&gt;mounted&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Stay in State&lt;/td&gt;
&lt;td&gt;These are State's lifecycle capabilities&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Read-only view building&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Extract to standalone &lt;code&gt;ConsumerWidget&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Pure functional rendering — independently maintainable and testable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Implementation
&lt;/h3&gt;

&lt;p&gt;Extract the "contract tree + stage dashboard + recent edits" rendering block into &lt;code&gt;ContractTreeSection&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ContractTreeSection&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;ConsumerWidget&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ContractGroup&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;HomeSelectionController&lt;/span&gt; &lt;span class="n"&gt;selection&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// ---- All interactions injected via callbacks ----&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="kt"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BuildContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ContractGroup&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;onMasterTap&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="kt"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ContractInfo&lt;/span&gt; &lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;onChildMenu&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="kt"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;stagePath&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;onStageTap&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;

  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="n"&gt;Widget&lt;/span&gt; &lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BuildContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;WidgetRef&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Pure rendering only: ListView + ContractCard + StageSection ...&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;The host &lt;code&gt;_HomeScreenState&lt;/code&gt; is left with just &lt;strong&gt;assembly + delegation&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;ContractTreeSection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nl"&gt;groups:&lt;/span&gt; &lt;span class="n"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;selection:&lt;/span&gt; &lt;span class="n"&gt;_selection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;onMasterTap:&lt;/span&gt; &lt;span class="n"&gt;_onMasterTap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;onChildMenu:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_handleChildMenu&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nl"&gt;onStageTap:&lt;/span&gt; &lt;span class="n"&gt;_openStageNarrative&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&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;strong&gt;Results (split ≠ deleting code; it's about rehoming responsibilities):&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;th&gt;Responsibility&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;home_screen.dart&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;816&lt;/td&gt;
&lt;td&gt;640&lt;/td&gt;
&lt;td&gt;Interaction orchestration (assembly + navigation + delegation)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;contract_tree_section.dart&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;263&lt;/td&gt;
&lt;td&gt;Contract tree + stage dashboard + recent edits (pure rendering)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;stage_card.dart&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;806&lt;/td&gt;
&lt;td&gt;529&lt;/td&gt;
&lt;td&gt;Pure presentation &lt;code&gt;StageCard&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;stage_section.dart&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;192&lt;/td&gt;
&lt;td&gt;Stage list area&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;stage_card_with_meta.dart&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;92&lt;/td&gt;
&lt;td&gt;Stage data loading glue layer&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Total code volume stayed the same&lt;/strong&gt;, but each file's responsibility went from "mixed" to "single." Pure presentation components (&lt;code&gt;StageCard&lt;/code&gt; / &lt;code&gt;ContractTreeSection&lt;/code&gt;) can now be tested independently without a State.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;stage_card_with_meta.dart&lt;/code&gt; is the thin glue between "data loading" and "pure rendering": it reads stage data (character list, save detection, recent activity) from Riverpod and passes it unchanged to the pure &lt;code&gt;StageCard&lt;/code&gt; — keeping &lt;code&gt;StageCard&lt;/code&gt; itself Riverpod-free and easier to test.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Why This Is "The Flutter Way"
&lt;/h3&gt;

&lt;p&gt;Flutter's composition model is inherently about "a Widget renders, callbacks go upward":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Button.onPressed&lt;/code&gt;, &lt;code&gt;TextField.onChanged&lt;/code&gt;, &lt;code&gt;ListView.builder&lt;/code&gt; — all examples of callback-based decoupling.&lt;/li&gt;
&lt;li&gt;The project already did this with &lt;code&gt;ContractCard&lt;/code&gt; / &lt;code&gt;StageSection&lt;/code&gt; — &lt;strong&gt;this time we just applied the same pattern to a whole page-level rendering block&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It also reduces rebuild scope:&lt;/strong&gt; extracting into a standalone &lt;code&gt;ConsumerWidget&lt;/code&gt; means unrelated field changes in the State won't reach this subtree — Flutter's &lt;code&gt;Element&lt;/code&gt; reuse + subtree isolation prevents unnecessary repaints of &lt;code&gt;ContractTreeSection&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Mixins are for &lt;strong&gt;behavior reuse&lt;/strong&gt; (e.g., &lt;code&gt;GenerationCoordinator&lt;/code&gt;). Using them to reorganize State is simply a misapplication.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;TIPS: Should the &lt;code&gt;String action&lt;/code&gt; be replaced with &lt;code&gt;enum&lt;/code&gt;?&lt;/strong&gt;&lt;br&gt;
A &lt;code&gt;String&lt;/code&gt; for &lt;code&gt;action&lt;/code&gt; might trigger type-safety instincts. But for PopupMenu scenarios, this is a pragmatic trade-off: &lt;code&gt;PopupMenuItem&amp;lt;String&amp;gt;&lt;/code&gt; works natively with String payloads, &lt;code&gt;menu_actions.dart&lt;/code&gt; shared constants already provide compile-time guardrails, and menu actions are an evolving set (each new action with an enum would require modifying the enum definition + updating every &lt;code&gt;switch&lt;/code&gt;).&lt;br&gt;
&lt;strong&gt;Bottom line:&lt;/strong&gt; If the action set is closed and stable (like &lt;code&gt;MessageRole&lt;/code&gt;), enum exhaustiveness is worth it. Otherwise, String + shared constants fits the PopupMenu ecosystem better.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Why We Stopped Here (and Didn't Extract a Coordinator)
&lt;/h3&gt;

&lt;p&gt;Extracting interaction orchestration to a Riverpod &lt;code&gt;Notifier&lt;/code&gt; coordinator is a common advanced suggestion — but we evaluated it and &lt;strong&gt;decided not to&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The remaining navigation / dialog / menu dispatch in the State is &lt;strong&gt;inherently UI work&lt;/strong&gt; — &lt;code&gt;Navigator.push&lt;/code&gt;, &lt;code&gt;ScaffoldMessenger&lt;/code&gt;, and confirmation dialogs all need &lt;code&gt;BuildContext&lt;/code&gt;. Moving them to a Notifier just shifts the shell without reducing parameters.&lt;/li&gt;
&lt;li&gt;These logic blocks were &lt;strong&gt;already extracted to top-level functions&lt;/strong&gt; (&lt;code&gt;home_operations.dart&lt;/code&gt; / &lt;code&gt;home_menu_actions.dart&lt;/code&gt;). What's left in the State is clean one-line delegations.&lt;/li&gt;
&lt;li&gt;Adding a coordinator would introduce &lt;code&gt;ref.read(coordinator.notifier).xxx()&lt;/code&gt; + &lt;code&gt;context.mounted&lt;/code&gt; boilerplate — extra indirection that increases cognitive load without real gain.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;So 640 lines isn't "unfinished" — it's &lt;strong&gt;a reasonable stopping point after achieving single responsibility&lt;/strong&gt;. Maintainability is about "every method clearly shows what it does," not line count.&lt;/p&gt;

&lt;p&gt;📌 &lt;strong&gt;Key takeaway:&lt;/strong&gt; When you hit language-mechanism limits, don't hack around them with &lt;code&gt;dynamic&lt;/code&gt; or workarounds.&lt;br&gt;&lt;br&gt;
Revisit the architecture from a framework-idiomatic angle — that's usually the real answer. &lt;strong&gt;Knowing when to stop is also part of architectural judgment.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  5. Decision Tree: Which Approach to Choose
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;State class too large?
│
├─ Refactoring for behavior reuse? (e.g., streaming throttling, generation coordination)
│    └─ ✅ Use Mixin (define in the same file to avoid library-private issues)
│
├─ Splitting pure data / generated code? (e.g., freezed .g.dart)
│    └─ ✅ Use part / part of
│
└─ Splitting State methods that need `this`?
     ├─ Pure rendering parts → ✅ Extract as standalone Widget + callback injection
     └─ Interaction orchestration parts → ✅ Keep in State
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Best For&lt;/th&gt;
&lt;th&gt;Key Limitation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Mixin&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Behavior reuse (no cross-file)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;_&lt;/code&gt; is library-private; invisible across files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;part&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Generated code / pure&lt;/td&gt;
&lt;td&gt;Shares private visibility but no &lt;code&gt;this&lt;/code&gt; context&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Standalone Widget + Callbacks&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Rendering ↔ Interaction decoupling&lt;/td&gt;
&lt;td&gt;Requires explicit parameter passing (callbacks)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  6. Engineering Safeguards for Landing the Refactor
&lt;/h2&gt;

&lt;p&gt;Refactoring is where things break most easily, so &lt;strong&gt;pure moves + pure parameter extraction = zero behavioral change&lt;/strong&gt; is the baseline:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Keep behavior identical:&lt;/strong&gt; Only move method bodies and rewrite call sites — no logic changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Static analysis as gatekeeper:&lt;/strong&gt; &lt;code&gt;flutter analyze&lt;/code&gt; must return &lt;code&gt;No issues found!&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full test suite:&lt;/strong&gt; 406 tests all green before calling it done.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small commits:&lt;/strong&gt; Split one concern at a time, validate independently — avoid "big bang" refactoring.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Dart's &lt;code&gt;_&lt;/code&gt; is &lt;strong&gt;library-private&lt;/strong&gt;, not class-private → Mixins fail across files.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;part&lt;/code&gt; shares private visibility but has no &lt;code&gt;this&lt;/code&gt; → not suitable for splitting State methods.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extract standalone Widget + inject callbacks&lt;/strong&gt; is the Flutter-idiomatic way to refactor oversized State classes.&lt;/li&gt;
&lt;li&gt;Decoupling rendering from interaction aligns with the framework philosophy and enables independent maintenance and testing.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Project: &lt;a href="https://github.com/yuelinghuashu/mephisto-gui" rel="noopener noreferrer"&gt;Mephisto&lt;/a&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>dart</category>
      <category>refactoring</category>
      <category>statemanagement</category>
    </item>
    <item>
      <title>LLM Narrative Engines, Part 6: Runtime Loop and Branching</title>
      <dc:creator>yuelinghuashu</dc:creator>
      <pubDate>Wed, 05 Aug 2026 10:45:53 +0000</pubDate>
      <link>https://dev.to/yuelinghuashu/llm-narrative-engines-part-6-runtime-loop-and-branching-4i47</link>
      <guid>https://dev.to/yuelinghuashu/llm-narrative-engines-part-6-runtime-loop-and-branching-4i47</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Before reading this&lt;/strong&gt;: This is the final post in the series. I'd recommend reading the previous five — Part 1 covers the &lt;code&gt;.meph&lt;/code&gt; format, Part 2 walks through a hands-on tutorial, Parts 3 and 4 explain how the parser outputs a &lt;code&gt;domain.Contract&lt;/code&gt;, and Part 5 covers testing. This post assumes you already know the engine has a &lt;code&gt;contract&lt;/code&gt; struct. The core problem we're solving: &lt;strong&gt;how do we drive it?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  I. A Narrative Engine's Fifth Problem: How Do You Bring Rules to Life?
&lt;/h2&gt;

&lt;p&gt;The parser turns contracts into structs, but structs don't tell stories. A narrative engine also needs a &lt;strong&gt;runtime&lt;/strong&gt; — a complete loop of input → matching → execution → output.&lt;/p&gt;

&lt;p&gt;Three engineering problems need solving:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;How do rules match in real time?&lt;/strong&gt; When user input arrives, the engine needs to iterate over all rules, evaluate conditions, and decide which actions to trigger — all in milliseconds, without affecting user experience.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;How does the LLM follow rules?&lt;/strong&gt; Rules can't directly constrain the LLM — they must take effect indirectly through prompts. Translating rules into a format the LLM understands is the core challenge here.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;How do state and memory persist?&lt;/strong&gt; Every turn changes the character's state and memory pool. But when the next turn starts, the engine must restore the complete state from the previous turn. Without persistence, there's no long-form narrative.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This post solves all three. Parts 4 and 5 already covered rule-matching details (two-phase matching, dice evaluation) — I won't rehash them here. Instead, I'll focus on how the runtime wires the entire pipeline together.&lt;/p&gt;




&lt;p&gt;Once the engine has a &lt;code&gt;contract&lt;/code&gt;, it enters a loop: receive user input → match rules → execute actions → call LLM → return response. That loop is the runtime.&lt;/p&gt;

&lt;p&gt;But before implementing the loop, several engineering problems must be solved first — they determine whether the engine is "functional" or "usable."&lt;/p&gt;




&lt;h2&gt;
  
  
  II. Novel-Grade Terminal Output: Full-Width Indentation and Streaming Interception
&lt;/h2&gt;

&lt;p&gt;LLM streaming output comes back in chunks. The engine intercepts each chunk via an &lt;code&gt;onChunk&lt;/code&gt; callback and writes it directly to the terminal.&lt;/p&gt;

&lt;p&gt;One design detail: &lt;strong&gt;full-width indentation (&lt;code&gt;　　&lt;/code&gt;)&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;onChunk&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ch&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ch&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sc"&gt;'\n'&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="n"&gt;needIndent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;
            &lt;span class="n"&gt;inParagraph&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;inParagraph&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;needIndent&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"　　"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c"&gt;// two full-width spaces at the start of each paragraph&lt;/span&gt;
                &lt;span class="n"&gt;needIndent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="n"&gt;inParagraph&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;true&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;The effect: each paragraph in the terminal output starts with two full-width spaces, making it look like a printed book. This small detail dramatically improves the creator's experience — it turns "terminal output" into "novel pages."&lt;/p&gt;

&lt;p&gt;But behind streaming output is a more critical engineering decision: &lt;strong&gt;the action executor handles all streaming callbacks uniformly&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;State-modification actions: return results immediately, then simulate character-by-character output via callback&lt;/li&gt;
&lt;li&gt;LLM actions: true streaming output, chunk by chunk&lt;/li&gt;
&lt;li&gt;Static text: return immediately, then simulate streaming output&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every action ultimately outputs through &lt;code&gt;onChunk&lt;/code&gt;, regardless of its source. This ensures a consistent terminal experience.&lt;/p&gt;




&lt;h2&gt;
  
  
  III. Five-Layer Sandwich Prompt: Welding Constraints at Both Ends
&lt;/h2&gt;

&lt;p&gt;The biggest problem with LLM narrative is format drift — it often outputs stage-direction script format:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;( smirks ) [Belial]: You're all too weak.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This breaks immersion. The solution: &lt;strong&gt;place format constraints at both the top and bottom of the prompt&lt;/strong&gt;, forming a sandwich structure. In v1.1.0, the actual prompt rendering (&lt;code&gt;RenderPrompt&lt;/code&gt; in &lt;code&gt;internal/core/llm/prompt.go&lt;/code&gt;) has five layers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;【格式硬性要求】
(NarrativeConstraints — no parentheses, no script markers, no soliloquies)

【世界观】
(context)

【角色】
You are {角色名}, a being with {锚点风格}. Your background: {角色背景}

【当前状态】
(rendered from the runtime `map[string]any`)

【命运的推动】
(conversation history — alternating fate and assistant records)

【你记得的过往】
(dynamically accumulated memories from the runtime)

【此刻】
(user_input)

【要求】
(NarrativeConstraints, emphasized again)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Constraints appear twice, at both ends, with the context sandwiched in between. Two reasons for this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Primacy effect&lt;/strong&gt;: the LLM sees the constraints first — they get the highest priority&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recency effect&lt;/strong&gt;: the constraints the LLM sees last influence the final output&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Double emphasis ensures the format constraints aren't diluted by the context in the middle.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.1 Deterministic Rendering: Sorting Guarantees Cache Hits
&lt;/h3&gt;

&lt;p&gt;There's a subtle engineering problem here: Go's &lt;code&gt;map&lt;/code&gt; iteration order is random.&lt;/p&gt;

&lt;p&gt;At runtime, &lt;code&gt;【状态】&lt;/code&gt; from the contract is converted to a &lt;code&gt;map[string]any&lt;/code&gt; for fast read/write. When rendering &lt;code&gt;【当前状态】&lt;/code&gt; in the prompt, if the iteration order changes each time, the generated prompt text changes — even if the state content is identical. This causes the &lt;strong&gt;LLM's KV Cache to completely miss&lt;/strong&gt;, forcing recomputation every time.&lt;/p&gt;

&lt;p&gt;The solution: sort the keys of &lt;code&gt;state&lt;/code&gt; (&lt;code&gt;sort.Strings&lt;/code&gt;) to ensure stable rendering order. When the state doesn't change, the generated prompt text is byte-for-byte identical, allowing the LLM service's KV Cache to hit, reducing latency and token consumption.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.2 Anti-Soliloquy Constraint
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;NarrativeConstraints&lt;/code&gt; has one deliberately emphasized rule:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Each response must include dialogue and action from at least one other character (non-player). If no other characters are present in the scene, introduce or create at least one interactive object. Prohibit purely player-only soliloquies.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This solves the "empty world" problem in narrative. If the LLM only responds to player input without introducing other characters, the story becomes a monologue and loses dramatic tension. This constraint forces the LLM to introduce at least one interactive object in every response, making the world feel alive.&lt;/p&gt;




&lt;h2&gt;
  
  
  IV. Mother-Child Save Mechanism
&lt;/h2&gt;

&lt;p&gt;After every turn, the engine automatically saves the current state to a child file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Naming convention&lt;/strong&gt; (v1.1.0 onward, aligned with the Flutter version, &lt;strong&gt;dot-separated&lt;/strong&gt;):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Master &lt;code&gt;story.meph&lt;/code&gt; → default child &lt;code&gt;story.child.meph&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Branch &lt;code&gt;--branch dark&lt;/code&gt; → &lt;code&gt;story.dark.meph&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;BuildChildPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;branch&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;dir&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;filepath&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Dir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;filepath&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ext&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;filepath&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Ext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TrimSuffix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ext&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c"&gt;// Already a child file: overwrite directly (prevent nested generation)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;isChildFileName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;filename&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;branch&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;filepath&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="s"&gt;"."&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;branch&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;ext&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;filepath&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;childSuffix&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;ext&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// childSuffix = ".child"&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;isChildFileName&lt;/code&gt; precisely identifies existing child files: &lt;code&gt;xxx.child&lt;/code&gt; (default child) or &lt;code&gt;xxx.branchName&lt;/code&gt; (branch names starting with letters). This means &lt;code&gt;my_story_1.meph&lt;/code&gt; (with a numeric suffix) won't be misidentified as a child.&lt;/p&gt;

&lt;p&gt;A child file is a complete &lt;code&gt;.meph&lt;/code&gt; contract, containing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;All static blocks from the master (character name, worldview, character background, opening scene, anchors, rules)&lt;/li&gt;
&lt;li&gt;Updated &lt;code&gt;【状态】&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Accumulated &lt;code&gt;【记忆】&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Recent &lt;code&gt;【历史】&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This means one static contract can evolve into any number of dynamic children:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;story.meph (master, read-only)
    ├── story.child.meph (main timeline)
    ├── story.dark.meph (dark branch)
    ├── story.light.meph (light branch)
    └── story.experimental.meph (experimental branch)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each branch evolves independently, without interfering with the others. The project includes &lt;code&gt;data/dantes.meph&lt;/code&gt; alongside an already-run &lt;code&gt;data/dantes.child.meph&lt;/code&gt; save.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Save timing&lt;/strong&gt;: instead of writing to disk every turn (which would be inefficient), it's split into two layers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;After each turn&lt;/strong&gt;: the Session layer (&lt;code&gt;cmd/mephisto/session.go&lt;/code&gt;) calls &lt;code&gt;engine.Save()&lt;/code&gt; to persist progress in real time&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On exit&lt;/strong&gt;: a &lt;code&gt;defer&lt;/code&gt; saves one more time, ensuring the final state is written before exit&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Rule freshness during save&lt;/strong&gt;: &lt;code&gt;Save()&lt;/code&gt; has a clever design — before saving, it reads the child file from disk (if it exists) and uses the &lt;code&gt;【规则】&lt;/code&gt; block from disk as the latest rules. This means the user's real-time edits to the rules section in their editor won't be overwritten by the auto-save. This also supports &lt;strong&gt;rule hot-reload&lt;/strong&gt; introduced in v1.0.3: &lt;code&gt;session.go&lt;/code&gt; watches the child file for changes via &lt;code&gt;fsnotify&lt;/code&gt;, with a 500ms debounce, then calls &lt;code&gt;ReloadContract&lt;/code&gt; to re-parse — replacing only the rules while preserving state and history. This makes "edit rules → save → instantly effective" a reality.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Loading&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Default: load the child if it exists&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--reset&lt;/code&gt;: ignore the child, start fresh from the master&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--branch dark&lt;/code&gt;: load the corresponding branch file&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;: Running a child file directly will overwrite it — the engine treats any &lt;code&gt;.meph&lt;/code&gt; file as a master and generates a corresponding child. To avoid losing progress, don't run &lt;code&gt;run&lt;/code&gt; directly on child files.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The value of this design&lt;/strong&gt;: creators can fork storylines at critical moments, exploring different directions without losing any progress.&lt;/p&gt;




&lt;h2&gt;
  
  
  V. Memory Extraction: Synchronous Weaving After Streaming
&lt;/h2&gt;

&lt;p&gt;Memory extraction is critical for long-form narrative — it extracts key events from conversation history, compresses them for long-term storage, and injects them into the LLM context every turn.&lt;/p&gt;

&lt;p&gt;But extraction calls the LLM, which takes seconds. If executed &lt;strong&gt;before&lt;/strong&gt; streaming output, users would wait several seconds every 5 turns before seeing the first character.&lt;/p&gt;

&lt;p&gt;The solution is simple: &lt;strong&gt;output first, extract later.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here's the flow for each turn:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User input
    │
    ▼
Rule matching + action execution + LLM streaming output (user sees text appear character by character)
    │
    ▼
Streaming completes, user finishes reading the response
    │
    ▼
Engine.Run synchronously executes memory extraction (still inside Run, user has finished reading)
    │
    ▼
Return to Session — Session calls Save to auto-save the child
    │
    ▼
Show input prompt, wait for next turn
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the &lt;strong&gt;difference from the old version&lt;/strong&gt;: memory extraction runs synchronously inside &lt;code&gt;engine.Run()&lt;/code&gt;, while child saving is handled by the Session layer after each Run returns. The two are decoupled — the engine handles narrative and memory, while Session handles persistence.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// internal/core/engine/engine.go&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Engine&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;onChunk&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;// ... rule matching, LLM call, streaming output ...&lt;/span&gt;

    &lt;span class="c"&gt;// 4. Record the assistant's response&lt;/span&gt;
    &lt;span class="n"&gt;runtime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddHistory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"assistant"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c"&gt;// 5. Memory extraction (every N turns, synchronous)&lt;/span&gt;
    &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;processMemories&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c"&gt;// ← user has finished reading the response by now&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&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;processMemories()&lt;/code&gt; internal flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Extraction interval check&lt;/strong&gt;: &lt;code&gt;ShouldExtract&lt;/code&gt; — triggers when turn_count % 5 == 0 (&lt;code&gt;ExtractInterval = 5&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Call LLM for extraction&lt;/strong&gt;: takes the most recent 10 turns (&lt;code&gt;ExtractWindow = 10&lt;/code&gt;), generates key-event summaries (each no more than 20 words)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Semantic deduplication&lt;/strong&gt;: &lt;code&gt;shared.DeduplicateMemories&lt;/code&gt; uses keyword Jaccard similarity for deduplication — semantically similar memories with different wording are automatically merged. For example: "Faust meets Mephistopheles in his study" and "Mephistopheles visits Faust's study late at night" — both memories share keywords like Faust, Mephistopheles, and study, so they're judged to describe the same event and merged into one&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Append + compress&lt;/strong&gt;: when the count exceeds the limit (&lt;code&gt;MaxLimit = 30&lt;/code&gt;), auto-compress — keep the most recent 5 entries plus 3-5 summary entries&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Why this design?&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;User-unaware&lt;/strong&gt;: streaming is already complete, the user is reading or thinking about the response. Memory extraction happens silently in the background — the user doesn't "wait"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simple logic&lt;/strong&gt;: synchronous calls are easier to control than async goroutines — no race conditions, no "memory isn't written yet when saving"&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If extraction fails, it only logs silently (the extraction function returns an error and skips it). The conversation continues — just without saving memories for that turn.&lt;/p&gt;




&lt;h2&gt;
  
  
  VI. The Complete Loop
&lt;/h2&gt;

&lt;p&gt;Putting it all together, here's how each turn of the engine runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User input
    │
    ▼
Rule matching
    │   ├── Passive rules (state modification + memory injection): batch execution, multiple can trigger
    │   └── Active rules (LLM instruction / static text): mutually exclusive, only the first match triggers
    │
    ▼
Execute action → LLM call (60-second timeout protection, falls back to ⚠️ static response on failure)
    │                        ↑—— timeout only protects the LLM call phase
    ▼
Streaming output (full-width indentation + chunk-by-chunk callbacks)
    │
    ▼
Record assistant history → (sync) memory extraction (every 5 turns, no streaming wait)
    │
    ▼
Session layer calls Save → auto-save to child file (story.child.meph)
    │
    ▼
Rule hot-reload listener (background async fsnotify, doesn't block main loop)
    │
    ▼
Wait for next input
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few runtime robustness details worth calling out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;LLM timeout fallback&lt;/strong&gt;: the entire LLM call is wrapped in a 60-second timeout context (&lt;code&gt;context.WithTimeout&lt;/code&gt;). On timeout or failure, the engine outputs &lt;code&gt;(⚠️ LLM call failed: request timeout, fallback to static response)&lt;/code&gt; via &lt;code&gt;onChunk&lt;/code&gt;, then returns a default static text. &lt;strong&gt;Telling the user "LLM is down" is better than making them guess "the character went silent"&lt;/strong&gt; — the former is a diagnosable engineering issue, the latter might be misinterpreted as narrative design.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Debugging and quiet mode&lt;/strong&gt;: debug output (&lt;code&gt;--debug&lt;/code&gt;) goes to &lt;code&gt;os.Stderr&lt;/code&gt;, while normal output (&lt;code&gt;--quiet&lt;/code&gt;) doesn't interfere. Both can be enabled simultaneously.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is Mephisto's runtime.&lt;/p&gt;




&lt;h2&gt;
  
  
  VII. Costs and Limitations
&lt;/h2&gt;

&lt;p&gt;This system doesn't come without cost:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Memory extraction depends on LLM quality&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the main model is in poor state, extracted summaries may drift — even altering key facts (e.g., writing "banished" instead of "defeated"). This is a "hallucination" risk.&lt;/p&gt;

&lt;p&gt;Two layers of mitigation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Prompt protection&lt;/strong&gt;: extraction and compression prompts explicitly forbid modifying core character settings (name, anchor content, state values, etc.). This works reliably on DeepSeek and GPT-4.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Model selection advice&lt;/strong&gt;: for 7B local models, summary quality drops significantly. If you must use a lightweight model, consider disabling automatic memory extraction (&lt;code&gt;ExtractInterval = 0&lt;/code&gt;) and managing memories manually.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Future enhancement: &lt;strong&gt;post-extraction validation&lt;/strong&gt; — after extraction results return, the engine checks for conflicts with core settings. If a conflict is found, discard that memory entry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Branch switching requires manual management&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Child files are stored independently. Switching branches requires the user to explicitly specify &lt;code&gt;--branch&lt;/code&gt;. Unlike real version control with diff and merge, branches don't auto-sync.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Streaming output occupies the terminal&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Full-width indentation and streaming look great in the terminal, but if the user wants to copy-paste text, the indentation and newlines come along — sometimes interfering.&lt;/p&gt;




&lt;h2&gt;
  
  
  VIII. Summary
&lt;/h2&gt;

&lt;p&gt;Six posts complete a full path:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Post&lt;/th&gt;
&lt;th&gt;Problem Solved&lt;/th&gt;
&lt;th&gt;Core Output&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;What format to write rules in?&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.meph&lt;/code&gt; format design&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;What to run first?&lt;/td&gt;
&lt;td&gt;Write a Faust contract from scratch and run it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;How to parse precisely with good errors?&lt;/td&gt;
&lt;td&gt;Block scanner + Parser&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;How to parse rules and variables?&lt;/td&gt;
&lt;td&gt;Rule expressions + interpolation syntax&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;How to ensure changes don't break things?&lt;/td&gt;
&lt;td&gt;Golden file testing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;How to bring a contract to life?&lt;/td&gt;
&lt;td&gt;Five-layer prompt + branching + memory extraction&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Together, they form a complete long-form narrative engine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Contract (.meph)
    │
    ▼
Parser (Posts 3 &amp;amp; 4) ──→ Contract
    │
    ▼
Engine (Post 6) ──→ Rule matching + LLM streaming narrative + memory extraction
    │
    ▼
Child save (story.child.meph) ──→ Long-term continuity + multiple branches
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Quick Reference: Complete Flow
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.meph contract file
    │
    ▼ Scanner (line number binding)
    │
    ▼ Block list []Block
    │
    ▼ Parser (routing by block title)
    │
    ▼ domain.Contract
    │   ├─ RoleName
    │   ├─ Anchor
    │   ├─ State
    │   ├─ Worldview
    │   └─ Rules (conditions stored as-is, evaluated at runtime)
    │
    ▼ Engine
    │   ├─ Five-layer sandwich prompt (top constraints → worldview → character → state/history/memory → bottom constraints)
    │   ├─ Rule matching (passive batch + active mutex)
    │   ├─ Action execution (inject / state modify / LLM call / static text)
    │   ├─ Streaming output (full-width indentation)
    │   ├─ Memory extraction (every 5 turns, synchronous, semantic deduplication)
    │   ├─ LLM timeout fallback (60s, ⚠️ message + static response)
    │   └─ Child save (story.child.meph, dot-separated naming)
    │
    ▼ Engine loop
        User input → rule matching → execute action → LLM streaming narrative
        → memory extraction → Session auto-save → hot-reload listener → wait for next input
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For quick reference on where to find specific content:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Flow Node&lt;/th&gt;
&lt;th&gt;Corresponding Post&lt;/th&gt;
&lt;th&gt;Key Concepts&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;.meph&lt;/code&gt; format&lt;/td&gt;
&lt;td&gt;Post 1&lt;/td&gt;
&lt;td&gt;Block titles, rule syntax&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hands-on&lt;/td&gt;
&lt;td&gt;Post 2&lt;/td&gt;
&lt;td&gt;Write a contract from scratch, no-LLM mode&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scanner&lt;/td&gt;
&lt;td&gt;Post 3&lt;/td&gt;
&lt;td&gt;Line number binding, whitelist-first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Parser&lt;/td&gt;
&lt;td&gt;Post 4&lt;/td&gt;
&lt;td&gt;Rule decomposition, interpolation syntax&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Testing&lt;/td&gt;
&lt;td&gt;Post 5&lt;/td&gt;
&lt;td&gt;Golden file, error-scenario tests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Engine runtime&lt;/td&gt;
&lt;td&gt;Post 6&lt;/td&gt;
&lt;td&gt;Five-layer prompt, branching, memory extraction&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;blockquote&gt;
&lt;p&gt;GitHub: &lt;a href="https://github.com/yuelinghuashu/mephisto" rel="noopener noreferrer"&gt;https://github.com/yuelinghuashu/mephisto&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>go</category>
      <category>llm</category>
      <category>engineering</category>
    </item>
    <item>
      <title>LLM Narrative Engines, Part 5: Integration Testing and Behavior Freezing</title>
      <dc:creator>yuelinghuashu</dc:creator>
      <pubDate>Sun, 02 Aug 2026 06:16:48 +0000</pubDate>
      <link>https://dev.to/yuelinghuashu/llm-narrative-engines-part-5-integration-testing-and-behavior-freezing-44jd</link>
      <guid>https://dev.to/yuelinghuashu/llm-narrative-engines-part-5-integration-testing-and-behavior-freezing-44jd</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Before reading this&lt;/strong&gt;: I'd recommend skimming Part 3's "Parser" section and Part 4's summary to understand how the parser outputs a &lt;code&gt;domain.Contract&lt;/code&gt;. This post assumes you already know the parser can turn &lt;code&gt;.meph&lt;/code&gt; into a struct.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  I. A Narrative Engine's Fourth Problem: How Do You Keep Behavior Stable?
&lt;/h2&gt;

&lt;p&gt;The parser is written. But it's &lt;strong&gt;code that gets maintained long-term&lt;/strong&gt; — requirements change, formats expand, bugs get fixed. Every change risks breaking existing behavior.&lt;/p&gt;

&lt;p&gt;The tension here is: &lt;strong&gt;creators depend on stable behavior, while developers depend on freedom to change.&lt;/strong&gt; If every code change requires manually testing every known scenario, the developer will fear refactoring. If you don't test, broken behavior reaches the creator — but the creator doesn't care that you refactored the parser.&lt;/p&gt;

&lt;p&gt;The solution is to "freeze" parsing behavior: use a fixed set of contracts as watchdogs. After every change, automatically compare parse results against expectations.&lt;/p&gt;

&lt;p&gt;This is what integration tests do: take a fixed set of &lt;code&gt;.meph&lt;/code&gt; contracts as "watchdogs," run them after every change, and verify that behavior hasn't been accidentally altered.&lt;/p&gt;




&lt;h2&gt;
  
  
  II. Golden File Testing: Freezing Parse Results
&lt;/h2&gt;

&lt;p&gt;The most straightforward approach: prepare a standard contract, parse it, serialize the result to JSON, and save it. On every subsequent test run, compare the current parse result against that JSON file.&lt;/p&gt;

&lt;p&gt;The project's &lt;code&gt;testdata/sample.meph&lt;/code&gt; is that standard contract. Here's the test flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;TestParseSample&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;testing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;got&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;ParseFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"testdata/sample.meph"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fatalf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"parse failed: %v"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;goldenPath&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="s"&gt;"testdata/sample.golden"&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;want&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Contract&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;loadGolden&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;goldenPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;want&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c"&gt;// Golden file doesn't exist — generate it automatically&lt;/span&gt;
        &lt;span class="n"&gt;saveGolden&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;goldenPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;got&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Golden file generated. Please review and re-run the test."&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FailNow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c"&gt;// Compare got and want&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;diff&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;cmp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Diff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;want&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;got&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;diff&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Parse result doesn't match expected:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;%s"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"💡 If this change is intentional, run: go test -update"&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;On first run, &lt;code&gt;sample.golden&lt;/code&gt; is auto-generated. Every subsequent run compares against it, reporting differences. If the change is intended (e.g., a new field was added), running &lt;code&gt;go test -update&lt;/code&gt; refreshes the Golden file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The core value of this mechanism:&lt;/strong&gt; the parser's behavior is "frozen." Any change must pass test validation — it can't silently alter parse results.&lt;/p&gt;




&lt;h2&gt;
  
  
  III. Parse-Time Validation
&lt;/h2&gt;

&lt;p&gt;Parsing isn't just "reading text in" — it validates required fields during the parse itself.&lt;/p&gt;

&lt;p&gt;If the character name is empty, &lt;code&gt;parseRoleName&lt;/code&gt; errors out immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Line X: character name cannot be empty
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a rule name, condition, or action is empty, &lt;code&gt;parseRuleLine&lt;/code&gt; errors out with the line number.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If parsing fails, the struct doesn't exist.&lt;/strong&gt; There's no state where "parsing succeeded but content is invalid" — this is another advantage of the hand-written parser over JSON/YAML. JSON parsers don't validate semantic completeness — they only validate structural correctness.&lt;/p&gt;




&lt;h2&gt;
  
  
  IV. Sliding-Window Aging Tests: Correct History Truncation
&lt;/h2&gt;

&lt;p&gt;The engine has a critical behavior: history can't grow indefinitely. It must auto-truncate, keeping only the most recent N turns.&lt;/p&gt;

&lt;p&gt;This test verifies that behavior:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;TestIntegrationHistoryLimit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;testing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;contract&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;parser&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ParseFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"../parser/testdata/sample.meph"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fatalf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"parse failed: %v"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c"&gt;// Set max history to 2 turns&lt;/span&gt;
    &lt;span class="n"&gt;eng&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;contract&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithMaxHistory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="c"&gt;// Run 5 turns&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;eng&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Hello"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;history&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;eng&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;History&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="c"&gt;// 5 turns produce 10 records, but capacity is only 4 (2 turns * 2 records/turn)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"history length = %d, want 4"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;history&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;This test ensures history truncation is &lt;strong&gt;"whole-turn"&lt;/strong&gt; rather than &lt;strong&gt;"per-record."&lt;/strong&gt; If truncation were per-record, you could end up with only the "fate" input and no "assistant" response — half a turn of data, leading to incomplete context in the &lt;code&gt;【命运的推动】&lt;/code&gt; prompt block.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Whole-turn truncation&lt;/strong&gt; preserves history integrity — either a full turn (fate + assistant) is retained, or it's discarded entirely.&lt;/p&gt;




&lt;h2&gt;
  
  
  V. Error Scenario Testing: Ensuring Precise Error Messages
&lt;/h2&gt;

&lt;p&gt;Beyond the "happy path," integration tests cover the &lt;strong&gt;error path&lt;/strong&gt; — ensuring all kinds of malformed input produce correct errors, with line numbers and block names included:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;TestParseErrors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;testing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;tests&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;name&lt;/span&gt;    &lt;span class="kt"&gt;string&lt;/span&gt;
        &lt;span class="n"&gt;input&lt;/span&gt;   &lt;span class="kt"&gt;string&lt;/span&gt;
        &lt;span class="n"&gt;wantErr&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="c"&gt;// substring the error should contain&lt;/span&gt;
    &lt;span class="p"&gt;}{&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;    &lt;span class="s"&gt;"content outside any block"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;   &lt;span class="s"&gt;"content outside any block&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;【角色名】&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;Belial"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;wantErr&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"content appears outside any block"&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="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;    &lt;span class="s"&gt;"list item missing - prefix"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;   &lt;span class="s"&gt;"【锚点】&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;核心信念: power"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;wantErr&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"list item must start with '-'"&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="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;    &lt;span class="s"&gt;"list item missing colon"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;   &lt;span class="s"&gt;"【锚点】&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;- 核心信念 &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;power&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;wantErr&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"missing ':' or '：'"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="c"&gt;// ...&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tt&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;tests&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;testing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;ParseString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;tt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wantErr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"expected error containing '%s', got: %v"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wantErr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These tests ensure error messages never degrade to &lt;code&gt;unexpected token at position 42&lt;/code&gt; — the kind of error we set out to eliminate in Part 1.&lt;/p&gt;




&lt;h2&gt;
  
  
  VI. The Cost
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Golden files need manual review on first generation or update (checking that the content is correct)&lt;/li&gt;
&lt;li&gt;Error-scenario tests need to cover as many edge cases as possible&lt;/li&gt;
&lt;li&gt;Each new block type requires updating test cases&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But the payoff is: &lt;strong&gt;you can refactor fearlessly. As long as all tests pass, behavior hasn't changed.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  VII. Summary
&lt;/h2&gt;

&lt;p&gt;Integration tests are the project's "watchdogs." They freeze the parser's behavior, and every change must pass their validation.&lt;/p&gt;

&lt;p&gt;Four things make up this test suite:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Golden file tests&lt;/strong&gt;: freeze the parse results of a standard contract&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parse-time validation&lt;/strong&gt;: check required fields and completeness during parsing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sliding-window aging tests&lt;/strong&gt;: ensure history is truncated by whole turns&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Error-scenario tests&lt;/strong&gt;: guarantee error messages are precise down to the line number&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;With this system in place, the engine can evolve confidently — no fear of breaking things, because the tests will catch it.&lt;/p&gt;

&lt;p&gt;Next, we enter the engine's runtime. The core problem: &lt;strong&gt;once you have a &lt;code&gt;domain.Contract&lt;/code&gt;, how do you drive the LLM to generate narrative that follows the rules?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The answer is the &lt;strong&gt;sandwich prompt structure&lt;/strong&gt; — putting format constraints at both the top and bottom, with context in the middle — to completely eliminate parenthetical stage-direction drivel.&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;GitHub: &lt;a href="https://github.com/yuelinghuashu/mephisto" rel="noopener noreferrer"&gt;https://github.com/yuelinghuashu/mephisto&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>go</category>
      <category>engineering</category>
    </item>
    <item>
      <title>LLM Narrative Engines, Part 4: Parsing Rules and Interpolation</title>
      <dc:creator>yuelinghuashu</dc:creator>
      <pubDate>Wed, 29 Jul 2026 03:48:48 +0000</pubDate>
      <link>https://dev.to/yuelinghuashu/llm-narrative-engines-part-4-parsing-rules-and-interpolation-mgc</link>
      <guid>https://dev.to/yuelinghuashu/llm-narrative-engines-part-4-parsing-rules-and-interpolation-mgc</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Before reading this&lt;/strong&gt;: I'd recommend skimming Part 3's "Two-Phase Design" and "Block Scanner" sections to understand how the scanner outputs &lt;code&gt;[]Block&lt;/code&gt;. This post assumes you already know how the parser routes blocks to different parse functions based on their &lt;code&gt;Title&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  I. A Narrative Engine's Third Problem: How Do You Parse Condition-Action Rules?
&lt;/h2&gt;

&lt;p&gt;Once the blocks are split, the most complex part is &lt;strong&gt;rule expressions&lt;/strong&gt;. A narrative engine needs to answer: how are conditions written, how are actions written, and how are variables referenced?&lt;/p&gt;

&lt;p&gt;There's an important design boundary here: &lt;strong&gt;the parser only parses, it doesn't evaluate&lt;/strong&gt;. Condition strings like &lt;code&gt;包含 "攻击" &amp;amp;&amp;amp; 状态.堕落指数 &amp;gt; 80&lt;/code&gt; are stored as-is in the struct. The engine evaluates them at runtime. Why? Because the runtime state needed for evaluation doesn't exist at parse time — state values change dynamically during conversation and can't be determined at load time.&lt;/p&gt;

&lt;p&gt;This post is about the parsing logic itself.&lt;/p&gt;




&lt;h2&gt;
  
  
  II. Rule Parsing: Splitting Conditions and Actions
&lt;/h2&gt;

&lt;p&gt;The rule format is fixed: &lt;code&gt;[name] if condition -&amp;gt; action&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[攻击] if 包含 "攻击" -&amp;gt; 注入 "贝利亚发动了猛烈的攻击"
[光之国] if 包含 "光之国" &amp;amp;&amp;amp; 状态.情绪 == "暴怒" -&amp;gt; 注入 "光之国的记忆让贝利亚更加愤怒"
[高堕落] if 状态.堕落指数 &amp;gt; 80 -&amp;gt; 状态.情绪 = "癫狂"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Interpolation syntax appears in rule actions and text blocks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;注入 "{角色名}的故乡是光之国"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This post tackles two problems:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;How do we parse rule conditions and actions into storable structures?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;How is interpolation syntax recognized and handled at the parsing layer?&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  2.1 Rule Name Extraction
&lt;/h3&gt;

&lt;p&gt;Find the first &lt;code&gt;[&lt;/code&gt; and the first &lt;code&gt;]&lt;/code&gt;, extract what's between them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;parseRuleLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lineNumber&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Rule&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;trimmed&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TrimSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HasPrefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trimmed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"["&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"规则必须以 '[' 开头"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trimmed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"]"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"缺少闭合的 ']'"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TrimSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trimmed&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"规则名不能为空"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;rest&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TrimSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trimmed&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;idx&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="c"&gt;// Extract condition and action next...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;: The error messages in the Go code above are shown in Chinese — they're the actual output from the engine's parser.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  2.2 Splitting Condition and Action
&lt;/h3&gt;

&lt;p&gt;Use &lt;code&gt;if&lt;/code&gt; and &lt;code&gt;-&amp;gt;&lt;/code&gt; as delimiters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Remove the "if " prefix&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HasPrefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"if "&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"规则条件必须以 'if ' 开头"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;rest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TrimPrefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"if"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;rest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TrimSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;// Use the first "-&amp;gt;" to split condition and action&lt;/span&gt;
&lt;span class="n"&gt;cond&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Cut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"-&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&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="n"&gt;cond&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TrimSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cond&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TrimSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2.3 Mutex Groups
&lt;/h3&gt;

&lt;p&gt;Actions may contain a &lt;code&gt;[group:xxx]&lt;/code&gt; marker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[攻击] if 包含 "攻击" -&amp;gt; [group:combat] 注入 "贝利亚发动了猛烈的攻击"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When parsing, we extract the group name and store it in &lt;code&gt;domain.Rule.Group&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;group&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HasPrefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"[group:"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;endIdx&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"]"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;endIdx&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;group&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;7&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;endIdx&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TrimSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;endIdx&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="m"&gt;1&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mutex groups ensure that within the same group, only the first matching rule is triggered. This logic takes effect at runtime; the parser just stores the group name.&lt;/p&gt;

&lt;p&gt;Mutex groups can be used with any action type, not just &lt;code&gt;注入&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[高堕落] if 状态.堕落指数 &amp;gt; 80 -&amp;gt; [group:escalate] 状态.情绪 = "癫狂"
[失控] if 状态.情绪 == "癫狂" &amp;amp;&amp;amp; 状态.堕落指数 &amp;gt; 90 -&amp;gt; [group:escalate] 注入 "{角色名}已完全失控"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2.4 Quote Handling
&lt;/h3&gt;

&lt;p&gt;Strings in conditions and actions are wrapped in &lt;code&gt;"&lt;/code&gt;. During parsing, I call &lt;code&gt;unquote&lt;/code&gt; to strip the outer quotes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;unquote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TrimSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="m"&gt;2&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="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HasPrefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HasSuffix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
           &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HasPrefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"“"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HasSuffix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"”"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Chinese quotes are supported because creators may use Chinese input methods — &lt;code&gt;“&lt;/code&gt; and &lt;code&gt;”&lt;/code&gt; are more natural to type than &lt;code&gt;"&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  III. Interpolation Syntax: Recognizing and Replacing &lt;code&gt;{variable}&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The core requirement for interpolation: &lt;strong&gt;recognize &lt;code&gt;{角色名}&lt;/code&gt; in any text and replace it with the corresponding character name.&lt;/strong&gt; Currently, only one interpolation variable is supported — &lt;code&gt;{角色名}&lt;/code&gt;, which comes from the &lt;code&gt;【角色名】&lt;/code&gt; block and is statically defined.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.1 Why Only &lt;code&gt;{角色名}&lt;/code&gt;?
&lt;/h3&gt;

&lt;p&gt;You might wonder: why can't state variables like corruption index or emotion be interpolated directly with &lt;code&gt;{堕落指数}&lt;/code&gt;?&lt;/p&gt;

&lt;p&gt;Because state variables are &lt;strong&gt;read and written dynamically at runtime&lt;/strong&gt; — creators modify them via &lt;code&gt;状态.键 = 值&lt;/code&gt; and compare them via &lt;code&gt;状态.键 &amp;gt; 值&lt;/code&gt;. Allowing &lt;code&gt;{堕落指数}&lt;/code&gt; interpolation would introduce a parallel mechanism: the same variable could be referenced both via &lt;code&gt;状态.堕落指数&lt;/code&gt; and &lt;code&gt;{堕落指数}&lt;/code&gt;. Two syntaxes for the same thing, increasing the learning curve.&lt;/p&gt;

&lt;p&gt;So state variables are uniformly referenced using the &lt;code&gt;状态.键&lt;/code&gt; syntax, with no interpolation. The engine's template substitution does one thing only: replace &lt;code&gt;{角色名}&lt;/code&gt; with the actual character name.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.2 Substitution Timing: Three Scenarios
&lt;/h3&gt;

&lt;p&gt;Interpolation substitution happens in &lt;strong&gt;three different scenarios&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;CLI welcome screen display&lt;/strong&gt;: Worldview and opening scenes are substituted once when displayed to the user. The creator sees "贝利亚奥特曼" instead of &lt;code&gt;{角色名}&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Rule action execution&lt;/strong&gt;: &lt;code&gt;注入 "{角色名}的故乡是光之国"&lt;/code&gt; is substituted every time the rule triggers. This is the most common usage of interpolation.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Prompt construction&lt;/strong&gt;: Worldview and background text are passed to the LLM &lt;strong&gt;as-is&lt;/strong&gt;, without engine-side substitution. The LLM naturally understands &lt;code&gt;{角色名}&lt;/code&gt; from context (e.g., "You are 贝利亚奥特曼").&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Why not substitute in scenario 3?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Because substitution changes the raw form of the text. If we replace &lt;code&gt;{角色名}的故乡是光之国&lt;/code&gt; with &lt;code&gt;贝利亚奥特曼的故乡是光之国&lt;/code&gt;, the LLM receives a "hardened" narrative. Keeping &lt;code&gt;{角色名}&lt;/code&gt; as-is lets the LLM dynamically decide how to use the name during generation — in some narrative branches, the character name might change (e.g., the character is renamed or forgotten), and keeping the placeholder is actually more flexible.&lt;/p&gt;




&lt;h2&gt;
  
  
  IV. The Intersection: When Rules Meet Interpolation
&lt;/h2&gt;

&lt;p&gt;Take this rule as an example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[光之国] if 包含 "光之国" -&amp;gt; 注入 "{角色名}的故乡是光之国"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Full execution flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User input: "I'm going to the Land of Light!"
    │
    ▼
Rule matching: contains "光之国" → true
    │
    ▼
Action recognition: action type is "注入"
    │
    ▼
Runtime substitution: {角色名} → "贝利亚奥特曼"
    │
    ▼
Append to memory: "贝利亚奥特曼的故乡是光之国" written to memory
    │
    ▼
LLM narrative: generates response with the new memory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why is substitution done at runtime?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Because the engine supports multiple branching storylines. If substitution happened at load time, all branches would share the same static value and couldn't evolve independently. Runtime substitution means each branch reads its own state — when the state changes, the interpolation result changes with it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dice Expression Error Tolerance
&lt;/h3&gt;

&lt;p&gt;Dice expressions like &lt;code&gt;roll(1d100) &amp;gt;= 80&lt;/code&gt; are stored as-is during parsing and evaluated at runtime. If the expression has a format error — e.g., &lt;code&gt;roll(1d10&lt;/code&gt; missing a closing parenthesis — the engine doesn't report an error. Instead, it treats the condition as not satisfied (returns &lt;code&gt;false&lt;/code&gt;). This is because dice expressions are part of conditions, and the parsing layer isn't responsible for validating runtime correctness — malformed expressions are simply treated as "no match."&lt;/p&gt;




&lt;h2&gt;
  
  
  V. Summary: The Parsing Layer Is Complete
&lt;/h2&gt;

&lt;p&gt;At this point, the parsing layer covers all syntax units:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Block Type&lt;/th&gt;
&lt;th&gt;Parse Function&lt;/th&gt;
&lt;th&gt;Complexity&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Text blocks&lt;/td&gt;
&lt;td&gt;&lt;code&gt;parseTextBlock&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Concatenates content, preserves newlines&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Key-value lists&lt;/td&gt;
&lt;td&gt;&lt;code&gt;parseKeyValuePairs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Supports Chinese/English colons, precise error reporting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rule lists&lt;/td&gt;
&lt;td&gt;&lt;code&gt;parseRules&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;High&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Condition expressions, mutex groups, dice expressions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plain text lists&lt;/td&gt;
&lt;td&gt;&lt;code&gt;parsePlainList&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Extracts lines one by one&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Interpolation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ReplacePlaceholders&lt;/code&gt; (runtime)&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Preserved at parse time, substituted at runtime&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The most critical boundary:&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The parser only "reads" — it turns text into structured data.&lt;br&gt;
The engine "computes" — condition evaluation, variable substitution, action execution.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Next, we cross that boundary and enter the engine's runtime. The first engineering challenge the engine faces: &lt;strong&gt;how do we ensure code changes don't break existing parsing behavior?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The answer is &lt;strong&gt;integration testing&lt;/strong&gt; — using a fixed set of &lt;code&gt;.meph&lt;/code&gt; contracts as "watchdogs," comparing parse results against expectations after every change.&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;GitHub: &lt;a href="https://github.com/yuelinghuashu/mephisto" rel="noopener noreferrer"&gt;https://github.com/yuelinghuashu/mephisto&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>go</category>
      <category>dsl</category>
      <category>engineering</category>
    </item>
    <item>
      <title>LLM Narrative Engines, Part 3: Block Scanner and Line Numbers</title>
      <dc:creator>yuelinghuashu</dc:creator>
      <pubDate>Sat, 25 Jul 2026 07:56:12 +0000</pubDate>
      <link>https://dev.to/yuelinghuashu/llm-narrative-engines-part-3-block-scanner-and-line-numbers-4fhd</link>
      <guid>https://dev.to/yuelinghuashu/llm-narrative-engines-part-3-block-scanner-and-line-numbers-4fhd</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Before reading this&lt;/strong&gt;: If you jumped here from a search engine, I'd recommend skimming Part 1's "See the Target" and ".meph Design Principles" sections to get familiar with what &lt;code&gt;.meph&lt;/code&gt; looks like and why it exists. This post assumes you already know what &lt;code&gt;【角色名】&lt;/code&gt; and &lt;code&gt;【规则】&lt;/code&gt; are.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A JSON parser reports &lt;code&gt;position 246&lt;/code&gt;. What a creator needs is &lt;strong&gt;"line 12: missing colon."&lt;/strong&gt; This post is about making that precise error reporting a reality.&lt;/p&gt;




&lt;h2&gt;
  
  
  I. A Narrative Engine's Second Problem: How Do Rules Become Structure?
&lt;/h2&gt;

&lt;p&gt;Once the format is defined, the next problem is &lt;strong&gt;parsing&lt;/strong&gt; — how do we turn text into data the program can work with?&lt;/p&gt;

&lt;p&gt;Two metrics matter here: &lt;strong&gt;parsing correctness&lt;/strong&gt; and &lt;strong&gt;error readability&lt;/strong&gt;. General-purpose formats (JSON/YAML) excel at the first but are a disaster on the second — &lt;code&gt;position 246&lt;/code&gt; means nothing to a creator. And for a custom format, the parser must be written from scratch.&lt;/p&gt;

&lt;p&gt;A well-designed parser should do three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Precise recognition&lt;/strong&gt; — every block and every line must be correctly classified&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Line number binding&lt;/strong&gt; — every error must trace back to a specific line, not a byte offset&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Whitelist validation&lt;/strong&gt; — typos like &lt;code&gt;【脚色名】&lt;/code&gt; (a misspelling of "Character Name") should not be silently treated as valid content&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This post implements all three.&lt;/p&gt;




&lt;h2&gt;
  
  
  II. See the Problem First: What Happens Without Line Numbers
&lt;/h2&gt;

&lt;p&gt;Before diving into the parser, let's look at a real scenario.&lt;/p&gt;

&lt;p&gt;A creator writes a contract with this line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;【锚点】
- 核心信念 "力量就是一切"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice: &lt;code&gt;- 核心信念 "力量就是一切"&lt;/code&gt; is missing a colon. The correct form is &lt;code&gt;- 核心信念: "力量就是一切"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;With a general-purpose parser, the error would be something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;unexpected token at position 42
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The creator has to copy-paste and count characters to figure out where position 42 is. This process is excruciating.&lt;/p&gt;

&lt;p&gt;My goal was to make the parser report:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;第 2 行（区块「锚点」）：缺少 ':' 或 '：'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No counting positions, no understanding what a "token" is — just "line 2, block '锚点': missing ':'."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This difference is the entire reason for writing a custom parser.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  III. Two-Phase Design
&lt;/h2&gt;

&lt;p&gt;I split the parsing into two phases:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Phase&lt;/th&gt;
&lt;th&gt;Responsibility&lt;/th&gt;
&lt;th&gt;Input&lt;/th&gt;
&lt;th&gt;Output&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Block Scanner&lt;/strong&gt; (Lexer)&lt;/td&gt;
&lt;td&gt;Split into blocks, record line numbers&lt;/td&gt;
&lt;td&gt;Raw text&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[]Block&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Structured Parser&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Parse into structured data&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[]Block&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;*domain.Contract&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Key design decision&lt;/strong&gt;: line numbers are bound during the scan phase. The parser receives them pre-attached and never needs to calculate offsets. This means error reporting always has precise, absolute line numbers.&lt;/p&gt;

&lt;p&gt;Two data structures:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Line&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Text&lt;/span&gt;   &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Number&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;  &lt;span class="c"&gt;// absolute line number, starting from 1&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Block&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Title&lt;/span&gt;   &lt;span class="kt"&gt;string&lt;/span&gt;  &lt;span class="c"&gt;// e.g., "角色名"&lt;/span&gt;
    &lt;span class="n"&gt;Content&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;Line&lt;/span&gt;  &lt;span class="c"&gt;// content lines with their line numbers attached&lt;/span&gt;
    &lt;span class="n"&gt;Line&lt;/span&gt;    &lt;span class="kt"&gt;int&lt;/span&gt;     &lt;span class="c"&gt;// the line number of the title line&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With this structure, error reporting becomes straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"第 %d 行（区块「%s」）：列表项必须以 '-' 开头"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;blockName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  IV. Block Scanner: A Simple State Machine
&lt;/h2&gt;

&lt;p&gt;The core is a line-by-line state machine. It has only two states:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;inBlock == false&lt;/code&gt;: currently outside any block&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;inBlock == true&lt;/code&gt;: inside a block, collecting content
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;Lex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="n"&gt;Block&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;blocks&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;Block&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;currentTitle&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;currentContent&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;Line&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;currentLine&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;inBlock&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rawLine&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;lineNumber&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;

        &lt;span class="c"&gt;// Outside a block: skip empty lines&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;inBlock&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TrimSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rawLine&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c"&gt;// Check if this line is a block title&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;isBlockTitle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rawLine&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;inBlock&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="c"&gt;// Save the current block&lt;/span&gt;
                &lt;span class="n"&gt;blocks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;blocks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Block&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="n"&gt;Title&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;   &lt;span class="n"&gt;currentTitle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;Content&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;currentContent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;Line&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;currentLine&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="c"&gt;// Start a new block&lt;/span&gt;
            &lt;span class="n"&gt;currentTitle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;
            &lt;span class="n"&gt;currentContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;Line&lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;
            &lt;span class="n"&gt;currentLine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lineNumber&lt;/span&gt;
            &lt;span class="n"&gt;inBlock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c"&gt;// Non-title line: must be inside a block&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;inBlock&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"第 %d 行：内容出现在任何区块之外"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lineNumber&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;currentContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;currentContent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Line&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;   &lt;span class="n"&gt;rawLine&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;Number&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;lineNumber&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;inBlock&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;blocks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;blocks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Block&lt;/span&gt;&lt;span class="p"&gt;{&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="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;blocks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"没有有效区块"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;blocks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Two key design decisions:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Whitelist first&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;isBlockTitle&lt;/code&gt; only recognizes a predefined set of titles:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;knownBlocks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="s"&gt;"角色名"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"锚点"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;  &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"规则"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;  &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"状态"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;  &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a creator writes &lt;code&gt;【脚色名】&lt;/code&gt; (a typo), the scanner won't treat it as a block start — it will return an error pointing to that line. The exact error message depends on context: if the line appears outside any block, it's "content appears outside any block"; if it appears inside a block, the block's parser will catch it. Either way, the line number is precise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Line number binding&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every line carries its &lt;code&gt;lineNumber&lt;/code&gt; at storage time, never offset. This is the foundation of precise error reporting.&lt;/p&gt;




&lt;h2&gt;
  
  
  V. Parser: Routing to Different Parse Functions
&lt;/h2&gt;

&lt;p&gt;After the scanner outputs &lt;code&gt;[]Block&lt;/code&gt;, the parser routes each block to its corresponding parse function based on &lt;code&gt;Title&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;parseBlocks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;blocks&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;Block&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Contract&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;contract&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Contract&lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;blocks&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Title&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"角色名"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;contract&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RoleName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parseRoleName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"锚点"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;contract&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Anchor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parseKeyValuePairs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"规则"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;contract&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Rules&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parseRules&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c"&gt;// ... other block types&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;contract&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each block type has its own parsing logic. Here's the key-value parser — note that every error includes the line number and block name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;parseKeyValuePairs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lines&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;Line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;blockName&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="n"&gt;KeyValue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;trimmed&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TrimSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;trimmed&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HasPrefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trimmed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"#"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HasPrefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trimmed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"-"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"第 %d 行（区块「%s」）：列表项必须以 '-' 开头"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;blockName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="c"&gt;// ... extract key-value pair&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  VI. Error Message Comparison
&lt;/h2&gt;

&lt;p&gt;Same error, two experiences:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What the user wrote wrong&lt;/th&gt;
&lt;th&gt;Generic parser error&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;.meph&lt;/code&gt; parser error&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;- 核心信念 "力量"&lt;/code&gt; (missing colon)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Unexpected token at position 42&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;第 2 行（区块「锚点」）：缺少 ':' 或 '：'&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;情绪: 暴怒&lt;/code&gt; (missing &lt;code&gt;-&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;invalid character looking for value&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;第 2 行（区块「状态」）：列表项必须以 '-' 开头&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;【脚色名】&lt;/code&gt; (typo)&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;Points to the exact line with an error&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  VII. The Cost
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Approximately 400 lines of Go code&lt;/li&gt;
&lt;li&gt;Need to write independent parsing logic for each block type&lt;/li&gt;
&lt;li&gt;Adding a new block type means updating the whitelist&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But there are no external dependencies. &lt;code&gt;go build&lt;/code&gt; completes in one step. The parsing logic is fully controllable and can be adjusted at any time.&lt;/p&gt;




&lt;h2&gt;
  
  
  VIII. Summary
&lt;/h2&gt;

&lt;p&gt;The block scanner and parser complete the "text to structure" transformation. &lt;code&gt;【角色名】&lt;/code&gt; is now &lt;code&gt;contract.RoleName&lt;/code&gt;. &lt;code&gt;【规则】&lt;/code&gt; is now &lt;code&gt;contract.Rules&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;But the conditions in &lt;code&gt;contract.Rules&lt;/code&gt; — like &lt;code&gt;包含 "攻击"&lt;/code&gt; — and the actions — like &lt;code&gt;注入 "{角色名}的故乡是光之国"&lt;/code&gt; — are still strings. The next post tackles: &lt;strong&gt;how do we parse rule condition-action expressions? How does the &lt;code&gt;{变量}&lt;/code&gt; interpolation syntax work?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Those are the last two pieces of the parsing layer.&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;GitHub: &lt;a href="https://github.com/yuelinghuashu/mephisto" rel="noopener noreferrer"&gt;https://github.com/yuelinghuashu/mephisto&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>go</category>
      <category>dsl</category>
      <category>engineering</category>
    </item>
    <item>
      <title>LLM Narrative Engines, Part 2: Quick Start — Write Your First Contract</title>
      <dc:creator>yuelinghuashu</dc:creator>
      <pubDate>Thu, 23 Jul 2026 13:13:17 +0000</pubDate>
      <link>https://dev.to/yuelinghuashu/llm-narrative-engines-part-2-quick-start-write-your-first-contract-4mb3</link>
      <guid>https://dev.to/yuelinghuashu/llm-narrative-engines-part-2-quick-start-write-your-first-contract-4mb3</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Before reading this&lt;/strong&gt;: If you haven't read the first post, &lt;a href="https://dev.to/yuelinghuashu/narrative-engines-with-llms-from-freeform-to-constrained-generation-1l4c"&gt;From Freeform to Constrained Generation&lt;/a&gt;, I'd recommend starting there to understand &lt;em&gt;why&lt;/em&gt; &lt;code&gt;.meph&lt;/code&gt; exists. This post requires zero prior knowledge — just a terminal and Go 1.26+.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In the previous post, we talked about why &lt;code&gt;.meph&lt;/code&gt; is a better fit for narrative contracts than JSON or YAML. But talk is cheap.&lt;/p&gt;

&lt;p&gt;Then let's stop talking and start building. We'll write a real contract from scratch, compile it, and run it. &lt;strong&gt;You'll have your first narrative running in no time&lt;/strong&gt; — and you'll see, firsthand, how the rules you write drive LLM-generated storytelling.&lt;/p&gt;




&lt;h2&gt;
  
  
  I. What You'll Need
&lt;/h2&gt;

&lt;p&gt;Three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Go 1.26+&lt;/strong&gt; — open a terminal and run &lt;code&gt;go version&lt;/code&gt; to check&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A terminal&lt;/strong&gt; — any OS works&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;(Optional) An LLM API key&lt;/strong&gt; — DeepSeek, OpenAI, or Ollama all work. Not required — the engine runs fine without an LLM.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you have an API key, create a &lt;code&gt;.env&lt;/code&gt; file in the project root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;MEPHISTO_CLIENT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;openai
&lt;span class="nv"&gt;MEPHISTO_MODEL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;deepseek-v4-flash
&lt;span class="nv"&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sk-your-key-here
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  II. Clone and Build
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/yuelinghuashu/mephisto.git
&lt;span class="nb"&gt;cd &lt;/span&gt;mephisto
go build &lt;span class="nt"&gt;-o&lt;/span&gt; ./mephisto ./cmd/mephisto
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If everything goes well, you'll see a binary named &lt;code&gt;mephisto&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  III. Write Your First Contract
&lt;/h2&gt;

&lt;p&gt;Now for the real work. Create a new file &lt;code&gt;data/faust.meph&lt;/code&gt; and fill it from scratch.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.1 Character Name
&lt;/h3&gt;

&lt;p&gt;The first line of any contract is the character name. Mark the block with &lt;code&gt;【角色名】&lt;/code&gt; (Character Name) and write the name directly below:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;【角色名】
Faust
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it — no quotes, no colons, no markup.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.2 Anchor
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;【锚点】&lt;/code&gt; (Anchor) block defines the character's core personality. Use the &lt;code&gt;- key: value&lt;/code&gt; format:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;【锚点】
- 核心信念：Knowledge above all else. I would pay any price for truth.
- 欲望：To experience everything a human can experience.
- 绝对禁忌：I will never admit regret.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are injected directly into the LLM's context — they're the bedrock of the character's behavior.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.3 State
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;【状态】&lt;/code&gt; (State) holds dynamic variables, also in key-value format:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;【状态】
- 灵魂完整度：100
- 情绪：Never satisfied
- 位置：Study
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;State values support three types: numbers, booleans, and strings. The engine infers types automatically at parse time — &lt;code&gt;"100"&lt;/code&gt; becomes the number &lt;code&gt;100&lt;/code&gt;, while &lt;code&gt;"Never satisfied"&lt;/code&gt; stays a string. This means you can write conditions like &lt;code&gt;状态.灵魂完整度 &amp;gt; 50&lt;/code&gt; directly, without manual type conversion.&lt;/p&gt;

&lt;p&gt;At runtime, state is stored as a &lt;code&gt;map[string]any&lt;/code&gt; for fast read/write. Initial order is inherited from the contract, but runtime access is key-based and order-agnostic.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.4 Worldview
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;【世界观】&lt;/code&gt; (Worldview) is multi-line text. Write freely — line breaks are preserved:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;【世界观】
The story takes place in 16th-century Germany, an era where theology and science are locked in tension. Universities are consumed by sterile scholastic debates; real knowledge is suppressed. The world is composed of God, angels, demons, and mortals — Heaven and Hell are real dimensions. Mephistopheles is a hellish emissary, skilled at trapping souls through words and contracts. Soul transactions are legally binding in this world — once a soul contract is signed, there's no revocation.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3.5 Character Background
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;【角色背景】&lt;/code&gt; (Character Background) works alongside Worldview to define the character's history:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;【角色背景】
Faust is a scholar of vast learning — philosophy, medicine, law, and theology. Yet he despairs at the limits of human knowledge: after a lifetime of study, he still cannot touch the essence of the world. In his despair, he signs a contract with Mephistopheles: his soul in exchange for unlimited earthly experience.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3.6 Opening Scene
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;【开局场景】&lt;/code&gt; (Opening Scene) sets the stage when the conversation begins:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;【开局场景】
Late night. Candlelight flickers in the study, books piled high on the desk. Faust stands by the window, gazing at the moonlight. On the corner of the table lies a contract, its ink still wet.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3.7 Rules — The Heart That Brings the Character to Life
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;【规则】&lt;/code&gt; (Rules) is the engine's core. Each rule has three parts: &lt;code&gt;[name] if condition -&amp;gt; action&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;【规则】
[New Desire] if 包含 "追求" || 包含 "想要" || 包含 "体验" -&amp;gt; 注入 "{角色名} feels a new longing kindle in his chest — nothing can stop him from experiencing it all firsthand"
[Mephistopheles] if 包含 "梅菲斯特" || 包含 "契约" -&amp;gt; 注入 "Mephistopheles's voice whispers at {角色名}'s ear: 'Is this what you truly want? Have you considered the price?'"
[Soul's Price] if 包含 "代价" || 包含 "灵魂" -&amp;gt; 注入 "{角色名} looks down at his hands, as if he can see something slowly slipping away"
[Never Satisfied] if 不包含 "放弃" &amp;amp;&amp;amp; 不包含 "满足" -&amp;gt; 注入 "{角色名}'s eyes burn with an unquenchable flame — he still wants more"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rule conditions support logical operators (&lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt;, &lt;code&gt;||&lt;/code&gt;), state comparisons (&lt;code&gt;状态.key &amp;gt; value&lt;/code&gt;), and dice expressions (&lt;code&gt;roll(1d100)&lt;/code&gt;). The most common action is &lt;code&gt;注入&lt;/code&gt; (inject) — it appends a message to memory, which the LLM then weaves naturally into the ongoing narrative.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.8 Validate
&lt;/h3&gt;

&lt;p&gt;Save the file and validate the parse:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./mephisto parse data/faust.meph
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll see JSON output like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"role_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Faust"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"anchor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rules"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If parsing fails, the error tells you exactly what's wrong and where — something like "line 12 (block '锚点'): missing ':' or '：'."&lt;/p&gt;




&lt;h2&gt;
  
  
  IV. First Conversation (No LLM Mode)
&lt;/h2&gt;

&lt;p&gt;The engine runs fine even without an LLM:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./mephisto run data/faust.meph
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll see a welcome message, then enter the conversation loop. Type "I want to experience love":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;命运 &amp;gt; I want to experience love
Faust gazes silently at fate.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No LLM means the engine returns the default response. But rules still fired — the input contained “想要” (want), which matched the condition &lt;code&gt;包含 "想要"&lt;/code&gt;, triggering the [New Desire] rule and writing the injection into memory.&lt;/p&gt;

&lt;p&gt;Type &lt;code&gt;/state&lt;/code&gt; to see the current state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Current state:
  灵魂完整度: 100
  情绪: Never satisfied
  位置: Study
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Type &lt;code&gt;/history&lt;/code&gt; to see the conversation log — your input “I want to experience love” is already recorded as a fate directive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This is how the engine works without an LLM:&lt;/strong&gt; rule matching, injection, state management — everything runs normally. The LLM is just the final “narrative rendering layer.”&lt;/p&gt;




&lt;h2&gt;
  
  
  V. Bring in the LLM
&lt;/h2&gt;

&lt;p&gt;Now add your API key and run again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./mephisto run data/faust.meph &lt;span class="nt"&gt;-debug&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll see the same welcome message, this time with &lt;code&gt;-debug&lt;/code&gt; enabled. The LLM configuration will be displayed first.&lt;/p&gt;

&lt;p&gt;Then type this at the prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;命运 &amp;gt; You want to acquire knowledge beyond human comprehension
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The terminal shows debug output — and then Mephistopheles steps out of the shadows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;🔍 Rule debug mode
----------------------------------------
📌 Checking rule [New Desire] (line 26)
   Condition: 包含 "追求" || 包含 "想要" || 包含 "体验"
   Result: true
   ✅ Triggered → inject "{角色名} feels a new longing kindle in his chest, no...

  The candlelight in the study flickered, casting the parchment rolls and codices that lined the walls into shifting pools of light and shadow. Faust sat slumped at his desk, piled high with manuscripts, his fingertips tracing the worn cover of an old astronomy text. But his gaze drifted past the window, into the pitch-black night. He muttered, his voice hoarse, almost carried away by the draft: “A lifetime of searching, and I've never even pushed open a single door.”

  Then, from the bookshelves behind him, came a sound — soft, like a mouse gnawing at wood, or a stifled chuckle. Faust did not turn. He said coldly: “Is that you again, Wagner? It's late. You needn't bring me any more warm broth.”

  But the footsteps were light — too light for his clumsy apprentice. A low, metallic voice emerged from the darkness: “Wagner is only fit to stoke your fire. I bring a different kind of warmth.” A figure stepped out of the shadows — tall, draped in a sumptuous crimson robe, with a lean face and a smile that seemed to curve just slightly too far. He toyed with a bronze ring between his fingers. He stopped before the desk, leaned forward slightly, and fixed his gaze on Faust's eyes: “You said a moment ago that you've spent your life unable to push open that door. But have you ever considered that doors weren't meant to be pushed?”

  Faust slowly lifted his head and fixed his stare on this uninvited guest. His hand pressed flat on the astronomy text. “Who are you? You enter my study without permission.”

  The man gave a soft laugh and turned the ring in the candlelight. It cast a twisted shadow — as though it were some kind of unutterable sigil. “I am the answer to all your questions — and the price of all your desires.” He extended his hand, palm up, fingers spread. Dim red light pulsed through the lines of his palm. “I can show you the patterns behind the stars. I can let you hear the melody that played at the beginning of creation. I can let you touch the law itself. All I ask in return is one small, insignificant thing.”

  Faust rose to his feet, his sleeve sweeping across the scattered drafts on the desk. Sheets of formulae and notes drifted to the floor. He stared at that outstretched hand for a long time, then finally spoke: “What do you want?”

  The man's smile widened, his voice soft as a feather brushing a blade: “Your soul. But don't worry — you don't use it anyway. It can't solve your equations. It can't carry you to the heavens. Keeping it is just letting it rot with a body that's already decaying.” He withdrew his hand and instead produced a roll of jet-black parchment, which he unfurled across the desk. The surface held no script, only an abyss of darkness that seemed to swallow the light around it. “Sign it, and I will deliver everything at once.”

  Faust's breath quickened. He looked down at that dark expanse, then up at the man's eyes — which reflected the birth and death of countless stars. At last, he reached out. His fingertip touched the parchment, and a chill shot up along his shoulder. He hesitated no longer. He took the quill the man offered, pricked his own thumb, and let the blood fall onto the page.

  Just then, the study door creaked open a crack. Wagner peered in, clutching a dim oil lamp. He saw the stranger in the room, then saw the blood on his master's fingertip. His face went white. Trembling, he cried out: “Master! What are you doing? When did this man enter?”

  Faust didn't stop. The blood-script was already taking form on the parchment. Without turning, he said: “Wagner, close the door. Tonight you will witness a scholar's final wish.” As the words faded, the darkness on the parchment began to churn, spreading through the room like a starless night unfurling — while the crimson-robed man's laughter echoed among the scrolls.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's what those 30 lines of contract you just wrote can generate.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Now try changing just one number.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Open &lt;code&gt;data/faust.meph&lt;/code&gt;, change &lt;code&gt;灵魂完整度&lt;/code&gt; in &lt;code&gt;【状态】&lt;/code&gt; from &lt;code&gt;100&lt;/code&gt; to &lt;code&gt;50&lt;/code&gt;, save, and run again. Type the same line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;命运 &amp;gt; You want to acquire knowledge beyond human comprehension
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same rules, same input, one number different — and the narrative shifts entirely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  The candlelight in the study swayed, casting enormous shadows that crawled across the shelves like living things. Faust slumped in his chair behind a mountain of books. A quill hung between his fingers, the ink long dry on its tip. His empty gaze swept across the Latin, Greek, and Hebrew scrolls — these symbols that had once enraptured him now lay like dried insect husks, drained of meaning.

  “Forty years,” he rasped, barely audible. “Forty years of study, and I have not even touched the edge of the first heaven.”

  From outside came the low cry of a night bird, as though something was answering his despair. At that moment, the study door opened silently, and in came his old servant Wagner, carrying a steaming bowl of herbal broth. He picked his way carefully through the scattered parchment, set the bowl on the corner of the desk, and spoke with evident worry: “Master, you haven't eaten for three days. You've been reading Paracelsus all night — your eyes are bloodshot. If you keep this up…”

  “If I keep this up, what? What's the worst that could happen — this body rotting before my desires do?” Faust's head snapped up, a near-crazed light kindling in his eyes. “Wagner, you've studied for years too. Tell me — have you ever seen the face of truth?”

  Wagner stepped back, daunted by the look in Faust's eyes. He murmured: “Master, this servant believes that truth is God's affair.”

  “God's affair.” Faust repeated the words, then burst into laughter — laughter thick with bitterness. “Exactly. Exactly. We hand everything over to God, fencing ourselves in with theological rails, safe in our pigsties, chewing the dry crusts the priests toss us — and we're grateful for it!” He rose to his feet, knocking over his chair behind him. The candle flame almost died under the wind of his robe. “I'm done with these words, these symbols, this stuff written by men to deceive one another. I need real knowledge — the knowledge that belongs to the ancient ones, the primordial ones.”

  Wagner stared at him in terror, his hands trembling. “Master… what are you saying? Such thoughts are blasphemy…”

  “Blasphemy?” Faust strode to the window and threw it open. The cold night wind rushed in, whipping his gray hair and robe into frenzy. “If seeking truth is blasphemy, then let this blasphemy be complete.” He turned, his eyes burning like live coals. “Wagner, leave. Tonight I need to be alone.”

  Wagner opened his mouth, but finally lowered his head and retreated quickly. The wooden door closed heavily behind him. Faust stood alone at the open window, staring up at the star-filled, silent sky. He whispered: “You've been standing there for billions of years. Do you have nothing to say to this mere mortal?”

  No sooner had the words left his mouth than the candle flame on the desk leaped high, turning into a sheet of blue fire. From its center, a voice spoke — low, elegant, not coming through the air, but echoing directly inside Faust's skull.

  “You're finally ready to listen, Mr. Faust.” The voice carried a smile. “Then allow me to introduce myself.”
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Notice the difference:&lt;/strong&gt; at soul integrity 100, Faust is a “contemplative scholar,” calmly skeptical when Mephistopheles appears — “Who are you? Entering my study without permission.” At soul integrity 50, he becomes a “brilliant scholar teetering on the edge of self-destruction,” shouting at Wagner, redefining the pursuit of knowledge as blasphemy. Even Mephistopheles' entrance changes — not emerging from shadow, but erupting from candle flames, his voice resonating directly inside Faust's skull.&lt;/p&gt;

&lt;p&gt;A single state value changes, and the entire texture of the narrative transforms. &lt;strong&gt;That's the power of state-driven storytelling.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;Look closely at the first output:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Faust's identity as a scholar&lt;/strong&gt; — drawn directly from &lt;code&gt;核心信念：Knowledge above all else&lt;/code&gt; in &lt;code&gt;【锚点】&lt;/code&gt; and the &lt;code&gt;【世界观】&lt;/code&gt; line about spending a lifetime without touching the essence of reality. The LLM faithfully inherited these traits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mephistopheles's appearance&lt;/strong&gt; — triggered by the [New Desire] rule from the input “want.” The injected memory gave the LLM the context: “Faust feels a new longing kindle in his chest.”&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;“Price” and “soul”&lt;/strong&gt; — the LLM naturally introduced the theme of “price,” which triggered the [Soul's Price] rule — even though you didn't explicitly type those words.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;“Never satisfied” as an underlying tone&lt;/strong&gt; — the [Never Satisfied] rule has the condition &lt;code&gt;不包含 "放弃" &amp;amp;&amp;amp; 不包含 "满足"&lt;/code&gt; (doesn't contain “give up” and doesn't contain “satisfied”), which fires on almost every turn. It constantly injects “Faust still wants more,” making this not just a one-turn interaction but a persistent character trait that runs through the entire narrative.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Every rule is doing work.&lt;/strong&gt; Not a single one is wasted.&lt;/p&gt;




&lt;h2&gt;
  
  
  VI. Try Changing Something
&lt;/h2&gt;

&lt;p&gt;Now that you've seen the engine in action, make some changes and see what happens:&lt;/p&gt;

&lt;h3&gt;
  
  
  Change a State Value
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- 灵魂完整度：50
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You already saw the result. Try &lt;code&gt;10&lt;/code&gt; or &lt;code&gt;0&lt;/code&gt; and see how Faust changes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Add a Dice Rule
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Favor of Fate] if 包含 "追求" &amp;amp;&amp;amp; roll(1d100) &amp;gt;= 80 -&amp;gt; 注入 "Fate seems to favor {角色名} — things go more smoothly than expected"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;roll(1d100) &amp;gt;= 80&lt;/code&gt; means: roll a 100-sided die, and the rule only triggers if the result is &amp;gt;= 80. A 20% success rate — not every pursuit is lucky. This introduces real randomness into the story.&lt;/p&gt;

&lt;h3&gt;
  
  
  Run Two Turns, Then Look at the Child Save
&lt;/h3&gt;

&lt;p&gt;After a couple of turns, open &lt;code&gt;data/faust_child.meph&lt;/code&gt; in a text editor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;【状态】
- 灵魂完整度：100
- 情绪：Never satisfied

【记忆】
- Faust feels a new longing kindle in his chest…
- Mephistopheles's voice whispers at Faust's ear…

【历史】
- fate: I want to experience love
- assistant: …
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the engine's Mother-Child save mechanism: &lt;code&gt;faust.meph&lt;/code&gt; is the read-only master contract; &lt;code&gt;faust_child.meph&lt;/code&gt; is the dynamic snapshot containing runtime state, memories, and history. Automatically saved after every conversation.&lt;/p&gt;




&lt;h2&gt;
  
  
  VII. Summary
&lt;/h2&gt;

&lt;p&gt;At this point, you've done three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Written a complete contract&lt;/strong&gt; — 30 lines covering character name, anchor, state, worldview, background, opening scene, and rules&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validated its structure with the parser&lt;/strong&gt; — the &lt;code&gt;parse&lt;/code&gt; command tells you precisely whether your contract is correct&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run the engine and watched the character come alive&lt;/strong&gt; — even without an LLM, rules still fire; with an LLM, every rule you wrote shapes the narrative direction&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Those 30 lines are the contract between you and the engine. The engine ensures the LLM honors it.&lt;/p&gt;




&lt;p&gt;Next post goes under the hood. It answers one critical question: &lt;strong&gt;how does the block scanner precisely recognize &lt;code&gt;【角色名】&lt;/code&gt; and &lt;code&gt;【规则】&lt;/code&gt;? And why can errors report “line 12, block ‘锚点’: missing ':' or '：'” — instead of &lt;code&gt;unexpected token at position 246&lt;/code&gt;?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The answer is a &lt;strong&gt;handwritten block scanner&lt;/strong&gt; — a lightweight lexer that scans line by line, binds precise line numbers, and enforces a block whitelist. See you in the next one.&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;GitHub: &lt;a href="https://github.com/yuelinghuashu/mephisto" rel="noopener noreferrer"&gt;https://github.com/yuelinghuashu/mephisto&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>go</category>
      <category>llm</category>
      <category>dsl</category>
    </item>
    <item>
      <title>LLM Narrative Engines, Part 1: From Freeform to Constrained</title>
      <dc:creator>yuelinghuashu</dc:creator>
      <pubDate>Mon, 20 Jul 2026 13:47:44 +0000</pubDate>
      <link>https://dev.to/yuelinghuashu/narrative-engines-with-llms-from-freeform-to-constrained-generation-1l4c</link>
      <guid>https://dev.to/yuelinghuashu/narrative-engines-with-llms-from-freeform-to-constrained-generation-1l4c</guid>
      <description>&lt;p&gt;On the first day I started building narratives with LLMs, I hit a wall.&lt;/p&gt;

&lt;p&gt;I defined a character: Belial, a fallen Ultraman — arrogant, contemptuous of the Land of Light, obsessed with power. The first three turns were perfect. Every response carried that satisfying villainous edge. By the fifth turn, he started giving me life advice, like a mild-mannered philosopher. By the tenth, he called himself "a guardian of the Land of Light." He had completely forgotten who he was.&lt;/p&gt;

&lt;p&gt;This isn't a model quality issue. It's the fundamental flaw of free generation — LLMs have no built-in mechanism to "enforce rules." You can write "you are an arrogant villain" in the system prompt, but the model's understanding of "arrogant" is probabilistic. Over long contexts, it attenuates, overwritten by user inputs, model outputs, and even token-order drift.&lt;/p&gt;

&lt;p&gt;I needed a way to write down rules that the model must follow. So I started building Mephisto.&lt;/p&gt;

&lt;p&gt;But before writing the engine code, I had to solve a more fundamental problem: &lt;strong&gt;what format should these rules be written in?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The format needs to carry: the character name (single-line), the worldview (multi-line), state variables (key-value pairs), and behavioral rules (condition-action pairs). Each type has a different structure and parsing strategy. The choice of format determines whether the creator spends their time writing stories or debugging syntax.&lt;/p&gt;

&lt;p&gt;This post documents my trade-offs and final decision.&lt;/p&gt;




&lt;h2&gt;
  
  
  I. A Narrative Engine's First Problem: How Do You Express Rules?
&lt;/h2&gt;

&lt;p&gt;A narrative engine's first problem is the &lt;strong&gt;rule expression layer&lt;/strong&gt; — how does the creator tell the engine what this character will and won't do? There are three common approaches:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;General-purpose structured formats&lt;/strong&gt; (JSON/YAML): program-friendly, but cruel to creators — escaping hell, unreadable error messages&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom domain-specific formats&lt;/strong&gt; (like .meph, Ink): a middle ground, but the parser must be written from scratch&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plain text&lt;/strong&gt;: most friendly to creators, but almost impossible for programs to parse precisely&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each approach has a cost. I'll compare them one by one, then explain why I chose the custom path.&lt;/p&gt;




&lt;h2&gt;
  
  
  II. See the Destination: A Real .meph Contract
&lt;/h2&gt;

&lt;p&gt;Before discussing any design decisions, let's look at the finished product. Here's a complete &lt;code&gt;.meph&lt;/code&gt; contract, taken from the project's &lt;code&gt;data/sample.meph&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;【角色名】
贝利亚奥特曼

【锚点】
- 核心信念：力量就是一切
- 说话风格：狂傲、嘲讽、不容置疑

【世界观】
光之国是宇宙中最强大的文明，也是贝利亚的故乡。
但他早已被驱逐，如今他带着对光之国的憎恨归来。

【状态】
- 堕落指数：50
- 情绪：暴怒
- 位置：宇宙空间站

【规则】
[攻击] if 包含 "攻击" -&amp;gt; 注入 "贝利亚发动了猛烈的攻击"
[防御] if 包含 "防御" || 包含 "防守" -&amp;gt; 注入 "贝利亚摆出了防御姿态"
[光之国] if 包含 "光之国" -&amp;gt; 注入 "{角色名}的故乡是光之国，也是他最大的仇恨来源"
[高堕落] if 状态.堕落指数 &amp;gt; 80 -&amp;gt; 状态.情绪 = "癫狂"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the entire contract. The creator sees not &lt;code&gt;{&lt;/code&gt; and &lt;code&gt;}&lt;/code&gt;, not escaped quotes, not indentation levels, but:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A name directly under &lt;code&gt;【角色名】&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Key-value pairs under &lt;code&gt;【锚点】&lt;/code&gt; using &lt;code&gt;- key: value&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Rules under &lt;code&gt;【规则】&lt;/code&gt; using &lt;code&gt;[name] if condition -&amp;gt; action&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Notice &lt;code&gt;{角色名}&lt;/code&gt; in the rules — that's &lt;strong&gt;interpolation syntax&lt;/strong&gt;. The parser stores it as-is, and the engine replaces it at runtime with the current character name. This ensures branches evolve independently without interfering with each other.&lt;/p&gt;

&lt;p&gt;It reads like a document. But the program can parse it precisely, and when something goes wrong, it can tell the creator &lt;strong&gt;"line 12, block '状态': list item must start with '-'"&lt;/strong&gt; — not &lt;code&gt;unexpected token at position 246&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The question is: how did I get here?&lt;/p&gt;




&lt;h2&gt;
  
  
  III. JSON: Friendly to Programs, Hostile to People
&lt;/h2&gt;

&lt;p&gt;JSON was the obvious first choice. Clean structure, simple parsing, mature libraries in every language. I actually built a prototype with JSON — I still have that yellowed printout somewhere, covered in handwritten notes. Then I hit the fatal problem.&lt;/p&gt;

&lt;p&gt;That rule from above, in JSON:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rules"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"攻击"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"condition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"包含 &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;攻击&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"注入 &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;贝利亚发动了猛烈的攻击&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The creator's intent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;包含 "攻击"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In JSON, it becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"condition": "包含 \"攻击\""
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The problem isn't that the syntax is "hard." It's &lt;strong&gt;context switching cost&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The creator can't directly express their intent — they have to constantly think "am I writing JSON or am I writing rules?" Over a large contract, this cost accumulates. You're not reading content; you're constantly verifying how many backslashes are on this line.&lt;/p&gt;

&lt;p&gt;Even worse: error messages. A common mistake — a trailing comma after the last element in a JSON array. The JSON parser reports:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Unexpected token } in JSON at position 246
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The creator has to paste it into an online tool and count characters to figure out where position 246 is. For non-technical users, this experience is devastating.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;JSON is friendly to programs, but hostile to people.&lt;/strong&gt; And the author of this file is a creator, not a programmer.&lt;/p&gt;




&lt;h2&gt;
  
  
  IV. YAML: The Mirage of Simplicity
&lt;/h2&gt;

&lt;p&gt;YAML seemed to fix JSON's readability problem:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;role_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;贝利亚奥特曼&lt;/span&gt;
&lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;攻击&lt;/span&gt;
    &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;包含 "攻击"&lt;/span&gt;
    &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;注入 "贝利亚发动了猛烈的攻击"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Indentation replaced brackets and commas. It looked more like "writing a document."&lt;/p&gt;

&lt;p&gt;But YAML's problems are more insidious than JSON's. They're not syntax errors — they're &lt;strong&gt;logic errors&lt;/strong&gt;. The file parses, but the behavior is wrong.&lt;/p&gt;

&lt;p&gt;Imagine defining a multi-line text block in YAML (a worldview, for example), using the &lt;code&gt;|&lt;/code&gt; marker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;worldview&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
  &lt;span class="s"&gt;光之国是宇宙中最强大的文明。&lt;/span&gt;
  &lt;span class="s"&gt;贝利亚被驱逐后，一直在寻找复仇的机会。&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then you define a list below it. One space off in indentation, and the parser may treat subsequent lines of the multi-line block as children of the list. Result: &lt;strong&gt;the worldview is truncated, the list structure is broken, no error is reported, and the engine behaves unexpectedly.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Even more subtle: Chinese full-width spaces (&lt;code&gt;　&lt;/code&gt;) and English half-width spaces (&lt;code&gt;&lt;/code&gt;) are visually indistinguishable. A creator accidentally types a full-width space instead of a half-width one — YAML parsers don't treat it as indentation. They either throw a syntax error or, worse, misidentify the entire block as a key. Without syntax highlighting, this is almost impossible to debug by eye.&lt;/p&gt;

&lt;p&gt;The creator isn't reading content; they're constantly verifying "how many spaces are on this line." For a hundred-line contract, this cognitive burden never stops.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;YAML's simplicity is a mirage.&lt;/strong&gt; It's friendly to people, but to programs, its rules are more complex and unpredictable than JSON's.&lt;/p&gt;




&lt;h2&gt;
  
  
  V. Plain Text: Free but Ambiguous
&lt;/h2&gt;

&lt;p&gt;Since structured formats have problems, why not skip structure entirely and just write plain text?&lt;/p&gt;

&lt;p&gt;Like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;角色名是贝利亚奥特曼。
世界观是光之国。
规则是如果用户提到攻击，就执行攻击。
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For creators, this is the most natural way — zero learning curve.&lt;/p&gt;

&lt;p&gt;But there's a problem: the program can't understand it. It doesn't know whether the "是" in "角色名是贝利亚奥特曼" is a declaration or a description. It doesn't know the relationship between "世界观是光之国" and the next sentence "规则是...". Natural language is clear to humans, but to programs, it's ambiguous, imprecise, and unparseable.&lt;/p&gt;

&lt;p&gt;If I used plain text, I'd have to design an implicit convention — recognizing blocks by keyword matching, distinguishing entries by line breaks. That's harder to guarantee correctness than an explicit syntax.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plain text is most friendly to creators, but almost unfriendly to programs.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  VI. Three Options Compared
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;th&gt;Creator-Friendly&lt;/th&gt;
&lt;th&gt;Program-Friendly&lt;/th&gt;
&lt;th&gt;Core Problem&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;JSON&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;❌ No&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;td&gt;Escaping hell, unreadable error messages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;YAML&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;⚠️ Partial&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;td&gt;Indentation sensitivity, multi-line vs. list confusion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Plain Text&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;td&gt;❌ No&lt;/td&gt;
&lt;td&gt;Ambiguous structure, unparseable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I needed a format that: &lt;strong&gt;reads like a document, but has structure.&lt;/strong&gt; It had to satisfy two conditions simultaneously:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Creators can write naturally, without learning JSON escaping or YAML indentation rules&lt;/li&gt;
&lt;li&gt;Programs can parse precisely, and on error, tell the creator the exact location and reason&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This meant I couldn't use any existing general-purpose format — I needed to design one specifically for the "narrative contract" scenario.&lt;/p&gt;

&lt;p&gt;Even if only one out of ten users is a minimalist who craves a simpler config — that's still 10%, if you round it. So I decided to go all in on the custom format.&lt;/p&gt;




&lt;h2&gt;
  
  
  VII. .meph Design Principles
&lt;/h2&gt;

&lt;p&gt;Back to that &lt;code&gt;.meph&lt;/code&gt; contract from the beginning. Its design is based on three principles:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Use human language for boundaries, eliminate bracket-phobia&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Creators see &lt;code&gt;【角色名】&lt;/code&gt;, not &lt;code&gt;{&lt;/code&gt; and &lt;code&gt;}&lt;/code&gt;. Chinese guillemets are more natural than curly braces for Chinese-speaking creators. &lt;code&gt;【角色名】&lt;/code&gt; itself says what this section contains — no comments needed.&lt;/p&gt;

&lt;p&gt;More importantly, block titles are restricted to a whitelist (&lt;code&gt;角色名&lt;/code&gt;, &lt;code&gt;锚点&lt;/code&gt;, &lt;code&gt;规则&lt;/code&gt;, &lt;code&gt;状态&lt;/code&gt;, etc.). If a creator writes &lt;code&gt;【脚色名】&lt;/code&gt; (a typo), the parser won't treat it as a block start — the creator will get an error pointing to that exact line.&lt;/p&gt;

&lt;p&gt;(The specific message depends on whether the typo appears inside a block or outside one, but the line number is always precise.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Distinguish "semantic blocks" rather than "data structures"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In JSON, the creator decides whether to use an object or an array — that's an implementation detail. In &lt;code&gt;.meph&lt;/code&gt;, the creator only needs to know "this is a list" or "this is a paragraph." The parser identifies the structure based on the block title — the creator doesn't declare it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Syntax mirrors natural logic&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Rules use the intuitive &lt;code&gt;[name] if condition -&amp;gt; action&lt;/code&gt; form. Condition logic uses readable expressions like &lt;code&gt;包含 "keyword"&lt;/code&gt; and &lt;code&gt;状态.key &amp;gt; value&lt;/code&gt;, not symbolic operators.&lt;/p&gt;




&lt;h2&gt;
  
  
  VIII. The Cost
&lt;/h2&gt;

&lt;p&gt;This design doesn't come without cost:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The parser must be handwritten&lt;/strong&gt;: I can't use &lt;code&gt;json.Unmarshal&lt;/code&gt; or &lt;code&gt;yaml.Unmarshal&lt;/code&gt; — I have to write my own lexer and parser&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The whitelist must be maintained&lt;/strong&gt;: adding a new block type means updating the &lt;code&gt;knownBlocks&lt;/code&gt; list&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Documentation is required&lt;/strong&gt;: creators need to learn the format — though the learning curve is lower than JSON's, it's still a curve&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tooling is absent&lt;/strong&gt;: no off-the-shelf syntax highlighting, formatting, or validation tools&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But the deciding factor is simple: &lt;strong&gt;who writes this file?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If it's a programmer writing for a program, JSON is fine. If it's a creator writing for a program, you need a "human-centric" format. And the target users of a narrative engine are creators — people who write stories.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I believe this trade-off is worth it.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  IX. Summary
&lt;/h2&gt;

&lt;p&gt;This post answered the question "what format to use?" The answer is &lt;code&gt;.meph&lt;/code&gt; — a custom text format designed specifically for narrative contracts, optimized for creator experience.&lt;/p&gt;

&lt;p&gt;But "design" only solves half the problem. The next post puts theory aside and walks through writing a real contract from scratch — so you can see what it actually feels like to use this format.&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;GitHub: &lt;a href="https://github.com/yuelinghuashu/mephisto" rel="noopener noreferrer"&gt;https://github.com/yuelinghuashu/mephisto&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>dsl</category>
      <category>go</category>
      <category>llm</category>
      <category>engineering</category>
    </item>
    <item>
      <title>From Code to npm: Publishing a Vue 3 Component Library and Avoiding Pitfalls</title>
      <dc:creator>yuelinghuashu</dc:creator>
      <pubDate>Wed, 24 Jun 2026 08:21:46 +0000</pubDate>
      <link>https://dev.to/yuelinghuashu/from-code-to-npm-vue-3-component-library-publishing-guide-1b78</link>
      <guid>https://dev.to/yuelinghuashu/from-code-to-npm-vue-3-component-library-publishing-guide-1b78</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;The component library is done, but publishing to npm is the real test: 2FA, network proxies, registry switching, build artifact verification… This guide covers all the pitfalls encountered and the final standardized release workflow in one go.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  1. Introduction
&lt;/h2&gt;

&lt;p&gt;After completing the component library development, the final step is also the crucial one—&lt;strong&gt;publishing to npm&lt;/strong&gt;. This process seems straightforward, but it hides plenty of modern engineering baggage: package name conflicts, strict 2FA validation, security keys (WebAuthn) getting stuck under certain network conditions, frequent registry mirror switching… and—&lt;strong&gt;the correctness of build artifacts&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This article documents my publishing journey from &lt;strong&gt;v0.0.1&lt;/strong&gt; all the way to &lt;strong&gt;v1.5.0&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Pre-Publishing Preparation
&lt;/h2&gt;

&lt;h3&gt;
  
  
  2.1 Build and Verification Pipeline (v1.5.0 Actual)
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;build&lt;/code&gt; script in v1.5.0 is an automated verification pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# package.json scripts&lt;/span&gt;
&lt;span class="s2"&gt;"build"&lt;/span&gt;: &lt;span class="s2"&gt;"pnpm run clean &amp;amp;&amp;amp; vite build &amp;amp;&amp;amp; pnpm run build:types &amp;amp;&amp;amp; pnpm run clean:dts &amp;amp;&amp;amp; pnpm run copy:reset &amp;amp;&amp;amp; pnpm run verify:build &amp;amp;&amp;amp; pnpm run check:size"&lt;/span&gt;,
&lt;span class="s2"&gt;"prepublishOnly"&lt;/span&gt;: &lt;span class="s2"&gt;"pnpm build &amp;amp;&amp;amp; pnpm test"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Responsibilities of each step:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Script&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;vite build&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Bundles JS + CSS artifacts (27 component multi-entry)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;build:types&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Generates &lt;code&gt;.d.ts&lt;/code&gt; type declarations via vue-tsc&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;clean:dts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Cleans up redundant type files from artifacts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;copy:reset&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Copies &lt;code&gt;reset.css&lt;/code&gt; to dist&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;verify:build&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Verifies .js / .d.ts / style.css / reset.css exist for &lt;strong&gt;each component entry&lt;/strong&gt; + export names are correct&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;check:size&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Bundle budget check (Min+Gzip ≤ 25KB)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;prepublishOnly&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Forces build + test before publishing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Core logic of &lt;code&gt;verify-build.js&lt;/code&gt; — preventing missed component entry publications:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Each component: dist/&amp;lt;kebab&amp;gt;.js exists + dist/exports/&amp;lt;Name&amp;gt;.d.ts exists + export name is correct&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;componentName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;kebabName&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;componentEntries&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;jsPath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;distDir&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;kebabName&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.js`&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;dtsPath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;distDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;exports&lt;/span&gt;&lt;span class="dl"&gt;"&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;componentName&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.d.ts`&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="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;existsSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jsPath&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;errors&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="s2"&gt;`Missing component artifact: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;kebabName&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.js`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// Any error → process.exit(1) prevents publishing&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  2.2 Distribution Contract (package.json)
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"moongate-vue"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1.5.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"module"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"main"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./dist/index.js"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"module"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./dist/index.js"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"types"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./dist/index.d.ts"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"exports"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"."&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"types"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./dist/index.d.ts"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"import"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./dist/index.js"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./dist/index.js"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"./style.css"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./dist/style.css"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"./reset.css"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./dist/reset.css"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"./button"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"types"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./dist/exports/Button.d.ts"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"import"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./dist/button.js"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./dist/button.js"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;27&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;component&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;entries&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"files"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"dist"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sideEffects"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"*.css"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"peerDependencies"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"vue"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^3.5.0"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;strong&gt;Key Differences (vs initial version)&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pure ES Module&lt;/strong&gt;: Only &lt;code&gt;.js&lt;/code&gt;, no &lt;code&gt;.cjs&lt;/code&gt; — this is a deliberate choice (all modern bundlers support ESM, and having an &lt;code&gt;import&lt;/code&gt; condition in &lt;code&gt;exports&lt;/code&gt; suffices)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;27 on-demand export entries&lt;/strong&gt;: Users can &lt;code&gt;import Button from 'moongate-vue/button'&lt;/code&gt; to load only the needed component&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;peerDependencies raised to &lt;code&gt;^3.5.0&lt;/code&gt;&lt;/strong&gt;: Because &lt;code&gt;useId()&lt;/code&gt; (Vue 3.5+ API) is used&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sideEffects: ["*.css"]&lt;/code&gt;: Prevents bundlers from tree-shaking away CSS&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;💡 Bundle Size Defense Tip&lt;/strong&gt;: The &lt;code&gt;files&lt;/code&gt; field is a "whitelist". With &lt;code&gt;["dist"]&lt;/code&gt; configured, npm only uploads the &lt;code&gt;dist&lt;/code&gt; directory. &lt;code&gt;package.json&lt;/code&gt;, &lt;code&gt;README.md&lt;/code&gt;, and &lt;code&gt;LICENSE&lt;/code&gt; are automatically included by npm.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;
  
  
  2.3 Managing npm Registry with nrm
&lt;/h3&gt;

&lt;p&gt;&lt;/p&gt;
  🛠️ nrm Registry Switching Details
  &lt;p&gt;Publishing to npm requires the official registry. If you previously switched to a domestic mirror for faster downloads, it's recommended to use &lt;code&gt;nrm&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; nrm
nrm &lt;span class="nb"&gt;ls
&lt;/span&gt;nrm use npm        &lt;span class="c"&gt;# Switch to official registry for publishing&lt;/span&gt;
nrm current
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tip&lt;/strong&gt;: The domestic Taobao npm mirror has migrated to &lt;code&gt;https://registry.npmmirror.com&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;



&lt;p&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  2.4 Local Link Testing
&lt;/h3&gt;

&lt;p&gt;Before publishing to npm, it's best to test in a real project first:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. In the component library directory: build + global link&lt;/span&gt;
pnpm build
pnpm &lt;span class="nb"&gt;link&lt;/span&gt; &lt;span class="nt"&gt;--global&lt;/span&gt;

&lt;span class="c"&gt;# 2. In the test project directory: link local component library&lt;/span&gt;
pnpm &lt;span class="nb"&gt;link&lt;/span&gt; /home/dark/projects/moongate-vue
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Testing checklist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Components render correctly&lt;/li&gt;
&lt;li&gt;[ ] Style file import works (&lt;code&gt;import 'moongate-vue/style.css'&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;[ ] On-demand entry works (&lt;code&gt;import Button from 'moongate-vue/button'&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;[ ] TypeScript type hints work correctly&lt;/li&gt;
&lt;li&gt;[ ] HMR hot reload works&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;: After testing, to remove the link you need: &lt;code&gt;pnpm remove moongate-vue&lt;/code&gt; + manually clean up &lt;code&gt;link:&lt;/code&gt; entries.&lt;/p&gt;


&lt;h2&gt;
  
  
  3. Overcoming the Two-Factor Authentication (2FA) Quagmire
&lt;/h2&gt;

&lt;p&gt;npm requires two-factor authentication to be enabled for publishing. npm has fully embraced the &lt;strong&gt;security key (WebAuthn)&lt;/strong&gt; model.&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;
  🛠️ WebAuthn Network Troubleshooting Details
  &lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;⚠️ Industrial-Grade Pitfall Warning&lt;/strong&gt;: npm's WebAuthn verification attempts to interact with Google's verification service. Under domestic network conditions, using Chrome/Edge to trigger the security key window is &lt;strong&gt;extremely prone to timeouts, unresponsiveness, or errors due to network issues&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Browser&lt;/th&gt;
&lt;th&gt;Requires Global Proxy&lt;/th&gt;
&lt;th&gt;Success Rate&lt;/th&gt;
&lt;th&gt;Recommendation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Chrome&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅ Must enable&lt;/td&gt;
&lt;td&gt;Very high&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;First choice&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Edge&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;❌ Not required&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Second choice&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Firefox&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;❌ Not required&lt;/td&gt;
&lt;td&gt;Very low&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Not recommended&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;br&gt;
&lt;p&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  3.2 CI/CD Alternative: Granular Access Token
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;//registry.npmjs.org/:_authToken&lt;span class="o"&gt;=&lt;/span&gt;your_granular_token_value
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Select &lt;strong&gt;Read and Write&lt;/strong&gt; permissions, and check &lt;strong&gt;"Bypass two-factor authentication for automation"&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Standardized Release Workflow
&lt;/h2&gt;

&lt;h3&gt;
  
  
  4.1 Manual Publishing Steps
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;&lt;code&gt;nrm use npm&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;npm login&lt;/code&gt; (complete 2FA in browser)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;npm version patch|minor|major&lt;/code&gt; (note &lt;code&gt;--no-git-tag-version&lt;/code&gt; is optional)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;npm publish --access public&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;Scoped packages must include &lt;code&gt;--access public&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  4.2 Step-by-Step Automation Scripts
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scripts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"release:pre"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"nrm use npm &amp;amp;&amp;amp; npm run build &amp;amp;&amp;amp; npm test"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"release:version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npm version patch --no-git-tag-version"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"release:tag"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"git add package.json &amp;amp;&amp;amp; git commit -m &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;chore: release v$(node -p 'require(&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;./package.json&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;).version')&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; &amp;amp;&amp;amp; git tag v$(node -p 'require(&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;./package.json&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;).version')"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"release:publish"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npm publish --access public"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"release"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npm run release:pre &amp;amp;&amp;amp; npm run release:version &amp;amp;&amp;amp; npm run release:tag &amp;amp;&amp;amp; npm run release:publish"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  5. Final Pre-Publishing Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] &lt;strong&gt;Build succeeds&lt;/strong&gt;: &lt;code&gt;pnpm run build&lt;/code&gt; completed without errors (including verify-build + check:size)&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Artifacts complete&lt;/strong&gt;: &lt;code&gt;dist/&lt;/code&gt; contains index.js, 27 component .js files, style.css, reset.css, index.d.ts&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Bundle size meets target&lt;/strong&gt;: Min+Gzip ≤ 25KB (confirmed by tree-shake-check.js output)&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Tests pass&lt;/strong&gt;: &lt;code&gt;pnpm test&lt;/code&gt; (450 tests all green)&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Version is clean&lt;/strong&gt;: Current version number has never existed on npm&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Local sandbox verified&lt;/strong&gt;: Styles + on-demand entry + types all working&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;peerDependencies correct&lt;/strong&gt;: vue ^3.5.0&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  6. FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Q1: Publishing returns 403/404?
&lt;/h3&gt;

&lt;p&gt;403 = package name is taken or not logged in. 404 = forgot &lt;code&gt;nrm use npm&lt;/code&gt; and published to a read-only mirror.&lt;/p&gt;

&lt;h3&gt;
  
  
  Q2: 2FA won't show the security key window?
&lt;/h3&gt;

&lt;p&gt;Confirm that the proxy is set to global/TUN mode. If it still fails, use a Granular Access Token.&lt;/p&gt;

&lt;h3&gt;
  
  
  Q3: Users get a white screen after installing styles?
&lt;/h3&gt;

&lt;p&gt;Confirm that &lt;code&gt;sideEffects: ["*.css"]&lt;/code&gt; is configured, and users explicitly &lt;code&gt;import 'moongate-vue/style.css'&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Q4: Users can &lt;code&gt;import 'moongate-vue'&lt;/code&gt; but &lt;code&gt;import 'moongate-vue/button'&lt;/code&gt; fails?
&lt;/h3&gt;

&lt;p&gt;Check whether the &lt;code&gt;/button&lt;/code&gt; sub-path is declared in &lt;code&gt;exports&lt;/code&gt;. v1.5.0 has all 27 components fully configured.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. Conclusion
&lt;/h2&gt;

&lt;p&gt;The publishing phase is often treated as "the last chore," but it's actually just as important as component design—&lt;strong&gt;the correctness of build artifacts, restraint in bundle size, and API stability&lt;/strong&gt; all depend on this stage holding the line.&lt;/p&gt;

&lt;p&gt;Publishing in v1.5.0 is no longer a simple &lt;code&gt;npm publish&lt;/code&gt;, but an automated pipeline guarded by &lt;strong&gt;verify-build.js + tree-shake-check.js + 450 tests&lt;/strong&gt; working together.&lt;/p&gt;

&lt;p&gt;May your component library also cross the quagmire and reach further horizons. 🚀&lt;/p&gt;




&lt;h2&gt;
  
  
  🌙 About Moongate Vue
&lt;/h2&gt;

&lt;p&gt;This article is based on the real publishing practice of &lt;a href="https://github.com/yuelinghuashu/moongate-vue" rel="noopener noreferrer"&gt;Moongate Vue&lt;/a&gt;. Related resources:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Project Repository&lt;/strong&gt;: &lt;a href="https://github.com/yuelinghuashu/moongate-vue" rel="noopener noreferrer"&gt;github.com/yuelinghuashu/moongate-vue&lt;/a&gt; — Minimalist Vue 3 component library, zero dependencies, CSS-first, 25KB gzip&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live Example&lt;/strong&gt;: &lt;a href="https://moongate.top" rel="noopener noreferrer"&gt;moongate.top&lt;/a&gt; — Personal blog, migrated from Nuxt UI v4 to Moongate Vue&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Online Documentation&lt;/strong&gt;: &lt;a href="https://vue.moongate.top" rel="noopener noreferrer"&gt;vue.moongate.top&lt;/a&gt; — Component API and theme customization guide&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>vue</category>
      <category>npm</category>
      <category>publishing</category>
      <category>devops</category>
    </item>
    <item>
      <title>Vue 3 Complex Component Development: API Design for Select and Pagination</title>
      <dc:creator>yuelinghuashu</dc:creator>
      <pubDate>Fri, 19 Jun 2026 11:58:12 +0000</pubDate>
      <link>https://dev.to/yuelinghuashu/vue-3-complex-component-development-api-design-for-select-and-pagination-1gb6</link>
      <guid>https://dev.to/yuelinghuashu/vue-3-complex-component-development-api-design-for-select-and-pagination-1gb6</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Simple components are one-way data consumers; complex components are data adapters and state coordinators. Using Select and Pagination as examples, let's see where the "industrial-grade details" lie.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  1. Introduction
&lt;/h2&gt;

&lt;p&gt;If writing a Button component is about enjoying the "paint-like beauty" of CSS variables, then writing Select and Pagination is about wrestling in the "mud pit" of native HTML's historical baggage. Simple components are one-way data consumers, while complex components are &lt;strong&gt;data adapters&lt;/strong&gt; (compatible with multiple formats) and &lt;strong&gt;state coordinators&lt;/strong&gt; (searchable, multiple selection, keyboard navigation, SSR-safe).&lt;/p&gt;

&lt;p&gt;This article uses &lt;strong&gt;Select&lt;/strong&gt; and &lt;strong&gt;Pagination&lt;/strong&gt; as examples to demonstrate the complex component development approach behind the v1.5.0 implementation.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Select Dropdown: The Data Adapter
&lt;/h2&gt;

&lt;p&gt;The Select component needs to accept a list of options and allow the user to choose one. Real-world APIs may return object arrays, string arrays, or even number arrays, so the component must have robust data adaptation capabilities.&lt;/p&gt;

&lt;h3&gt;
  
  
  2.1 Requirements Analysis
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Support object arrays &lt;code&gt;{ label, value }&lt;/code&gt; (default)&lt;/li&gt;
&lt;li&gt;Support custom field names (&lt;code&gt;labelKey&lt;/code&gt; / &lt;code&gt;valueKey&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Support string arrays &lt;code&gt;['Option A', 'Option B']&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Support number arrays &lt;code&gt;[1, 2, 3]&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Provide a placeholder (non-selectable default option)&lt;/li&gt;
&lt;li&gt;Support disabled options (&lt;code&gt;disabled: true&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Searchable mode&lt;/strong&gt; (filterable): input filtering + dropdown panel&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multiple selection mode&lt;/strong&gt; (multiple + filterable): tag display&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Must solve the type trap of native &lt;code&gt;&amp;lt;select&amp;gt;&lt;/code&gt; returning strings&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full ARIA keyboard navigation&lt;/strong&gt; (listbox + option + aria-activedescendant)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2.2 Dual-Mode Architecture: Native Mode + Searchable Mode
&lt;/h3&gt;

&lt;p&gt;The v1.5.0 Select supports &lt;strong&gt;dual modes&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Native mode&lt;/strong&gt; (default): renders a native &lt;code&gt;&amp;lt;select&amp;gt;&lt;/code&gt;, zero JS overhead&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Searchable mode&lt;/strong&gt; (&lt;code&gt;filterable=true&lt;/code&gt;): renders a custom input + dropdown panel, supporting search/multiple selection/keyboard navigation
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vue"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- Native mode: best performance --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;Select&lt;/span&gt; &lt;span class="na"&gt;v-model=&lt;/span&gt;&lt;span class="s"&gt;"category"&lt;/span&gt; &lt;span class="na"&gt;:options=&lt;/span&gt;&lt;span class="s"&gt;"categories"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- Searchable mode: filtering + dropdown --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;Select&lt;/span&gt; &lt;span class="na"&gt;v-model=&lt;/span&gt;&lt;span class="s"&gt;"fruit"&lt;/span&gt; &lt;span class="na"&gt;:options=&lt;/span&gt;&lt;span class="s"&gt;"fruits"&lt;/span&gt; &lt;span class="na"&gt;filterable&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- Searchable + multiple selection --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;Select&lt;/span&gt; &lt;span class="na"&gt;v-model=&lt;/span&gt;&lt;span class="s"&gt;"tags"&lt;/span&gt; &lt;span class="na"&gt;:options=&lt;/span&gt;&lt;span class="s"&gt;"tags"&lt;/span&gt; &lt;span class="na"&gt;filterable&lt;/span&gt; &lt;span class="na"&gt;multiple&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2.3 API Design and Type Anti-Corruption
&lt;/h3&gt;

&lt;p&gt;To avoid type pollution caused by &lt;code&gt;any&lt;/code&gt;, we use union type narrowing:&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;type&lt;/span&gt; &lt;span class="nx"&gt;SelectValue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;SelectOption&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Props&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;options&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;SelectOption&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;
  &lt;span class="nx"&gt;placeholder&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;Size&lt;/span&gt;
  &lt;span class="nx"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;
  &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;
  &lt;span class="nx"&gt;labelKey&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="c1"&gt;// default 'label'&lt;/span&gt;
  &lt;span class="nx"&gt;valueKey&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="c1"&gt;// default 'value'&lt;/span&gt;
  &lt;span class="nx"&gt;filterable&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="c1"&gt;// searchable mode&lt;/span&gt;
  &lt;span class="nx"&gt;emptyText&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;maxHeight&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="c1"&gt;// dropdown panel max height&lt;/span&gt;
  &lt;span class="nx"&gt;multiple&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="c1"&gt;// multiple selection (requires filterable)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Type backtracking&lt;/strong&gt; solves the problem of native select always returning strings:&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;handleNativeChange&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Event&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;HTMLSelectElement&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rawValue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;

  &lt;span class="c1"&gt;// Find the original type (number or object value) in the original options&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;originalItem&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;props&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="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;getValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;rawValue&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;finalValue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;originalItem&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;getValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;originalItem&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rawValue&lt;/span&gt;

  &lt;span class="nx"&gt;modelValue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;finalValue&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="s2"&gt;change&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;finalValue&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;This logic ensures that a number value bound via &lt;code&gt;v-model&lt;/code&gt; won't accidentally become a string.&lt;/p&gt;

&lt;h3&gt;
  
  
  2.4 Attribute Passthrough Split
&lt;/h3&gt;

&lt;p&gt;This is a key detail of the searchable mode — &lt;strong&gt;which attributes pass through to the native input and which stay on the outer wrapper&lt;/strong&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;attrs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useAttrs&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="cm"&gt;/** Form/aria attributes that pass through to the native form element */&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;formAttrs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;computed&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="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;attrs&lt;/span&gt;&lt;span class="p"&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;key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;aria-&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&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="s2"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;role&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tabindex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&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;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="cm"&gt;/** Remaining attributes retained on the outer wrapper (class/style/events, etc.) */&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wrapperAttrs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;computed&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="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;attrs&lt;/span&gt;&lt;span class="p"&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="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;formAttrs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why is this split necessary? If &lt;code&gt;aria-label&lt;/code&gt; stays on the outer wrapper without being passed through to the actual &lt;code&gt;&amp;lt;input&amp;gt;&lt;/code&gt;, screen readers won't be able to identify the input's accessible name — this triggers an &lt;code&gt;aria-input-field-name&lt;/code&gt; violation in axe-core checks in &lt;code&gt;a11y.test.ts&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  2.5 ARIA Keyboard Navigation (WAI-ARIA Combobox Pattern)
&lt;/h3&gt;

&lt;p&gt;The keyboard navigation in searchable mode follows the WAI-ARIA Combobox pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vue"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- Dropdown panel --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt;
  &lt;span class="na"&gt;v-if=&lt;/span&gt;&lt;span class="s"&gt;"isOpen"&lt;/span&gt;
  &lt;span class="na"&gt;ref=&lt;/span&gt;&lt;span class="s"&gt;"dropdownRef"&lt;/span&gt;
  &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"mg-select-dropdown"&lt;/span&gt;
  &lt;span class="na"&gt;role=&lt;/span&gt;&lt;span class="s"&gt;"listbox"&lt;/span&gt;
  &lt;span class="na"&gt;:aria-label=&lt;/span&gt;&lt;span class="s"&gt;"listboxAriaLabel"&lt;/span&gt;
  &lt;span class="na"&gt;:aria-activedescendant=&lt;/span&gt;&lt;span class="s"&gt;"focusedIndex &amp;gt;= 0 ? getOptionId(focusedIndex) : undefined"&lt;/span&gt;
&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="c"&gt;&amp;lt;!-- Options --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt;
    &lt;span class="na"&gt;v-for=&lt;/span&gt;&lt;span class="s"&gt;"(item, index) in filteredOptions"&lt;/span&gt;
    &lt;span class="na"&gt;:id=&lt;/span&gt;&lt;span class="s"&gt;"getOptionId(index)"&lt;/span&gt;
    &lt;span class="na"&gt;role=&lt;/span&gt;&lt;span class="s"&gt;"option"&lt;/span&gt;
    &lt;span class="na"&gt;:aria-selected=&lt;/span&gt;&lt;span class="s"&gt;"isSelected(item)"&lt;/span&gt;
    &lt;span class="na"&gt;:class=&lt;/span&gt;&lt;span class="s"&gt;"{ 'mg-select-option-focused': focusedIndex === index }"&lt;/span&gt;
    &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="na"&gt;click=&lt;/span&gt;&lt;span class="s"&gt;"selectOption(item)"&lt;/span&gt;
    &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="na"&gt;mouseenter=&lt;/span&gt;&lt;span class="s"&gt;"focusedIndex = index"&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;Supported operations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ArrowDown&lt;/code&gt; / &lt;code&gt;ArrowUp&lt;/code&gt;: move highlight (&lt;code&gt;focusedIndex&lt;/code&gt;) and &lt;code&gt;scrollIntoView&lt;/code&gt; to keep it visible&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Enter&lt;/code&gt;: select the currently highlighted option&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Esc&lt;/code&gt;: close the dropdown&lt;/li&gt;
&lt;li&gt;Each option has a unique &lt;code&gt;id&lt;/code&gt; (based on &lt;code&gt;useId()&lt;/code&gt;) for &lt;code&gt;aria-activedescendant&lt;/code&gt; reference&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2.6 Multiple Selection Mode
&lt;/h3&gt;

&lt;p&gt;Multiple selection (&lt;code&gt;multiple + filterable&lt;/code&gt;) turns &lt;code&gt;modelValue&lt;/code&gt; into an array. The core branching logic for selection:&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;// Multiple selection: toggle selection (remove if selected, append if not)&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;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;multiple&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;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;multipleValues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isAlreadySelected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&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;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;isAlreadySelected&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="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&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;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

  &lt;span class="nx"&gt;modelValue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;
  &lt;span class="c1"&gt;// Multiple selection keeps the dropdown open for continuous selection&lt;/span&gt;
  &lt;span class="nf"&gt;nextTick&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;inputRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;focus&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;// Single selection: close after selection&lt;/span&gt;
&lt;span class="nx"&gt;modelValue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;
&lt;span class="nf"&gt;closeDropdown&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;During multiple selection:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Selected items render as tags; each tag has a delete button with &lt;code&gt;aria-label="Remove {label}"&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The dropdown &lt;strong&gt;stays open&lt;/strong&gt; after selection (convenient for continuous multiple selection)&lt;/li&gt;
&lt;li&gt;The input only displays search text; selected tags appear outside&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. Pagination: The State Coordinator
&lt;/h2&gt;

&lt;h3&gt;
  
  
  3.1 API Design (v1.5.0 Actual)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Props&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;totalPages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="c1"&gt;// total pages (required)&lt;/span&gt;
  &lt;span class="nx"&gt;modelValue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="c1"&gt;// current page (v-model)&lt;/span&gt;
  &lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sm&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;md&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;lg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="nx"&gt;showQuickJump&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="c1"&gt;// first/last page quick jump buttons (default true)&lt;/span&gt;
  &lt;span class="nx"&gt;prevText&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="c1"&gt;// previous page text (uses global i18n)&lt;/span&gt;
  &lt;span class="nx"&gt;nextText&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;firstText&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;lastText&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pagination uses &lt;code&gt;defineModel&amp;lt;number&amp;gt;&lt;/code&gt; to bind the current page:&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;currentPage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;defineModel&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;required&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3.2 Quick Jump + Page Number Editing
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vue"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- Display mode: clickable number → enter editing --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;v-else&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"mg-pagination-current"&lt;/span&gt; &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="na"&gt;click=&lt;/span&gt;&lt;span class="s"&gt;"startEdit"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  {{ currentPage }}
&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- Edit mode: input field --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt;
  &lt;span class="na"&gt;v-if=&lt;/span&gt;&lt;span class="s"&gt;"isEditing"&lt;/span&gt;
  &lt;span class="na"&gt;v-model=&lt;/span&gt;&lt;span class="s"&gt;"inputPage"&lt;/span&gt;
  &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"number"&lt;/span&gt;
  &lt;span class="na"&gt;:min=&lt;/span&gt;&lt;span class="s"&gt;"1"&lt;/span&gt;
  &lt;span class="na"&gt;:max=&lt;/span&gt;&lt;span class="s"&gt;"totalPages"&lt;/span&gt;
  &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="na"&gt;blur=&lt;/span&gt;&lt;span class="s"&gt;"commitJump"&lt;/span&gt;
  &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="na"&gt;keyup.enter=&lt;/span&gt;&lt;span class="s"&gt;"commitJump"&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;Extreme edge-case defense in &lt;code&gt;commitJump&lt;/code&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;commitJump&lt;/span&gt; &lt;span class="o"&gt;=&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="nx"&gt;isEditing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;newPage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inputPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="c1"&gt;// Invalid input: abandon and restore&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isNaN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;inputPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;currentPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;goToPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// clamp to [1, totalPages]&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;goToPage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;newPage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;page&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;newPage&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;newPage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&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;newPage&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;totalPages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;newPage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;totalPages&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;newPage&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;currentPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
  &lt;span class="nx"&gt;currentPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;newPage&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="s2"&gt;change&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;newPage&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;h3&gt;
  
  
  3.3 Global Text (i18n)
&lt;/h3&gt;

&lt;p&gt;v1.5.0 introduced a global text system. All aria-labels and button text in Pagination go through the configuration chain:&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;texts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useTexts&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// reactive global text&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prevTextValue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;computed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prevText&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;texts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;paginationPrev&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;pageInfoLabel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;computed&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nf"&gt;formatTemplate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;texts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;paginationPageInfo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;current&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;currentPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;totalPages&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;Priority: &lt;strong&gt;Component prop &amp;gt; setConfig texts &amp;gt; Built-in language text&lt;/strong&gt;. Text supports &lt;code&gt;{current}&lt;/code&gt;/&lt;code&gt;{total}&lt;/code&gt; template placeholders.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Composable Extraction
&lt;/h2&gt;

&lt;p&gt;Complex components often need to extract shared logic. The core composables in v1.5.0:&lt;/p&gt;

&lt;h3&gt;
  
  
  4.1 useFormField (Shared by Input/Textarea)
&lt;/h3&gt;

&lt;p&gt;Handles &lt;code&gt;v-model&lt;/code&gt; updates + native event passthrough:&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;function&lt;/span&gt; &lt;span class="nf"&gt;useFormField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;modelValue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;emit&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;handleInput&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Event&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="nx"&gt;modelValue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;HTMLInputElement&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;value&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="s2"&gt;input&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// change/focus/blur native event passthrough&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;handleInput&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;handleChange&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;handleBlur&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;handleFocus&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;h3&gt;
  
  
  4.2 useFloating (Shared by Popover/Tooltip)
&lt;/h3&gt;

&lt;p&gt;Custom floating layer positioning engine: viewport flipping + boundary correction + ResizeObserver:&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;function&lt;/span&gt; &lt;span class="nf"&gt;useFloating&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;UseFloatingOptions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Delayed show/hide&lt;/span&gt;
  &lt;span class="c1"&gt;// Position calculation (directional positioning + viewport flipping)&lt;/span&gt;
  &lt;span class="c1"&gt;// Reposition on scroll/window resize&lt;/span&gt;
  &lt;span class="c1"&gt;// ResizeObserver monitors only the floating layer's own size&lt;/span&gt;
  &lt;span class="c1"&gt;// SSR-safe (isBrowser guard)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;triggerRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;floatingRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;visible&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;currentPlacement&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;floatStyle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;show&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hide&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4.3 useOverlayBehavior (Shared by Modal/Drawer, located in useScrollLock.ts)
&lt;/h3&gt;

&lt;p&gt;Scroll locking + ESC close + focus trap:&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;// composables/useScrollLock.ts&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;useOverlayBehavior&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isOpen&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;overlayRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onClose&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="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// body scroll lock (module-level lockCount counter, multi-instance safe)&lt;/span&gt;
  &lt;span class="c1"&gt;// ESC key close&lt;/span&gt;
  &lt;span class="c1"&gt;// Tab focus trap&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This function is defined together with scroll locking logic (&lt;code&gt;lockBodyScroll&lt;/code&gt;/&lt;code&gt;unlockBodyScroll&lt;/code&gt;) in &lt;strong&gt;&lt;code&gt;useScrollLock.ts&lt;/code&gt;&lt;/strong&gt; — scroll lock, ESC close, and focus trap all semantically belong to the "overlay behavior" concern, hence they are placed in the same file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Module-level lock counting&lt;/strong&gt; solves scroll lock conflicts when multiple Modal/Drawer instances are open simultaneously — body scrolling is only restored when the last one closes.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. SSR Adaptation: useId and isBrowser
&lt;/h2&gt;

&lt;p&gt;The SSR adaptation in v1.5.0 is more refined than the initial version, with two core mechanisms:&lt;/p&gt;

&lt;h3&gt;
  
  
  5.1 useId() Ensures Hydration Safety
&lt;/h3&gt;

&lt;p&gt;All components that require &lt;code&gt;id&lt;/code&gt; (Modal/Drawer/Select/Tabs) use Vue 3's &lt;code&gt;useId()&lt;/code&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;selectBaseId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useId&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;getOptionId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&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;selectBaseId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-option-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;useId()&lt;/code&gt; generates consistent IDs on both server and client, avoiding hydration mismatches.&lt;/p&gt;

&lt;h3&gt;
  
  
  5.2 isBrowser Guard
&lt;/h3&gt;

&lt;p&gt;All DOM operations include a browser environment guard:&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;isBrowser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;undefined&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;undefined&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="c1"&gt;// In watch/onMounted/top-level code:&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isBrowser&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Combined with the module-level counter in &lt;code&gt;useScrollLock.ts&lt;/code&gt;, &lt;code&gt;lockBodyScroll()&lt;/code&gt; directly skips DOM operations in non-browser environments.&lt;/p&gt;

&lt;h3&gt;
  
  
  5.3 createOverlay: SSR-Safe Imperative Component
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// composables/createOverlay.ts&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;createOverlay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;component&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;containerClass&lt;/span&gt;&lt;span class="p"&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isBrowser&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="c1"&gt;// SSR returns null&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  6. Data and State Flow
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;External Data ──► Data Adapter ──► Internal State
(options)        (getLabel/        (selected/
                  getValue)         searchText)
   ▲                                  │
   │                                  ▼
Global Env  ◄── State Coordinator ◄── User Interaction
(i18n/SSR)     (watch/event)        (click/keyboard)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Left column&lt;/strong&gt;: External input (data + user actions)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Middle&lt;/strong&gt;: Adaptation and coordination (translating the external world into internal state)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Right column&lt;/strong&gt;: Internal state (component self-managed)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bottom&lt;/strong&gt;: Global environment (i18n / SSR) reverse-constraining component behavior&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  7. Testing Strategy
&lt;/h2&gt;

&lt;p&gt;The v1.5.0 Select has &lt;strong&gt;40+ tests&lt;/strong&gt; and Pagination has &lt;strong&gt;14 tests&lt;/strong&gt;, covering:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;Test Content&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Type Backtracking&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Native mode number array &lt;code&gt;[10,20,30]&lt;/code&gt; still produces a number modelValue after selection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ARIA Navigation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;aria-activedescendant&lt;/code&gt; points to highlighted option, each option has a unique id&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Keyboard Operations&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;ArrowDown/Up highlight, Enter select, Esc close, boundary doesn't overflow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Multiple Selection&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Tag rendering, toggle selection, tag deletion, Enter continuous multiple selection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Edge Cases&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Empty search results, external modelValue change, dropdown stays open on blur&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Accessibility&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;axe-core reports no violations for Select (native + searchable)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Plus &lt;strong&gt;SSR checks&lt;/strong&gt;: &lt;code&gt;renderToString&lt;/code&gt; confirms components don't crash on the server and floating layers are hidden by default.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;Simple Component (Button)&lt;/th&gt;
&lt;th&gt;Complex Component (Select / Pagination)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Props Count&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Fewer (11)&lt;/td&gt;
&lt;td&gt;More (10-15)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Data Format&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Fixed (string)&lt;/td&gt;
&lt;td&gt;Flexible (supports multiple array types, configurable fields, type anti-corruption)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;State Management&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No internal state&lt;/td&gt;
&lt;td&gt;Searchable text, multiple selection array, edit state, dropdown visibility&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Accessibility&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Native semantics&lt;/td&gt;
&lt;td&gt;WAI-ARIA Combobox pattern (listbox/option/activedescendant)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Logic Reuse&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Not needed&lt;/td&gt;
&lt;td&gt;Composables (useFormField/useFloating/useOverlayBehavior)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SSR Adaptation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Automatic&lt;/td&gt;
&lt;td&gt;useId hydration safety + isBrowser guard&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;i18n&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Minimal text&lt;/td&gt;
&lt;td&gt;Global configuration chain (prop &amp;gt; setConfig &amp;gt; built-in)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Testing Strategy&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Snapshots, event triggers&lt;/td&gt;
&lt;td&gt;State combinations, edge cases, keyboard simulation, type backtracking, axe-core&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;An excellent complex component, internally, should be like a vacuum cleaner — accommodating all kinds of bizarre backend data formats (through key mapping and type backtracking) — and externally, should be like a gentleman — coupling with the global environment (i18n, SSR, keyboard) in a restrained manner. &lt;strong&gt;High cohesion, low coupling&lt;/strong&gt; is fully embodied in these two types of components.&lt;/p&gt;




&lt;h2&gt;
  
  
  🌙 About Moongate Vue
&lt;/h2&gt;

&lt;p&gt;This article is from the Moongate Vue Component Library Design Series (4 articles), all content is based on real project practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Project Repository&lt;/strong&gt;: &lt;a href="https://github.com/yuelinghuashu/moongate-vue" rel="noopener noreferrer"&gt;github.com/yuelinghuashu/moongate-vue&lt;/a&gt; — Minimalist Vue 3 component library, zero dependencies, CSS-first, 25KB gzip&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real-world Example&lt;/strong&gt;: &lt;a href="https://moongate.top" rel="noopener noreferrer"&gt;moongate.top&lt;/a&gt; — Personal blog, migrated from Nuxt UI v4 to Moongate Vue&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Online Documentation&lt;/strong&gt;: &lt;a href="https://vue.moongate.top" rel="noopener noreferrer"&gt;vue.moongate.top&lt;/a&gt; — Component API and theme customization guide&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>vue</category>
      <category>vue3</category>
      <category>componentdesign</category>
      <category>typescript</category>
    </item>
  </channel>
</rss>
