@litfamily/litclaude 1.0.31 → 1.0.32

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.32 - 2026-10-17
4
+
5
+ - The desktop mod card has its look and motion back. 1.0.31 drew it with the app's plain boxes and text to stop it blinking, but that also took away its design and all of its animation. This release restores the animated card from 1.0.30. Its animations now start already visible, so when the app reloads the card after a real change, the card no longer fades in from blank or restarts its border light from the corner. The clock stays off the desktop card, so a running tool alone does not reload it. The terminal and phone cards are unchanged.
6
+
3
7
  ## 1.0.31 - 2026-10-17
4
8
 
5
9
  - 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.
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.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>
1
+ <p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.32/docs/assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.32/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.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>
7
+ <img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.32/docs/assets/readme/badge-version.svg" alt="1.0.32" />
8
+ <a href="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.32/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.32/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.31/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.32/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.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)
76
+ - [Privacy and network behavior](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.32/docs/privacy.md)
77
+ - [Migration and ownership conflicts](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.32/docs/migration.md)
78
+ - [Release history](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.32/CHANGELOG.md)
79
+ - [MIT license](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.32/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.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>
1
+ <p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.32/docs/assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.32/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.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>
7
+ <img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.32/docs/assets/readme/badge-version.svg" alt="1.0.32" />
8
+ <a href="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.32/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.32/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.31/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.32/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.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)
74
+ - [개인정보와 네트워크 동작](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.32/docs/privacy.md)
75
+ - [이전과 소유권 충돌](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.32/docs/migration.md)
76
+ - [변경 이력](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.32/CHANGELOG.md)
77
+ - [MIT 라이선스](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.32/LICENSE)
@@ -1 +1 @@
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>
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="146" height="20" role="img" aria-label="release: 1.0.32"><title>release: 1.0.32</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.32</text></g></svg>
package/docs/hooks.md CHANGED
@@ -462,22 +462,19 @@ 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. 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
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. Each animation starts part way through its
474
+ cycle, so a freshly loaded card already looks like the running one: the border and the pill are
475
+ lit, the light sits on its pill and the comet is part way round, not parked at the top-left
476
+ corner. The app still blanks the card for a moment while it loads a new drawing; the mod cannot
477
+ prevent that. It is redrawn only when something you would
481
478
  notice changes, such as the stage, the tool, a skill, a usage figure or the goal. While the card
482
479
  shows in a terminal and the app at once, the terminal's loop countdown and lane times wait for such
483
480
  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.31",
3
+ "version": "1.0.32",
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.31",
4
+ "version": "1.0.32",
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.31";
12
+ const serverVersion = "1.0.32";
13
13
 
14
14
  const publicSourceReadTool = {
15
15
  name: "public_source_read",
@@ -14,7 +14,6 @@
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";
18
17
  import { renderBand, renderRows } from "../lib/mod/render.mjs";
19
18
  import { accentFromSettings, normalizeModSettings, resolveGlyphs } from "../lib/mod/settings.mjs";
20
19
  import { composeSvgCard } from "../lib/mod/svg-card.mjs";
@@ -102,18 +101,17 @@ const stillModel = (model) => model && {
102
101
  };
103
102
 
104
103
  // What the card would show on each surface still attached. Invalidation redraws every surface at
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.
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.
110
108
  function drawnKey(live, model) {
111
109
  const kinds = new Set(Object.values(live.drawnOn));
112
110
  if (kinds.size === 0) return JSON.stringify(model);
113
- const still = kinds.has("svg") || kinds.has("native");
111
+ const withSvg = kinds.has("svg");
114
112
  return [...kinds].map((kind) => (kind === "svg"
115
113
  ? (model?.visible ? composeSvgCard(model, { motion: live.motion, accent: live.accent }).source : "hidden")
116
- : JSON.stringify(still ? stillModel(model) : model))).join("\n");
114
+ : JSON.stringify(withSvg ? stillModel(model) : model))).join("\n");
117
115
  }
118
116
 
119
117
  async function rebuild($, live) {
@@ -375,14 +373,10 @@ export const register = (on, options) => {
375
373
  const elements = $.ui.resolve(e);
376
374
  const columns = Math.max(20, e.props.bodyColumns ?? e.viewport?.columns ?? 80);
377
375
  let drawn = null;
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.
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.
386
380
  if (model.visible) {
387
381
  const { source, alt, animated } = composeSvgCard(model, { motion: settings.motion, accent: live.accent });
388
382
  drawn = elements.Svg({ source, alt, ...(animated ? { isInteractive: true } : {}) });
@@ -1,10 +1,10 @@
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.
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.
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
@@ -13,7 +13,8 @@
13
13
  // head shimmers, the step spinner turns and a comet runs around the border (full only). Idle, it is
14
14
  // still. The drawing carries no step clock and no loop countdown: the surface loads each new
15
15
  // drawing afresh, restarting every animation, so a figure that moves with time alone would make the
16
- // card blink on every poll.
16
+ // card blink on every poll. Each loop opens mid-cycle (see `loop`), so a fresh load starts on a
17
+ // frame of the running card rather than on a dim border and a comet at the corner.
17
18
  //
18
19
  // Text is set in a monospace face and placed by counted cells (Hangul and emoji as two), so the
19
20
  // layout does not depend on measuring a font the drawing cannot see.
@@ -21,7 +22,7 @@
21
22
  import { textWidth, truncateToWidth } from "./layout.mjs";
22
23
  import { TIMINGS, barCells, gradientAt } from "./motion.mjs";
23
24
 
24
- export const C = Object.freeze({
25
+ const C = Object.freeze({
25
26
  bg: "#0d1117",
26
27
  pink: "#ff2d95",
27
28
  orange: "#ff6337",
@@ -41,8 +42,8 @@ export const C = Object.freeze({
41
42
  hudYellow: "#d7af00",
42
43
  hudRed: "#d75f5f",
43
44
  });
44
- export const LABELS = Object.freeze({ plan: "PLAN", work: "WORK", review: "REVIEW" });
45
- export const PHASES = Object.freeze(["plan", "work", "review"]);
45
+ const LABELS = Object.freeze({ plan: "PLAN", work: "WORK", review: "REVIEW" });
46
+ const PHASES = Object.freeze(["plan", "work", "review"]);
46
47
 
47
48
  // Drawing units: CSS pixels at the card's natural size; the surface draws it at that width, up to its slot.
48
49
  export const SVG_CARD = Object.freeze({ width: 880, height: 160, font: 13, cell: 7.8, pad: 18 });
@@ -50,9 +51,17 @@ const ROW_Y = Object.freeze([28, 58, 86, 114, 142]);
50
51
  const PILL_H = 20;
51
52
  const FONT = "ui-monospace, 'SF Mono', SFMono-Regular, Menlo, 'JetBrains Mono', Consolas, monospace";
52
53
 
54
+ // Timing of one looping animation, started `phase` of the way into its cycle. The surface loads
55
+ // each new drawing afresh, so every loop would otherwise open on its first frame: the breathing at
56
+ // its dimmest, the light off the pill's left edge, the comet parked at the top-left corner. A
57
+ // negative begin opens each loop mid-cycle, in a frame that looks like the card already running.
58
+ const loop = (ms, phase) => `dur="${ms}ms" begin="-${Math.round(ms * phase)}ms" repeatCount="indefinite"`;
59
+ // The breathing pieces share one phase so the pill, the border and the count bar's head stay in step.
60
+ const BREATH_PHASE = 0.25;
61
+
53
62
  const esc = (text) => String(text).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
54
63
  const widthOf = (text) => textWidth(text) * SVG_CARD.cell;
55
- export const levelColor = (percent) => (percent >= 90 ? C.hudRed : percent >= 70 ? C.hudYellow : C.hudGreen);
64
+ const levelColor = (percent) => (percent >= 90 ? C.hudRed : percent >= 70 ? C.hudYellow : C.hudGreen);
56
65
 
57
66
  // Edge spaces become offsets: an SVG text element drops leading and trailing whitespace.
58
67
  const text = (x, y, body, { fill = C.white, bold = false, anchor = "start" } = {}) => {
@@ -79,13 +88,13 @@ const pill = (x, y, label, { fill, ink, bold = true, sweep = false, breath = nul
79
88
  const w = widthOf(label) + 16;
80
89
  const top = y - PILL_H + 6;
81
90
  const parts = [`<rect x="${x.toFixed(1)}" y="${top}" width="${w.toFixed(1)}" height="${PILL_H}" rx="${PILL_H / 2}" fill="${fill}"/>`];
82
- if (breath) parts.push(`<rect x="${x.toFixed(1)}" y="${top}" width="${w.toFixed(1)}" height="${PILL_H}" rx="${PILL_H / 2}" fill="${breath}" opacity="0"><animate attributeName="opacity" values="0;${breathPeak};0" dur="${TIMINGS.breath}ms" repeatCount="indefinite"/></rect>`);
91
+ if (breath) parts.push(`<rect x="${x.toFixed(1)}" y="${top}" width="${w.toFixed(1)}" height="${PILL_H}" rx="${PILL_H / 2}" fill="${breath}" opacity="0"><animate attributeName="opacity" values="0;${breathPeak};0" ${loop(TIMINGS.breath, BREATH_PHASE)}/></rect>`);
83
92
  if (sweep) parts.push(`<rect x="${x.toFixed(1)}" y="${top}" width="${w.toFixed(1)}" height="${PILL_H}" rx="${PILL_H / 2}" fill="url(#lit-sweep)"/>`);
84
93
  parts.push(text(x + 8, y, label, { fill: ink, bold }));
85
94
  return { markup: parts.join(""), end: x + w };
86
95
  };
87
96
 
88
- export const indicatorOf = (model, accent) => {
97
+ const indicatorOf = (model, accent) => {
89
98
  if (model.completion) return { label: "DONE ✓", fill: C.green };
90
99
  if (model.idle) return { label: "idle", fill: C.dim, plain: true };
91
100
  const flow = model.workflow;
@@ -109,7 +118,7 @@ const topRow = (model, accent, animate) => {
109
118
  return brand.markup + title.markup + right;
110
119
  };
111
120
 
112
- export const rightOfPhases = (model, withProgress = true) => {
121
+ const rightOfPhases = (model, withProgress = true) => {
113
122
  const pieces = [];
114
123
  if (model.ctxWarning) pieces.push([[`ctx ${model.ctxWarning.percent}% → ${model.ctxWarning.threshold}%`, { fill: C.amber, bold: true }]]);
115
124
  if (model.lanes.count > 0) pieces.push([[`● ${model.lanes.count} ${model.lanes.count === 1 ? "lane" : "lanes"}`, { fill: C.green }]]);
@@ -158,7 +167,7 @@ const phaseRow = (model, accent, animate) => {
158
167
  return out.join("");
159
168
  };
160
169
 
161
- export const mapColor = (accent, position) => gradientAt([C.orange, C.pink, accent], position);
170
+ const mapColor = (accent, position) => gradientAt([C.orange, C.pink, accent], position);
162
171
  const shortLabel = (label) => label.replace(/^[A-Za-z]+/u, (word) => word.slice(0, 3));
163
172
 
164
173
  // Right-aligned groups after `x`, the leftmost dropped first when the row is short of room.
@@ -197,7 +206,7 @@ const mapRowSvg = (model, accent, animate) => {
197
206
  out.push(`<line x1="${x1.toFixed(1)}" y1="${y - 4}" x2="${x2.toFixed(1)}" y2="${y - 4}" stroke="${C.rail}" stroke-width="2.5" stroke-linecap="round"/>`);
198
207
  const filled = count && count.total > 0 ? Math.min(1, count.done / count.total) : 0;
199
208
  if (filled > 0) out.push(`<line x1="${x1.toFixed(1)}" y1="${y - 4}" x2="${(x1 + filled * layout.rail).toFixed(1)}" y2="${y - 4}" stroke="${mapColor(accent, mid)}" stroke-width="2.5" stroke-linecap="round"/>`);
200
- if (animate.sweep) out.push(`<circle cx="${x1.toFixed(1)}" cy="${y - 4}" r="2.5" fill="#ffffff"><animate attributeName="cx" values="${x1.toFixed(1)};${x2.toFixed(1)}" dur="${TIMINGS.flow}ms" repeatCount="indefinite"/></circle>`);
209
+ if (animate.sweep) out.push(`<circle cx="${x1.toFixed(1)}" cy="${y - 4}" r="2.5" fill="#ffffff"><animate attributeName="cx" values="${x1.toFixed(1)};${x2.toFixed(1)}" ${loop(TIMINGS.flow, 0.5)}/></circle>`);
201
210
  } else {
202
211
  out.push(`<line x1="${x1.toFixed(1)}" y1="${y - 4}" x2="${x2.toFixed(1)}" y2="${y - 4}" stroke="${C.rail}" stroke-width="1.5" stroke-linecap="round" stroke-opacity="0.7"/>`);
203
212
  }
@@ -218,7 +227,7 @@ const mapRowSvg = (model, accent, animate) => {
218
227
  return out.join("");
219
228
  };
220
229
 
221
- export const VERDICT_FILL = Object.freeze({ pass: ["✓", C.green], fail: ["✗", C.red], pending: ["·", C.dim], running: ["●", C.amber] });
230
+ const VERDICT_FILL = Object.freeze({ pass: ["✓", C.green], fail: ["✗", C.red], pending: ["·", C.dim], running: ["●", C.amber] });
222
231
 
223
232
  // └ task 3/7 ▮▮▮▯▯▯▯▯ · next ▸ <task> on the left, the spinner and the tool on the right.
224
233
  const detailRowSvg = (model, accent, animate) => {
@@ -232,7 +241,7 @@ const detailRowSvg = (model, accent, animate) => {
232
241
  out.push(text(nameEnd, y, model.step.name, { fill: C.white, anchor: "end" }));
233
242
  const cx = nameEnd - widthOf(model.step.name) - 10;
234
243
  out.push(`<circle cx="${cx.toFixed(1)}" cy="${y - 4}" r="4.5" fill="none" stroke="${accent}" stroke-width="1.8" stroke-dasharray="18 10.3">`
235
- + (animate.breath ? `<animateTransform attributeName="transform" type="rotate" values="0 ${cx.toFixed(1)} ${y - 4};360 ${cx.toFixed(1)} ${y - 4}" dur="${TIMINGS.spin * 8}ms" repeatCount="indefinite"/>` : "")
244
+ + (animate.breath ? `<animateTransform attributeName="transform" type="rotate" values="0 ${cx.toFixed(1)} ${y - 4};360 ${cx.toFixed(1)} ${y - 4}" ${loop(TIMINGS.spin * 8, 0.5)}/>` : "")
236
245
  + "</circle>");
237
246
  right = width - cx + 10;
238
247
  }
@@ -252,7 +261,7 @@ const detailRowSvg = (model, accent, animate) => {
252
261
  const cells = barCells(total > 0 ? done / total : 0, 8);
253
262
  cells.forEach(({ glyph, color }, index) => out.push(`<rect x="${(x + index * 9).toFixed(1)}" y="${y - 10}" width="7" height="10" rx="1.5" fill="${glyph === "█" ? color : C.empty}"/>`));
254
263
  const head = cells.findLastIndex(({ glyph }) => glyph === "█");
255
- if (head >= 0 && animate.sweep) out.push(`<rect x="${(x + head * 9).toFixed(1)}" y="${y - 10}" width="7" height="10" rx="1.5" fill="#ffffff" opacity="0"><animate attributeName="opacity" values="0;0.6;0" dur="${TIMINGS.breath}ms" repeatCount="indefinite"/></rect>`);
264
+ if (head >= 0 && animate.sweep) out.push(`<rect x="${(x + head * 9).toFixed(1)}" y="${y - 10}" width="7" height="10" rx="1.5" fill="#ffffff" opacity="0"><animate attributeName="opacity" values="0;0.6;0" ${loop(TIMINGS.breath, BREATH_PHASE)}/></rect>`);
256
265
  x += 8 * 9 + 4;
257
266
  }
258
267
  const pieces = [];
@@ -276,7 +285,7 @@ const detailRowSvg = (model, accent, animate) => {
276
285
  return out.join("");
277
286
  };
278
287
 
279
- export const TONES = Object.freeze({ pink: [C.pink, "#ff6fae", "🔥"], amber: [C.amber, C.amber, "⚠"], red: [C.red, C.red, "✗"] });
288
+ const TONES = Object.freeze({ pink: [C.pink, "#ff6fae", "🔥"], amber: [C.amber, C.amber, "⚠"], red: [C.red, C.red, "✗"] });
280
289
 
281
290
  // Skill chips, or the notice or the finish while one is up, or the hint while nothing has fired.
282
291
  const middleRow = (model, accent, animate) => {
@@ -351,7 +360,7 @@ const bottomRow = (model, accent) => {
351
360
  return out.join("");
352
361
  };
353
362
 
354
- export const borderColor = (model, accent) => {
363
+ const borderColor = (model, accent) => {
355
364
  if (model.warnings.some(({ tone }) => tone === "red")) return C.red;
356
365
  if (model.warnings.length) return C.amber;
357
366
  return model.idle ? C.faint : accent;
@@ -389,13 +398,13 @@ export const composeSvgCard = (model, { motion = "full", accent = "#5fd7ff" } =
389
398
  const edge = borderColor(model, accent);
390
399
  const perimeter = 1000;
391
400
  const defs = animate.sweep
392
- ? `<defs><linearGradient id="lit-sweep" x1="0" x2="1" y1="0" y2="0"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset="0.5" stop-color="#fff" stop-opacity="0.55"/><stop offset="1" stop-color="#fff" stop-opacity="0"/><animateTransform attributeName="gradientTransform" type="translate" values="-1.2 0;1.2 0" dur="${TIMINGS.sweep}ms" repeatCount="indefinite"/></linearGradient></defs>`
401
+ ? `<defs><linearGradient id="lit-sweep" x1="0" x2="1" y1="0" y2="0"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset="0.5" stop-color="#fff" stop-opacity="0.55"/><stop offset="1" stop-color="#fff" stop-opacity="0"/><animateTransform attributeName="gradientTransform" type="translate" values="-1.2 0;1.2 0" ${loop(TIMINGS.sweep, 0.5)}/></linearGradient></defs>`
393
402
  : "";
394
403
  const frame = `<rect x="1" y="1" width="${width - 2}" height="${height - 2}" rx="14" fill="${C.bg}" stroke="${edge}" stroke-width="1.5"${model.idle ? ' stroke-opacity="0.8"' : ""}>`
395
- + (animate.breath && !model.warnings.length ? `<animate attributeName="stroke-opacity" values="0.45;1;0.45" dur="${TIMINGS.breath}ms" repeatCount="indefinite"/>` : "")
404
+ + (animate.breath && !model.warnings.length ? `<animate attributeName="stroke-opacity" values="0.45;1;0.45" ${loop(TIMINGS.breath, BREATH_PHASE)}/>` : "")
396
405
  + "</rect>";
397
406
  const comet = animate.comet
398
- ? `<rect x="1" y="1" width="${width - 2}" height="${height - 2}" rx="14" fill="none" stroke="url(#lit-comet)" stroke-width="2.5" stroke-linecap="round" pathLength="${perimeter}" stroke-dasharray="70 ${perimeter - 70}"><animate attributeName="stroke-dashoffset" values="0;-${perimeter}" dur="${TIMINGS.comet * 2}ms" repeatCount="indefinite"/></rect>`
407
+ ? `<rect x="1" y="1" width="${width - 2}" height="${height - 2}" rx="14" fill="none" stroke="url(#lit-comet)" stroke-width="2.5" stroke-linecap="round" pathLength="${perimeter}" stroke-dasharray="70 ${perimeter - 70}"><animate attributeName="stroke-dashoffset" values="0;-${perimeter}" ${loop(TIMINGS.comet * 2, 0.5)}/></rect>`
399
408
  + `<defs><linearGradient id="lit-comet" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="${C.orange}"/><stop offset="0.6" stop-color="${C.pink}"/><stop offset="1" stop-color="#ffffff"/></linearGradient></defs>`
400
409
  : "";
401
410
  const body = [
@@ -1,133 +0,0 @@
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
- };