@gavin-hu/my-pi-agent 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/README.md +17 -11
  2. package/README.zh-CN.md +13 -8
  3. package/docs/extensions/README.md +1 -1
  4. package/docs/extensions/board.md +286 -123
  5. package/docs/extensions/bytefly.md +381 -223
  6. package/docs/extensions/file-browse.md +14 -7
  7. package/docs/extensions/job.md +1 -2
  8. package/docs/extensions/plan.md +56 -11
  9. package/docs/extensions/session-bus.md +23 -10
  10. package/docs/extensions/team.md +45 -17
  11. package/docs/extensions/wechat.md +2 -1
  12. package/docs/extensions/worktree.md +30 -15
  13. package/extensions/board/commands.ts +23 -5
  14. package/extensions/board/format.ts +107 -9
  15. package/extensions/board/join.ts +1 -1
  16. package/extensions/board/page.ts +170 -94
  17. package/extensions/board/result.ts +9 -0
  18. package/extensions/board/schema.ts +22 -7
  19. package/extensions/board/seat.ts +9 -10
  20. package/extensions/board/snapshot.ts +1 -0
  21. package/extensions/board/tools.ts +186 -21
  22. package/extensions/board/transport/router.ts +27 -108
  23. package/extensions/board/transport/server.ts +18 -29
  24. package/extensions/board/types.ts +67 -0
  25. package/extensions/board/view/client.ts +23 -13
  26. package/extensions/board/view/dialog.ts +43 -0
  27. package/extensions/board/view/keys.ts +28 -24
  28. package/extensions/board/view/live.ts +5 -171
  29. package/extensions/board/view/names.ts +91 -0
  30. package/extensions/board/view/page.ts +48 -88
  31. package/extensions/board/view/render.ts +213 -190
  32. package/extensions/board/view/styles.ts +65 -48
  33. package/extensions/board/view/team.ts +63 -41
  34. package/extensions/bytefly/footer/budget.ts +6 -5
  35. package/extensions/bytefly/footer/collect/entries.ts +12 -4
  36. package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
  37. package/extensions/bytefly/footer/config.ts +19 -8
  38. package/extensions/bytefly/footer/price/price.ts +30 -8
  39. package/extensions/bytefly/footer/register.ts +5 -5
  40. package/extensions/bytefly/footer/render/format.ts +18 -0
  41. package/extensions/bytefly/footer/render/lines.ts +23 -7
  42. package/extensions/bytefly/footer/render/segments.ts +117 -31
  43. package/extensions/bytefly/footer/types.ts +29 -5
  44. package/extensions/bytefly/footer/unpriced.ts +42 -17
  45. package/extensions/bytefly/panel/commands.ts +28 -1
  46. package/extensions/bytefly/panel/filter.ts +68 -0
  47. package/extensions/bytefly/panel/format.ts +16 -15
  48. package/extensions/bytefly/panel/join.ts +56 -13
  49. package/extensions/bytefly/panel/prices.ts +43 -0
  50. package/extensions/bytefly/panel/settings.ts +72 -23
  51. package/extensions/bytefly/panel/tui/entries.ts +26 -8
  52. package/extensions/bytefly/panel/tui/frame.ts +130 -93
  53. package/extensions/bytefly/panel/tui/keys.ts +352 -0
  54. package/extensions/bytefly/panel/tui/list-view.ts +25 -46
  55. package/extensions/bytefly/panel/tui/panes.ts +76 -0
  56. package/extensions/bytefly/panel/tui/section-view.ts +126 -0
  57. package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
  58. package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
  59. package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
  60. package/extensions/bytefly/panel/tui.ts +306 -259
  61. package/extensions/check/commands.ts +2 -1
  62. package/extensions/file-browse/files/classify.ts +136 -0
  63. package/extensions/file-browse/files/files.ts +8 -547
  64. package/extensions/file-browse/files/index.ts +90 -0
  65. package/extensions/file-browse/files/list.ts +261 -0
  66. package/extensions/file-browse/files/tree.ts +101 -0
  67. package/extensions/file-browse/transport/router.ts +0 -2
  68. package/extensions/file-browse/view/emit.ts +51 -0
  69. package/extensions/file-browse/view/html.ts +6 -7
  70. package/extensions/file-browse/view/markdown.ts +11 -13
  71. package/extensions/file-browse/view/raw-html.ts +18 -13
  72. package/extensions/job/runtime.ts +4 -4
  73. package/extensions/plan/feedback.ts +40 -16
  74. package/extensions/plan/format.ts +10 -3
  75. package/extensions/plan/index.ts +17 -1
  76. package/extensions/plan/plan-files.ts +88 -0
  77. package/extensions/plan/plans.ts +19 -23
  78. package/extensions/plan/policy.ts +10 -5
  79. package/extensions/plan/runtime.ts +31 -2
  80. package/extensions/plan/todos.ts +51 -3
  81. package/extensions/plan/tools/exit.ts +10 -1
  82. package/extensions/plan/tools/todo-seed.ts +4 -2
  83. package/extensions/plan/tools/write.ts +12 -1
  84. package/extensions/plan/types.ts +15 -0
  85. package/extensions/plan/web-blocks.ts +89 -0
  86. package/extensions/plan/web-chrome.ts +3 -5
  87. package/extensions/plan/web-client.ts +179 -330
  88. package/extensions/plan/web-collab.ts +3 -3
  89. package/extensions/plan/web-comments.ts +18 -8
  90. package/extensions/plan/web-composer.ts +80 -0
  91. package/extensions/plan/web-draft.ts +40 -0
  92. package/extensions/plan/web-keys.ts +134 -0
  93. package/extensions/plan/web-outline.ts +192 -0
  94. package/extensions/plan/web-page.ts +4 -1
  95. package/extensions/plan/web-plan.ts +150 -0
  96. package/extensions/plan/web-router.ts +0 -1
  97. package/extensions/plan/web-settle.ts +194 -0
  98. package/extensions/rewind/commands/review.ts +2 -1
  99. package/extensions/session-bus/rows.ts +7 -3
  100. package/extensions/session-bus/runtime.ts +100 -38
  101. package/extensions/session-bus/store.ts +14 -2
  102. package/extensions/session-bus/tui.ts +1 -1
  103. package/extensions/session-bus/types.ts +10 -1
  104. package/extensions/subagent/commands.ts +2 -1
  105. package/extensions/team/commands.ts +41 -8
  106. package/extensions/team/format.ts +14 -0
  107. package/extensions/team/index.ts +2 -1
  108. package/extensions/team/tools.ts +30 -9
  109. package/extensions/team/types.ts +17 -0
  110. package/extensions/todo/index.ts +4 -1
  111. package/extensions/todo/nudge.ts +8 -2
  112. package/extensions/wechat/bridge.ts +43 -172
  113. package/extensions/wechat/intake.ts +245 -0
  114. package/extensions/worktree/commands.ts +24 -17
  115. package/extensions/worktree/exit.ts +12 -6
  116. package/extensions/worktree/git.ts +22 -14
  117. package/extensions/worktree/guard.ts +15 -285
  118. package/extensions/worktree/handoff.ts +10 -2
  119. package/extensions/worktree/index.ts +2 -1
  120. package/extensions/worktree/lifecycle.ts +2 -0
  121. package/extensions/worktree/registry.ts +20 -11
  122. package/extensions/worktree/root-tools.ts +13 -3
  123. package/extensions/worktree/runtime.ts +8 -2
  124. package/extensions/worktree/screen.ts +15 -8
  125. package/extensions/worktree/shell.ts +303 -0
  126. package/extensions/worktree/state.ts +1 -1
  127. package/extensions/worktree/status.ts +42 -9
  128. package/extensions/worktree/tools.ts +19 -13
  129. package/lib/README.md +6 -5
  130. package/lib/board/card.ts +192 -3
  131. package/lib/board/links.ts +26 -2
  132. package/lib/board/page-pointer.ts +86 -32
  133. package/lib/board/round-text.ts +11 -2
  134. package/lib/board/round.ts +25 -20
  135. package/lib/board/rules.ts +144 -11
  136. package/lib/board/store.ts +239 -52
  137. package/lib/board/view.ts +94 -10
  138. package/lib/format.ts +22 -0
  139. package/lib/http/serve.ts +3 -0
  140. package/lib/http/sink.ts +34 -9
  141. package/lib/plan-entry.ts +11 -0
  142. package/lib/policy.ts +14 -1
  143. package/lib/session-roster.ts +74 -3
  144. package/lib/spawn.ts +37 -3
  145. package/lib/stray-fields.ts +45 -0
  146. package/lib/tool-names.ts +13 -0
  147. package/lib/tui/glyphs.ts +2 -3
  148. package/lib/web/README.md +52 -2
  149. package/lib/web/client-runtime.ts +123 -0
  150. package/lib/web/index.ts +4 -2
  151. package/lib/web/markup.ts +88 -1
  152. package/lib/web/names.ts +42 -0
  153. package/package.json +1 -1
  154. package/extensions/board/view/actions.ts +0 -306
  155. package/extensions/board/writes.ts +0 -99
  156. package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
  157. package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
  158. package/extensions/job/process.ts +0 -35
package/lib/web/README.md CHANGED
@@ -21,6 +21,47 @@ counts its own stylesheet (`pages.test.ts`, `web.test.ts`). A page rule is
21
21
  namespaced (`review-*`, `browse-*`) and overrides a kit class rather than
22
22
  re-declaring it.
23
23
 
24
+ ## One browser runtime, spliced in
25
+
26
+ Three surfaces render pages from a bundle held as a template literal, and each had written
27
+ the same three helpers for itself: an element builder, a "did this key land in a field?"
28
+ test, and a status line's paint. No type checker and no AST scan compares text inside a
29
+ string, so the plan's `el(tag, className)` and the board's `el(tag, className, text)` were
30
+ two builders, and the plan compared `INPUT` where the board lowercased the tag.
31
+
32
+ `client-runtime.ts` holds one body of each, as ordinary TypeScript, and exports
33
+ `CLIENT_RUNTIME_JS` — the four functions as source, in the order a client reads them. A
34
+ page carries it the way it carries `plural` and its class table: the source crosses the
35
+ boundary, and the page runs the function instead of a copy of it.
36
+
37
+ The bodies are JavaScript on purpose — no annotation survives into the string, and no
38
+ backtick or `${` may appear in one. The DOM is reached through `globalThis`, because this
39
+ project compiles without a DOM lib; the cast names the guarantee the page makes.
40
+
41
+ What stays page-side: what a status *says*. `paintStatus` owns the text, its tooltip, and
42
+ the `bf-status--<tone>` class; the board composes `live · 3m` and appends its own class.
43
+ A shared runtime that also decided the wording would be a second copy of the page's voice.
44
+
45
+ ## Name a class once, in a table
46
+
47
+ A page's document is TypeScript and its client is a string inside a template literal, so
48
+ the two halves cannot import from each other: a class name used to be a literal in each,
49
+ and one assembled at runtime (`"board-card__reason" + kind`) could not be found by
50
+ reading either half. That is how a class reaches a stylesheet with no rule behind it —
51
+ an unstyled element, and nothing fails.
52
+
53
+ So a page declares its vocabulary once, in a `ClassNames` table beside its own modules,
54
+ and both halves read it: the document interpolates `${N.toolbar}`, the bundle carries
55
+ `namesScript(table)`. A name is then a symbol, and a test can compare the set a page
56
+ wears with the set its stylesheet draws, in both directions.
57
+
58
+ `namesScript` is the boundary, and it throws rather than trusting a caller: a value must
59
+ be `KEBAB` — lowercase, hyphenated — because the table is spliced into a template
60
+ literal, and a backtick would end the string while `${` would interpolate at build time.
61
+ The global it binds must be a JavaScript `IDENTIFIER`. The table's *keys* are the page's
62
+ own English; its *values* are what CSS matches, and nothing else in the page may spell
63
+ one.
64
+
24
65
  ## Color is a role
25
66
 
26
67
  `WEB_THEME_TOKENS` is derived from `themes/bytefly-dark.json` and
@@ -99,8 +140,17 @@ choice. Neither page reloads for a theme.
99
140
 
100
141
  ## Markup atoms and escaping
101
142
 
102
- `docHead`, `brandLockup`, `themeToggle`, `chip`, `button`, and `keysSheet` return
103
- strings. Every dynamic **value** goes through `escapeHtml` from
143
+ `docHead`, `brandLockup`, `markIcon`, `tabTitle`, `themeToggle`, `chip`, `button`, and
144
+ `keysSheet` return strings. `docHead` links the mark as the tab icon — two SVG
145
+ data URIs from the same blocks the lockup draws, the dark one plain and the light
146
+ one behind `prefers-color-scheme` — because an icon is its own document and
147
+ cannot read the page's tokens.
148
+
149
+ `tabTitle` is where the three served pages' tab titles agree: `<page> · <subject> ·
150
+ <count>`, the page's name lowercase and the same word its `brandLockup` carries, a part
151
+ the page does not have left out rather than drawn empty. The board's count is live, so
152
+ its page repaints the title on every frame and the body crosses into the bundle as
153
+ `TAB_TITLE_JS`, the way `plural` does — one shape for the string, not two. Every dynamic **value** goes through `escapeHtml` from
104
154
  [`../escape.ts`](../escape.ts); the fields documented as trusted (`css`, an
105
155
  `icon` glyph, an attribute key, a client id) are code-authored constants, never
106
156
  model or file text. A page that renders its own document follows the same rule.
@@ -0,0 +1,123 @@
1
+ /**
2
+ * The browser runtime both page clients splice into their bundles: the element builder,
3
+ * the "is the user typing?" test, the status line, and the JSON POST.
4
+ *
5
+ * Two surfaces had written each of these for themselves, and because a bundle is a
6
+ * template literal no type checker and no AST scan ever compared them: the plan's
7
+ * `el(tag, className)` and the board's `el(tag, className, text)` were two builders, and
8
+ * the plan's `typing` compared `INPUT` where the board's lowercased the tag. Both are
9
+ * now one body, written here as real TypeScript, and each client carries the *source*
10
+ * (`CLIENT_RUNTIME_JS`) the same way it carries `plural` and the class table.
11
+ *
12
+ * The functions are plain JavaScript on purpose: their text is spliced into a bundle, so
13
+ * no annotation may survive into the string and no backtick or `${` may appear in a body.
14
+ * The DOM is reached through `globalThis`, because this project compiles without a DOM
15
+ * lib — the cast names the guarantee the page makes: it is a browser, and it has one.
16
+ */
17
+
18
+ /**
19
+ * The pieces of a DOM node the runtime touches, and the ones a page client needs beyond
20
+ * it. The page supplies the real thing; this is the shape its bundle is typed against.
21
+ */
22
+ export interface ClientNode {
23
+ className: string;
24
+ textContent: string | null;
25
+ title: string;
26
+ hidden: boolean;
27
+ href: string;
28
+ id: string;
29
+ style: { width: string };
30
+ type: string;
31
+ scrollTop: number;
32
+ contains(other: ClientNode | null): boolean;
33
+ focus(): void;
34
+ classList: { add(name: string): void; remove(name: string): void; toggle(name: string, on?: boolean): void };
35
+ setAttribute(name: string, value: string): void;
36
+ getAttribute(name: string): string | null;
37
+ removeAttribute(name: string): void;
38
+ appendChild(child: ClientNode): void;
39
+ remove(): void;
40
+ replaceChildren(): void;
41
+ querySelector(selector: string): ClientNode | null;
42
+ querySelectorAll(selector: string): ArrayLike<ClientNode>;
43
+ scrollIntoView?(options?: { block?: string }): void;
44
+ addEventListener(type: string, listener: (event: { target?: unknown }) => void): void;
45
+ }
46
+
47
+ /** What `document.createElement` has to answer for these helpers. */
48
+ interface RuntimeDocument {
49
+ createElement(tag: string): ClientNode;
50
+ }
51
+
52
+ /**
53
+ * One element with an optional class and text.
54
+ *
55
+ * `text` is set as `textContent`, never parsed: a card title and a plan's prose are both
56
+ * model- or user-written, and the one property that cannot execute is the one to use.
57
+ */
58
+ export function el(tag: string, className?: string, text?: string | null): ClientNode {
59
+ // Reached through `globalThis` and cast here, because this project compiles without a
60
+ // DOM lib; the cast names what the page guarantees — it is a browser, and it has one.
61
+ const node = (globalThis as unknown as { document: RuntimeDocument }).document.createElement(tag);
62
+ if (className) node.className = className;
63
+ if (text !== undefined && text !== null) node.textContent = String(text);
64
+ return node;
65
+ }
66
+
67
+ /**
68
+ * Whether a key event landed in a field, where every key is text rather than a shortcut.
69
+ *
70
+ * The tag is lowercased once, so a document parsed as XHTML (`tagName` upper case) and a
71
+ * page built by `createElement` (`tagName` lower case) both answer the same.
72
+ */
73
+ export function typing(target: { tagName?: string; isContentEditable?: boolean }): boolean {
74
+ if (!target || !target.tagName) return target?.isContentEditable === true;
75
+ const tag = target.tagName.toLowerCase();
76
+ return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable === true;
77
+ }
78
+
79
+ /**
80
+ * Paint a status line: its text, the same text as its tooltip, and its tone's class.
81
+ *
82
+ * The status slot is the page's own id, so the caller passes the node; what is shared is
83
+ * the rule that a tone is a `bf-status--*` class and that the text is also the title (a
84
+ * clamped line has no other way to be read).
85
+ */
86
+ export function paintStatus(node: ClientNode, text: string, kind?: string): void {
87
+ node.textContent = text || "";
88
+ node.title = text || "";
89
+ node.className = kind ? "bf-status bf-status--" + kind : "bf-status";
90
+ }
91
+
92
+ /** One answer from the server: whether it accepted, its status, and its parsed body. */
93
+ interface PostResult {
94
+ ok: boolean;
95
+ status: number;
96
+ body: unknown;
97
+ }
98
+
99
+ /**
100
+ * POST JSON and read the reply's body, whatever the status.
101
+ *
102
+ * A page that only honoured the happy path would report `server returned 409` where the
103
+ * server had said *why*, so the body is parsed on both branches and the caller decides.
104
+ */
105
+ export function postJson(url: string, payload: unknown): Promise<PostResult> {
106
+ return fetch(url, {
107
+ method: "POST",
108
+ headers: { "Content-Type": "application/json" },
109
+ body: JSON.stringify(payload),
110
+ }).then((response: { ok: boolean; status: number; json(): Promise<unknown> }) =>
111
+ response.json().then(
112
+ (body: unknown): PostResult => ({ ok: response.ok, status: response.status, body }),
113
+ (): PostResult => ({ ok: response.ok, status: response.status, body: null }),
114
+ ),
115
+ );
116
+ }
117
+
118
+ /**
119
+ * The runtime, as a bundle receives it: four bodies, in the order a client reads them.
120
+ *
121
+ * `test` holds each to its module body and proves the string is safe to splice.
122
+ */
123
+ export const CLIENT_RUNTIME_JS = [el, typing, paintStatus, postJson].map(String).join("\n\n");
package/lib/web/index.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  /**
2
2
  * The Bytefly web brand system: tokens, base rules, components, layout,
3
- * runtime, and markup atoms. Import from `lib/web/index.ts` rather than a
4
- * leaf module so the surface reads as one kit.
3
+ * runtime, markup atoms, and the class-name table a page's bundle carries. Import
4
+ * from `lib/web/index.ts` rather than a leaf module so the surface reads as one kit.
5
5
  */
6
6
 
7
7
  export * from "./base.ts";
8
+ export * from "./client-runtime.ts";
8
9
  export * from "./components.ts";
9
10
  export * from "./layout.ts";
10
11
  export * from "./markup.ts";
12
+ export * from "./names.ts";
11
13
  export * from "./runtime.ts";
12
14
  export * from "./tokens.ts";
package/lib/web/markup.ts CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { escapeHtml } from "../escape.ts";
10
10
  import { THEME_JS_PATH } from "./runtime.ts";
11
+ import { WEB_THEME_TOKENS } from "./tokens.ts";
11
12
 
12
13
  /** Options for {@link docHead}. */
13
14
  export interface DocHeadOptions {
@@ -17,6 +18,29 @@ export interface DocHeadOptions {
17
18
  css: string;
18
19
  }
19
20
 
21
+ /**
22
+ * A page's browser-tab title: `<page> · <subject> · <count>`.
23
+ *
24
+ * One convention across the served pages, so a strip of tabs reads as one product. The
25
+ * page's name comes first, lowercase and the same word its header's `brandLockup`
26
+ * carries; then what the page is showing; then the count a reader opens the tab for. A
27
+ * part the page does not have is left out rather than drawn empty, so a board with
28
+ * nothing open reads `board · my-pi-agent` and a plan reads `plan review · <its title>`.
29
+ */
30
+ export function tabTitle(product: string, subject?: string, count?: string): string {
31
+ const kept = [product, subject, count];
32
+ return kept.filter((part) => part !== undefined && part !== "").join(" · ");
33
+ }
34
+
35
+ /**
36
+ * The same body, as the browser receives it.
37
+ *
38
+ * The board's count is live, so its tab is repainted on every frame and this rule has to
39
+ * cross into the bundle. It is written as concatenation with no backtick and no
40
+ * interpolation, because the bundle is a template the source is spliced into.
41
+ */
42
+ export const TAB_TITLE_JS = String(tabTitle);
43
+
20
44
  /**
21
45
  * The shared document head: charset, viewport, colour scheme, title, the page
22
46
  * CSS, and the pre-paint theme bootstrap. The body and its scripts stay with
@@ -27,17 +51,80 @@ export function docHead(options: DocHeadOptions): string {
27
51
  <meta name="viewport" content="width=device-width, initial-scale=1">
28
52
  <meta name="color-scheme" content="dark light">
29
53
  <title>${escapeHtml(options.title)}</title>
54
+ ${markIcon()}
30
55
  <style>${options.css}</style>
31
56
  <script src="${THEME_JS_PATH}"></script>`;
32
57
  }
33
58
 
59
+ /**
60
+ * The mark's blocks: `█▀█` over `█▀ █`, as `[x, height]` per row in a 4x2 grid.
61
+ *
62
+ * One shape, three places — the header lockup, the tab icon, and the TUI header's
63
+ * half-blocks — so it is written once. The rows differ: the top fills x=2, the bottom
64
+ * leaves it empty and fills x=3, which is what makes the mark read as two glyphs.
65
+ */
66
+ const MARK_ROWS: ReadonlyArray<{ group: "top" | "bottom"; rects: ReadonlyArray<readonly [number, number]> }> = [
67
+ {
68
+ group: "top",
69
+ rects: [
70
+ [0, 1],
71
+ [1, 0.5],
72
+ [2, 1],
73
+ ],
74
+ },
75
+ {
76
+ group: "bottom",
77
+ rects: [
78
+ [0, 1],
79
+ [1, 0.5],
80
+ [3, 1],
81
+ ],
82
+ },
83
+ ];
84
+
85
+ /** One row of the mark, as `<rect>`s starting at `y`. */
86
+ function markRowRects(row: (typeof MARK_ROWS)[number], y: number): string {
87
+ return row.rects.map(([x, height]) => `<rect x="${x}" y="${y}" width="1" height="${height}"/>`).join("");
88
+ }
89
+
90
+ /**
91
+ * The mark as a standalone SVG, in one appearance's colours.
92
+ *
93
+ * A favicon is its own document: it cannot read the page's tokens, so the fills are
94
+ * resolved here from the same palette the page paints with, and each appearance gets
95
+ * its own icon.
96
+ */
97
+ function markSvg(fills: { top: string; bottom: string }): string {
98
+ const rows = MARK_ROWS.map((row, index) => `<g fill="${fills[row.group]}">${markRowRects(row, index)}</g>`);
99
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4 2">${rows.join("")}</svg>`;
100
+ }
101
+
102
+ /**
103
+ * The tab icon: the mark, in the appearance the reader's system is in.
104
+ *
105
+ * The dark icon is the plain `rel="icon"` — the page paints dark at `:root`, and a
106
+ * browser that reads no `media` query on an icon keeps it. The light one is the
107
+ * override, so a light tab does not carry a mark tuned for a dark one.
108
+ */
109
+ export function markIcon(): string {
110
+ const dark = encodeURIComponent(
111
+ markSvg({ top: WEB_THEME_TOKENS.dark["--accent"], bottom: WEB_THEME_TOKENS.dark["--warn"] }),
112
+ );
113
+ const light = encodeURIComponent(
114
+ markSvg({ top: WEB_THEME_TOKENS.light["--accent"], bottom: WEB_THEME_TOKENS.light["--warn"] }),
115
+ );
116
+ return `<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,${dark}">
117
+ <link rel="icon" type="image/svg+xml" media="(prefers-color-scheme: light)" href="data:image/svg+xml,${light}">`;
118
+ }
119
+
34
120
  /**
35
121
  * The Bytefly nameplate: the two-tone half-block mark, the `bytefly` wordmark,
36
122
  * and the product name. The mark mirrors the TUI header (`█▀█` / `█▀ █`), drawn
37
123
  * as a 4x2 grid so the accent and warning rows stay crisp at any zoom.
38
124
  */
39
125
  export function brandLockup(product: string): string {
40
- return `<span class="bf-brand"><svg class="bf-mark" viewBox="0 0 4 2" aria-hidden="true"><g class="bf-mark__top"><rect x="0" y="0" width="1" height="1"/><rect x="1" y="0" width="1" height="0.5"/><rect x="2" y="0" width="1" height="1"/></g><g class="bf-mark__bottom"><rect x="0" y="1" width="1" height="1"/><rect x="1" y="1" width="1" height="0.5"/><rect x="3" y="1" width="1" height="1"/></g></svg><span class="bf-wordmark">bytefly</span><span class="bf-context">${escapeHtml(product)}</span></span>`;
126
+ const rows = MARK_ROWS.map((row, index) => `<g class="bf-mark__${row.group}">${markRowRects(row, index)}</g>`);
127
+ return `<span class="bf-brand"><svg class="bf-mark" viewBox="0 0 4 2" aria-hidden="true">${rows.join("")}</svg><span class="bf-wordmark">bytefly</span><span class="bf-context">${escapeHtml(product)}</span></span>`;
41
128
  }
42
129
 
43
130
  /**
@@ -0,0 +1,42 @@
1
+ /**
2
+ * A page's class names, as a table a bundle can carry.
3
+ *
4
+ * A browser page in this package is a TypeScript document plus a client script held in
5
+ * a template literal, so the two halves cannot import from each other: a class name is a
6
+ * string in one and a string in the other, and a name built by concatenation
7
+ * (`"board-card__reason" + kind`) cannot be found by reading either. A table fixes both:
8
+ * the document and the bundle read the same values, and a test can compare the set the
9
+ * page wears with the set its stylesheet draws.
10
+ *
11
+ * The bundle carries the table as **source**, so every value must survive being spliced
12
+ * into a template literal: a backtick would end the string, and `${` would interpolate
13
+ * at build time. `KEBAB` is that contract, and {@link namesScript} throws rather than
14
+ * trusting a caller — a bad name is a build error here, not a mystery on the page.
15
+ */
16
+
17
+ /** A page's vocabulary: a key per thing it dresses, and the class it wears. */
18
+ export type ClassNames = Readonly<Record<string, string>>;
19
+
20
+ /** What a class name may be: lowercase, hyphenated, and nothing a script could misread. */
21
+ export const KEBAB = /^[a-z][a-z0-9_-]*$/;
22
+
23
+ /** What the global the table is bound to may be: a JavaScript identifier, and nothing else. */
24
+ export const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
25
+
26
+ /** The declared global the table is bound to in a page's bundle. */
27
+ export const NAMES_GLOBAL = "N";
28
+
29
+ /**
30
+ * The table as one script statement, for a client bundle to start with.
31
+ *
32
+ * `JSON.stringify` is the serializer because the values are already strings that pass
33
+ * {@link KEBAB}; the check before it is what makes that true, and its message names the
34
+ * offending key and value so the fix is obvious.
35
+ */
36
+ export function namesScript(table: ClassNames, global: string = NAMES_GLOBAL): string {
37
+ for (const [key, value] of Object.entries(table)) {
38
+ if (!KEBAB.test(value)) throw new Error(`the class name for "${key}" is not a kebab-case name: "${value}"`);
39
+ }
40
+ if (!IDENTIFIER.test(global)) throw new Error(`"${global}" is not a name a script can declare`);
41
+ return `var ${global} = ${JSON.stringify(table)};`;
42
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gavin-hu/my-pi-agent",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Personal Pi customizations, packaged as a Pi package: worktree isolation and rewind snapshots, question, todo, goal, plan, subagent, job, watch, web-access, file-browse, file-open, file-read, check, memory, context, session-bus, board, team, wechat, and bytefly.",
5
5
  "keywords": [
6
6
  "pi-package",