@litfamily/litclaude 1.0.30 → 1.0.31

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.31 - 2026-10-17
4
+
5
+ - In the Claude Code desktop app, the mod card used to blink: every real change, such as a new step, tool or skill, swapped in a whole new page, so the card went blank for a moment and its animations started over. The desktop card is now built from the app's own boxes and text, and a change updates only what moved. It looks the same but no longer animates. The terminal and phone cards are unchanged.
6
+
3
7
  ## 1.0.30 - 2026-10-16
4
8
 
5
9
  - The pre-write guard now refuses more of the lines that tell a reader what a document is not for, such as `This table is not a ranking.` or `이 문서는 제품을 비교하려는 것이 아닙니다.` These forms came from real sessions. Bold-label callouts such as `> **주의:** ...` are now read too, while ordinary quoted text is still skipped. Lines from manuals and lab reports that only look similar stay allowed, and a large flood of matches can no longer push the scan past its limit into an allowed write.
package/README.md CHANGED
@@ -1,14 +1,14 @@
1
- <p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.30/docs/assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.30/docs/assets/cover-motion.webp" width="100%" alt="LitFamily motion cover: five armored robots power on one by one, the LitClaude robot wakes with glowing eyes and a lit frame, then LITFAMILY and KEEP THE WORK LIT. light up." /></picture></p>
1
+ <p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.31/docs/assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.31/docs/assets/cover-motion.webp" width="100%" alt="LitFamily motion cover: five armored robots power on one by one, the LitClaude robot wakes with glowing eyes and a lit frame, then LITFAMILY and KEEP THE WORK LIT. light up." /></picture></p>
2
2
 
3
3
  <h1 align="center">LitClaude</h1>
4
4
  <p align="center"><strong>Keep the work lit.</strong></p>
5
5
  <p align="center">A Claude Code plugin that helps every session plan the work, build it, check it, and leave a note the next session can pick up.</p>
6
6
  <p align="center">
7
- <img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.30/docs/assets/readme/badge-version.svg" alt="1.0.30" />
8
- <a href="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.30/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.30/docs/assets/readme/badge-license.svg" alt="MIT license" /></a>
7
+ <img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.31/docs/assets/readme/badge-version.svg" alt="1.0.31" />
8
+ <a href="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.31/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.31/docs/assets/readme/badge-license.svg" alt="MIT license" /></a>
9
9
  </p>
10
10
  <p align="center">
11
- <a href="https://github.com/wjgoarxiv/litclaude#readme">Full guide and skills gallery on GitHub</a> · <a href="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.30/README_ko-KR.md">한국어</a>
11
+ <a href="https://github.com/wjgoarxiv/litclaude#readme">Full guide and skills gallery on GitHub</a> · <a href="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.31/README_ko-KR.md">한국어</a>
12
12
  </p>
13
13
 
14
14
  Add `lit` to a Claude Code prompt and Claude pins the goal, writes a failing test first,
@@ -73,7 +73,7 @@ the `/plugin` menu.
73
73
  ## More
74
74
 
75
75
  - [Full guide and skills gallery on GitHub](https://github.com/wjgoarxiv/litclaude#readme)
76
- - [Privacy and network behavior](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.30/docs/privacy.md)
77
- - [Migration and ownership conflicts](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.30/docs/migration.md)
78
- - [Release history](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.30/CHANGELOG.md)
79
- - [MIT license](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.30/LICENSE)
76
+ - [Privacy and network behavior](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.31/docs/privacy.md)
77
+ - [Migration and ownership conflicts](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.31/docs/migration.md)
78
+ - [Release history](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.31/CHANGELOG.md)
79
+ - [MIT license](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.31/LICENSE)
package/README_ko-KR.md CHANGED
@@ -1,14 +1,14 @@
1
- <p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.30/docs/assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.30/docs/assets/cover-motion.webp" width="100%" alt="LitFamily 모션 커버: 다섯 로봇 패널이 차례로 켜지고, LitClaude 로봇의 눈과 테두리가 빛난 뒤 LITFAMILY와 KEEP THE WORK LIT. 문구가 밝아지는 영상" /></picture></p>
1
+ <p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.31/docs/assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.31/docs/assets/cover-motion.webp" width="100%" alt="LitFamily 모션 커버: 다섯 로봇 패널이 차례로 켜지고, LitClaude 로봇의 눈과 테두리가 빛난 뒤 LITFAMILY와 KEEP THE WORK LIT. 문구가 밝아지는 영상" /></picture></p>
2
2
 
3
3
  <h1 align="center">LitClaude</h1>
4
4
  <p align="center"><strong>Keep the work lit.</strong></p>
5
5
  <p align="center">Claude Code에서 세션마다 계획하고, 만들고, 확인한 뒤 다음 세션이 이어받을 기록을 남기게 돕는 플러그인입니다.</p>
6
6
  <p align="center">
7
- <img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.30/docs/assets/readme/badge-version.svg" alt="1.0.30" />
8
- <a href="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.30/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.30/docs/assets/readme/badge-license.svg" alt="MIT license" /></a>
7
+ <img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.31/docs/assets/readme/badge-version.svg" alt="1.0.31" />
8
+ <a href="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.31/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.31/docs/assets/readme/badge-license.svg" alt="MIT license" /></a>
9
9
  </p>
10
10
  <p align="center">
11
- <a href="https://github.com/wjgoarxiv/litclaude/blob/main/README_ko-KR.md">전체 안내와 스킬 갤러리는 GitHub에서</a> · <a href="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.30/README.md">English</a>
11
+ <a href="https://github.com/wjgoarxiv/litclaude/blob/main/README_ko-KR.md">전체 안내와 스킬 갤러리는 GitHub에서</a> · <a href="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.31/README.md">English</a>
12
12
  </p>
13
13
 
14
14
  Claude Code 프롬프트에 `lit`을 붙이면 Claude가 목표를 고정하고, 실패하는 테스트를 먼저 쓰고,
@@ -71,7 +71,7 @@ LitClaude는 프로젝트 상태를 `.litclaude/`에 두고, 그 안의 `.gitign
71
71
  ## 더 보기
72
72
 
73
73
  - [전체 안내와 스킬 갤러리 (GitHub)](https://github.com/wjgoarxiv/litclaude/blob/main/README_ko-KR.md)
74
- - [개인정보와 네트워크 동작](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.30/docs/privacy.md)
75
- - [이전과 소유권 충돌](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.30/docs/migration.md)
76
- - [변경 이력](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.30/CHANGELOG.md)
77
- - [MIT 라이선스](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.30/LICENSE)
74
+ - [개인정보와 네트워크 동작](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.31/docs/privacy.md)
75
+ - [이전과 소유권 충돌](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.31/docs/migration.md)
76
+ - [변경 이력](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.31/CHANGELOG.md)
77
+ - [MIT 라이선스](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.31/LICENSE)
@@ -1 +1 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="146" height="20" role="img" aria-label="release: 1.0.30"><title>release: 1.0.30</title><g shape-rendering="crispEdges"><rect width="49" height="20" fill="#080d14"/><rect x="49" width="97" height="20" fill="#ff6337"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text x="255" y="140" textLength="390" transform="scale(.1)">release</text><text x="965" y="140" textLength="870" transform="scale(.1)" fill="#080D14">1.0.30</text></g></svg>
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="146" height="20" role="img" aria-label="release: 1.0.31"><title>release: 1.0.31</title><g shape-rendering="crispEdges"><rect width="49" height="20" fill="#080d14"/><rect x="49" width="97" height="20" fill="#ff6337"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text x="255" y="140" textLength="390" transform="scale(.1)">release</text><text x="965" y="140" textLength="870" transform="scale(.1)" fill="#080D14">1.0.31</text></g></svg>
package/docs/hooks.md CHANGED
@@ -462,15 +462,22 @@ step's spinner (`- \ | /`) and clock are ASCII, and a glyph swaps only together
462
462
 
463
463
  The desktop app's Code tab, and the other surfaces that are not a terminal, draw a mod's `Text` in
464
464
  a proportional font and cut each span to the cells it was laid out for, so the card above comes
465
- apart there. On those surfaces the mod draws the same five rows as one `Svg` element instead
466
- (`lib/mod/svg-card.mjs`): monospace text placed by counted cells, real pills and a rounded border.
467
- It shows the settled state, with no entrances or crossfades. While LitClaude works it moves with
468
- SMIL animation inside the drawing, which the surface runs in a script-less sandboxed frame: the
469
- pill and the border breathe and the step's spinner turns, and on `full` a light crosses the
470
- current stage and the current chips, a head runs the rail leaving it, the count bar's head
471
- shimmers and a comet runs around the border. An idle card is a still image. The app loads each new
472
- drawing from scratch and restarts its animation, so this card names the running tool without its
473
- clock and shows the loop round without its countdown. It is redrawn only when something you would
465
+ apart there. In the desktop app the mod builds the same five rows from the app's own boxes and
466
+ text instead (`lib/mod/native-card.mjs`): a dark card with a rounded border, the LIT pill, the
467
+ stage pills, the running tool, the skill chips and the usage figures, each row laid out by the app
468
+ in its monospaced face. The app updates that card in place when something changes, so a new stage,
469
+ tool or skill no longer blanks it. It holds still: the app's boxes and text have no animation, and
470
+ the card shows the settled state with no entrances or crossfades.
471
+
472
+ On a phone the card is one `Svg` drawing (`lib/mod/svg-card.mjs`): monospace text placed by
473
+ counted cells, real pills and a rounded border. While LitClaude works it moves with SMIL animation
474
+ inside the drawing: the pill and the border breathe and the step's spinner turns, and on `full` a
475
+ light crosses the current stage and the current chips, a head runs the rail leaving it, the count
476
+ bar's head shimmers and a comet runs around the border. An idle card is a still image.
477
+
478
+ Neither the desktop card nor the phone drawing has the step clock or the loop countdown: a new
479
+ drawing restarts the phone's animation, and the desktop card would only show a frozen clock. Both
480
+ name the running tool and show the loop round, and are redrawn only when something you would
474
481
  notice changes, such as the stage, the tool, a skill, a usage figure or the goal. While the card
475
482
  shows in a terminal and the app at once, the terminal's loop countdown and lane times wait for such
476
483
  a change too; its step clock keeps counting on its own.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@litfamily/litclaude",
3
- "version": "1.0.30",
3
+ "version": "1.0.31",
4
4
  "description": "Claude Code plugin: add lit to a prompt and Claude plans, tests, checks and reviews the work, then leaves a handoff for the next session.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "litclaude",
3
3
  "description": "Claude Code-native workflow plugin with a bounded-authority start-work lifecycle.",
4
- "version": "1.0.30",
4
+ "version": "1.0.31",
5
5
  "author": {
6
6
  "name": "LitClaude contributors"
7
7
  },
@@ -9,7 +9,7 @@ import {
9
9
  } from "../lib/wikify-knowledge.mjs";
10
10
 
11
11
  const protocolVersion = "2024-11-05";
12
- const serverVersion = "1.0.30";
12
+ const serverVersion = "1.0.31";
13
13
 
14
14
  const publicSourceReadTool = {
15
15
  name: "public_source_read",
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { composeBand, composeEvidence } from "../lib/mod/frame.mjs";
16
16
  import { TIMINGS } from "../lib/mod/motion.mjs";
17
+ import { composeNativeCard } from "../lib/mod/native-card.mjs";
17
18
  import { renderBand, renderRows } from "../lib/mod/render.mjs";
18
19
  import { accentFromSettings, normalizeModSettings, resolveGlyphs } from "../lib/mod/settings.mjs";
19
20
  import { composeSvgCard } from "../lib/mod/svg-card.mjs";
@@ -101,17 +102,18 @@ const stillModel = (model) => model && {
101
102
  };
102
103
 
103
104
  // What the card would show on each surface still attached. Invalidation redraws every surface at
104
- // once, and the desktop's Svg is loaded afresh with each new source, restarting its animations; so
105
- // while a drawing surface is attached, time alone never redraws: it is compared by its drawing and
106
- // the terminal by the still model (its surface module counts the step clock on by itself). A
107
- // terminal on its own is compared by the whole model, so its countdown and lane times move too.
105
+ // once. A phone's Svg is loaded afresh with each new source, restarting its animations, and the
106
+ // desktop's own-element card shows no clock; so while either is attached, time alone never
107
+ // redraws: the Svg is compared by its drawing, the desktop and the terminal by the still model (the
108
+ // terminal's surface module counts the step clock on by itself). A terminal on its own is compared
109
+ // by the whole model, so its countdown and lane times move too.
108
110
  function drawnKey(live, model) {
109
111
  const kinds = new Set(Object.values(live.drawnOn));
110
112
  if (kinds.size === 0) return JSON.stringify(model);
111
- const withSvg = kinds.has("svg");
113
+ const still = kinds.has("svg") || kinds.has("native");
112
114
  return [...kinds].map((kind) => (kind === "svg"
113
115
  ? (model?.visible ? composeSvgCard(model, { motion: live.motion, accent: live.accent }).source : "hidden")
114
- : JSON.stringify(withSvg ? stillModel(model) : model))).join("\n");
116
+ : JSON.stringify(still ? stillModel(model) : model))).join("\n");
115
117
  }
116
118
 
117
119
  async function rebuild($, live) {
@@ -373,10 +375,14 @@ export const register = (on, options) => {
373
375
  const elements = $.ui.resolve(e);
374
376
  const columns = Math.max(20, e.props.bodyColumns ?? e.viewport?.columns ?? 80);
375
377
  let drawn = null;
376
- live.drawnOn[e.surface] = e.surface !== "terminal" && elements.Svg ? "svg" : "cells";
377
- if (e.surface !== "terminal" && elements.Svg) {
378
- // A desktop or editor draws Text in a proportional font, so the cell-built card comes apart
379
- // there; those surfaces get the card as one drawing instead.
378
+ const native = e.surface === "desktop";
379
+ live.drawnOn[e.surface] = native ? "native" : e.surface !== "terminal" && elements.Svg ? "svg" : "cells";
380
+ if (native) {
381
+ // The desktop draws Text in a proportional font, so the cell-built card comes apart there, and
382
+ // it loads each new Svg into a fresh frame, blanking it; it gets a card of flex rows instead.
383
+ if (model.visible) drawn = composeNativeCard(elements, model, { accent: live.accent });
384
+ } else if (e.surface !== "terminal" && elements.Svg) {
385
+ // A phone or editor draws Text in a proportional font too; it gets the card as one drawing.
380
386
  if (model.visible) {
381
387
  const { source, alt, animated } = composeSvgCard(model, { motion: settings.motion, accent: live.accent });
382
388
  drawn = elements.Svg({ source, alt, ...(animated ? { isInteractive: true } : {}) });
@@ -0,0 +1,133 @@
1
+ // The LIT card in the desktop app's own elements: a bordered Box of flex rows and monospaced Text
2
+ // spans, laid out by the app rather than by counted cells. The app draws a Box as a flex div and a
3
+ // Text as a styled span (the host's StyledElement), so a new tree updates the page in place. The Svg
4
+ // card it replaces was a whole document the app loaded into a fresh sandboxed frame on every real
5
+ // change (the stage, the tool, a skill), which blanked the card and restarted its animations.
6
+ //
7
+ // It shows the same five rows and colours as the Svg card (svg-card.mjs): the brand pill, the
8
+ // discipline and the stage indicator; the workflow map or the phase track with the lanes and
9
+ // progress; the detail line with the running tool; the skill chips, the notice or the finish; the
10
+ // usage figures. Box and Text carry no animation props, so this card is still; like the Svg card
11
+ // it carries no step clock and no loop countdown, so time alone never changes the tree.
12
+
13
+ import { barCells } from "./motion.mjs";
14
+ import { C, LABELS, PHASES, TONES, VERDICT_FILL, borderColor, indicatorOf, levelColor, mapColor, rightOfPhases } from "./svg-card.mjs";
15
+
16
+ const MAX_CHIPS = 6;
17
+
18
+ /**
19
+ * The card as one element tree from a surface's `{ Box, Text }`. `accent` is the HUD accent.
20
+ */
21
+ export const composeNativeCard = ({ Box, Text }, model, { accent = "#5fd7ff" } = {}) => {
22
+ const span = (body, { fill = C.white, bold = false, bg = null } = {}) => Text({
23
+ children: body,
24
+ color: fill,
25
+ mono: true,
26
+ wrap: "truncate-end",
27
+ ...(bg ? { backgroundColor: bg } : {}),
28
+ ...(bold ? { weight: "semibold" } : {}),
29
+ });
30
+ const pill = (label, fill, ink = C.ink, bold = true) => span(` ${label} `, { fill: ink, bg: fill, bold });
31
+ const pieces = (list) => list.map(([body, style]) => span(body, style));
32
+ const group = (children, gap = "xs") => Box({ flexDirection: "row", alignItems: "center", gap, flexShrink: 1, overflow: "hidden", children });
33
+ const joined = (groups) => groups.flatMap((list, index) => (index > 0 ? [span("·", { fill: C.faint }), ...pieces(list)] : pieces(list)));
34
+ const row = (left, right = []) => Box({
35
+ flexDirection: "row",
36
+ justifyContent: "space-between",
37
+ alignItems: "center",
38
+ gap: "md",
39
+ children: [group(left.length ? left : [span(" ")]), ...(right.length ? [group(right)] : [])],
40
+ });
41
+
42
+ const indicator = indicatorOf(model, accent);
43
+ const top = row(
44
+ [pill("🔥 LIT", C.pink), span("·", { fill: C.dim }), span(model.title ?? "ready", model.idle ? { fill: C.dim } : { fill: accent, bold: true })],
45
+ [span(indicator.label, { fill: indicator.fill, bold: !indicator.plain })],
46
+ );
47
+
48
+ const nowFill = (isWork = true) => (model.idle ? C.muted : model.ctxWarning && isWork ? C.amber : accent);
49
+ const nowInk = model.idle ? C.mutedText : C.ink;
50
+ let track;
51
+ if (model.workflow) {
52
+ const { stages } = model.workflow;
53
+ const last = Math.max(1, stages.length - 1);
54
+ const left = [];
55
+ stages.forEach((stage, index) => {
56
+ if (index > 0) left.push(span("──", { fill: stage.state === "todo" ? C.rail : mapColor(accent, (index - 0.5) / last) }));
57
+ if (stage.state === "now") left.push(pill(stage.label, nowFill(), nowInk));
58
+ else left.push(span(`${stage.label} ${stage.state === "done" ? "✓" : "·"}`, { fill: stage.state === "done" ? mapColor(accent, index / last) : C.dim }));
59
+ });
60
+ track = row(left, joined(rightOfPhases(model, false)));
61
+ } else {
62
+ const left = [];
63
+ PHASES.forEach((id, index) => {
64
+ const state = model.phases[id];
65
+ if (index > 0) left.push(span("──", { fill: model.phases[PHASES[index - 1]] === "done" ? accent : C.rail }));
66
+ if (state === "now") left.push(pill(LABELS[id], nowFill(id === "work"), nowInk));
67
+ else left.push(span(`${state === "done" ? "●" : "○"} ${LABELS[id]}`, { fill: state === "done" ? accent : C.dim }));
68
+ });
69
+ track = row(left, joined(rightOfPhases(model)));
70
+ }
71
+
72
+ const flow = model.workflow;
73
+ const detailLeft = [];
74
+ if (flow) {
75
+ detailLeft.push(span("└", { fill: C.dim }));
76
+ const parts = [];
77
+ if (flow.count) {
78
+ const { done, total, unit } = flow.count;
79
+ const bar = barCells(total > 0 ? done / total : 0, 8).map(({ glyph, color }) => span("▮", { fill: glyph === "█" ? color : C.empty }));
80
+ parts.push([span(unit, { fill: C.dim }), Box({ flexDirection: "row", children: [span(`${done}`, { bold: true }), span(`/${total}`, { fill: C.dim })] }), Box({ flexDirection: "row", children: bar })]);
81
+ }
82
+ if (flow.verdicts) parts.push(flow.verdicts.map(({ name, state }) => span(`${name} ${VERDICT_FILL[state][0]}`, { fill: VERDICT_FILL[state][1] })));
83
+ for (const item of flow.items) parts.push([span(item)]);
84
+ if (flow.next) parts.push([span("next", { fill: C.dim }), span("▸", { fill: accent }), span(flow.next)]);
85
+ parts.forEach((part, index) => detailLeft.push(...(index > 0 ? [span("·", { fill: C.faint })] : []), ...part));
86
+ }
87
+ const detail = row(detailLeft, model.step ? [span("◌", { fill: accent }), span(model.step.name)] : []);
88
+
89
+ let middle;
90
+ if (model.completion) {
91
+ middle = row([span("✦", { fill: C.pink }), span("COMPLETE", { bold: true }), span(`· ${model.completion.detail ?? ""}`, { fill: C.green })]);
92
+ } else if (model.notice) {
93
+ const [bar, title, icon] = TONES[model.notice.tone] ?? TONES.pink;
94
+ middle = row([span("▌", { fill: bar }), span(`${icon} ${model.notice.title}`, { fill: title, bold: true }), ...(model.notice.detail ? [span(`· ${model.notice.detail}`, { fill: C.dim })] : [])]);
95
+ } else if (model.skills.length === 0) {
96
+ if (!model.idle && model.next && !model.workflow?.next) middle = row([span("▸", { fill: accent }), span(model.next)]);
97
+ else if (!model.idle) middle = row([span("no LitFamily skill yet in this request", { fill: C.dim })]);
98
+ else middle = row([span("no LitFamily skill yet · type", { fill: C.dim }), span("lit <task>", { fill: accent }), span("to start one", { fill: C.dim })]);
99
+ } else {
100
+ const shown = model.skills.slice(0, MAX_CHIPS).map((chip) => (chip.state === "current"
101
+ ? pill(`⚡ ${chip.id}`, accent)
102
+ : pill(`↯ ${chip.id}`, C.muted, C.mutedText, false)));
103
+ const rest = model.skills.length - MAX_CHIPS;
104
+ middle = row([span("skills", { fill: C.dim }), group(rest > 0 ? [...shown, pill(`+${rest}`, C.muted, C.dim, false)] : shown, "sm")]);
105
+ }
106
+
107
+ const bottomGroups = [];
108
+ const warning = model.warnings[0];
109
+ if (warning) {
110
+ const color = warning.tone === "red" ? C.red : C.amber;
111
+ bottomGroups.push([span(`${warning.tone === "red" ? "✗" : "⚠"} ${warning.subject}`, { fill: color, bold: true }), span(warning.text, { fill: color })]);
112
+ }
113
+ const { context, rates, git } = model.figures;
114
+ if (context !== null) {
115
+ const filled = Math.max(0, Math.min(5, Math.round(context / 20)));
116
+ const bar = Array.from({ length: 5 }, (_, i) => span("▮", { fill: i < filled ? accent : C.empty }));
117
+ bottomGroups.push([span("ctx", { fill: C.dim }), span(`${context}%`, { fill: accent }), Box({ flexDirection: "row", children: bar })]);
118
+ }
119
+ for (const rate of rates) bottomGroups.push([span(rate.label, { fill: C.dim }), span(`${rate.percent}%`, { fill: levelColor(rate.percent) })]);
120
+ if (git) bottomGroups.push([span("git", { fill: C.dim }), span(git, { fill: accent })]);
121
+ const bottom = row(bottomGroups.flatMap((list, index) => (index > 0 ? [span("·", { fill: C.faint }), ...list] : list)));
122
+
123
+ return Box({
124
+ flexDirection: "column",
125
+ gap: "xs",
126
+ paddingX: "md",
127
+ paddingY: "sm",
128
+ borderStyle: "round",
129
+ borderColor: borderColor(model, accent),
130
+ backgroundColor: C.bg,
131
+ children: [top, track, detail, middle, bottom],
132
+ });
133
+ };
@@ -1,10 +1,10 @@
1
- // The LIT card as one SVG drawing, for the surfaces that are not a terminal grid. The Claude desktop
2
- // app draws a mod's Text in a proportional font and cuts each span to the cells it was given, so the
3
- // terminal card's text border and its padded pills come apart there. Those surfaces carry an `Svg`
4
- // element instead, and this module draws the same card into it: the brand pill, the discipline and
5
- // the stage indicator on top, the workflow map (or the phase track and progress when no family
6
- // places the session), the detail line with the live step, the skill chips (or the notice or the
7
- // finish), and the live figures at the bottom.
1
+ // The LIT card as one SVG drawing, for the surfaces that are neither a terminal grid nor the desktop
2
+ // app (which gets native-card.mjs). They draw a mod's Text in a proportional font and cut each span
3
+ // to the cells it was given, so the terminal card's text border and its padded pills come apart
4
+ // there. Those surfaces carry an `Svg` element instead, and this module draws the same card into
5
+ // it: the brand pill, the discipline and the stage indicator on top, the workflow map (or the phase
6
+ // track and progress when no family places the session), the detail line with the live step, the
7
+ // skill chips (or the notice or the finish), and the live figures at the bottom.
8
8
  //
9
9
  // It draws the settled state of the model; the terminal's entrances and crossfades stay terminal
10
10
  // only. While LitClaude works and motion allows, SMIL animation in the drawing keeps it moving: the
@@ -21,7 +21,7 @@
21
21
  import { textWidth, truncateToWidth } from "./layout.mjs";
22
22
  import { TIMINGS, barCells, gradientAt } from "./motion.mjs";
23
23
 
24
- const C = Object.freeze({
24
+ export const C = Object.freeze({
25
25
  bg: "#0d1117",
26
26
  pink: "#ff2d95",
27
27
  orange: "#ff6337",
@@ -41,8 +41,8 @@ const C = Object.freeze({
41
41
  hudYellow: "#d7af00",
42
42
  hudRed: "#d75f5f",
43
43
  });
44
- const LABELS = Object.freeze({ plan: "PLAN", work: "WORK", review: "REVIEW" });
45
- const PHASES = Object.freeze(["plan", "work", "review"]);
44
+ export const LABELS = Object.freeze({ plan: "PLAN", work: "WORK", review: "REVIEW" });
45
+ export const PHASES = Object.freeze(["plan", "work", "review"]);
46
46
 
47
47
  // Drawing units: CSS pixels at the card's natural size; the surface draws it at that width, up to its slot.
48
48
  export const SVG_CARD = Object.freeze({ width: 880, height: 160, font: 13, cell: 7.8, pad: 18 });
@@ -52,7 +52,7 @@ const FONT = "ui-monospace, 'SF Mono', SFMono-Regular, Menlo, 'JetBrains Mono',
52
52
 
53
53
  const esc = (text) => String(text).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
54
54
  const widthOf = (text) => textWidth(text) * SVG_CARD.cell;
55
- const levelColor = (percent) => (percent >= 90 ? C.hudRed : percent >= 70 ? C.hudYellow : C.hudGreen);
55
+ export const levelColor = (percent) => (percent >= 90 ? C.hudRed : percent >= 70 ? C.hudYellow : C.hudGreen);
56
56
 
57
57
  // Edge spaces become offsets: an SVG text element drops leading and trailing whitespace.
58
58
  const text = (x, y, body, { fill = C.white, bold = false, anchor = "start" } = {}) => {
@@ -85,7 +85,7 @@ const pill = (x, y, label, { fill, ink, bold = true, sweep = false, breath = nul
85
85
  return { markup: parts.join(""), end: x + w };
86
86
  };
87
87
 
88
- const indicatorOf = (model, accent) => {
88
+ export const indicatorOf = (model, accent) => {
89
89
  if (model.completion) return { label: "DONE ✓", fill: C.green };
90
90
  if (model.idle) return { label: "idle", fill: C.dim, plain: true };
91
91
  const flow = model.workflow;
@@ -109,7 +109,7 @@ const topRow = (model, accent, animate) => {
109
109
  return brand.markup + title.markup + right;
110
110
  };
111
111
 
112
- const rightOfPhases = (model, withProgress = true) => {
112
+ export const rightOfPhases = (model, withProgress = true) => {
113
113
  const pieces = [];
114
114
  if (model.ctxWarning) pieces.push([[`ctx ${model.ctxWarning.percent}% → ${model.ctxWarning.threshold}%`, { fill: C.amber, bold: true }]]);
115
115
  if (model.lanes.count > 0) pieces.push([[`● ${model.lanes.count} ${model.lanes.count === 1 ? "lane" : "lanes"}`, { fill: C.green }]]);
@@ -158,7 +158,7 @@ const phaseRow = (model, accent, animate) => {
158
158
  return out.join("");
159
159
  };
160
160
 
161
- const mapColor = (accent, position) => gradientAt([C.orange, C.pink, accent], position);
161
+ export const mapColor = (accent, position) => gradientAt([C.orange, C.pink, accent], position);
162
162
  const shortLabel = (label) => label.replace(/^[A-Za-z]+/u, (word) => word.slice(0, 3));
163
163
 
164
164
  // Right-aligned groups after `x`, the leftmost dropped first when the row is short of room.
@@ -218,7 +218,7 @@ const mapRowSvg = (model, accent, animate) => {
218
218
  return out.join("");
219
219
  };
220
220
 
221
- const VERDICT_FILL = Object.freeze({ pass: ["✓", C.green], fail: ["✗", C.red], pending: ["·", C.dim], running: ["●", C.amber] });
221
+ export const VERDICT_FILL = Object.freeze({ pass: ["✓", C.green], fail: ["✗", C.red], pending: ["·", C.dim], running: ["●", C.amber] });
222
222
 
223
223
  // └ task 3/7 ▮▮▮▯▯▯▯▯ · next ▸ <task> on the left, the spinner and the tool on the right.
224
224
  const detailRowSvg = (model, accent, animate) => {
@@ -276,7 +276,7 @@ const detailRowSvg = (model, accent, animate) => {
276
276
  return out.join("");
277
277
  };
278
278
 
279
- const TONES = Object.freeze({ pink: [C.pink, "#ff6fae", "🔥"], amber: [C.amber, C.amber, "⚠"], red: [C.red, C.red, "✗"] });
279
+ export const TONES = Object.freeze({ pink: [C.pink, "#ff6fae", "🔥"], amber: [C.amber, C.amber, "⚠"], red: [C.red, C.red, "✗"] });
280
280
 
281
281
  // Skill chips, or the notice or the finish while one is up, or the hint while nothing has fired.
282
282
  const middleRow = (model, accent, animate) => {
@@ -351,7 +351,7 @@ const bottomRow = (model, accent) => {
351
351
  return out.join("");
352
352
  };
353
353
 
354
- const borderColor = (model, accent) => {
354
+ export const borderColor = (model, accent) => {
355
355
  if (model.warnings.some(({ tone }) => tone === "red")) return C.red;
356
356
  if (model.warnings.length) return C.amber;
357
357
  return model.idle ? C.faint : accent;