@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 +4 -0
- package/README.md +8 -8
- package/README_ko-KR.md +8 -8
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/hooks.md +13 -16
- package/package.json +1 -1
- package/plugins/litclaude/.claude-plugin/plugin.json +1 -1
- package/plugins/litclaude/bin/litclaude-mcp.js +1 -1
- package/plugins/litclaude/hooks/lit-mod.mjs +10 -16
- package/plugins/litclaude/lib/mod/svg-card.mjs +34 -25
- package/plugins/litclaude/lib/mod/native-card.mjs +0 -133
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.
|
|
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.
|
|
8
|
-
<a href="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.
|
|
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.
|
|
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.
|
|
77
|
-
- [Migration and ownership conflicts](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.
|
|
78
|
-
- [Release history](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.
|
|
79
|
-
- [MIT license](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.
|
|
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.
|
|
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.
|
|
8
|
-
<a href="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.
|
|
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.
|
|
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.
|
|
75
|
-
- [이전과 소유권 충돌](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.
|
|
76
|
-
- [변경 이력](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.
|
|
77
|
-
- [MIT 라이선스](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.
|
|
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.
|
|
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.
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
the
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
light
|
|
476
|
-
|
|
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.
|
|
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": {
|
|
@@ -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
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
// terminal
|
|
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
|
|
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(
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
//
|
|
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
|
|
2
|
-
// app
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
54
63
|
const widthOf = (text) => textWidth(text) * SVG_CARD.cell;
|
|
55
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)}"
|
|
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
|
-
|
|
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}"
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
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"
|
|
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}"
|
|
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
|
-
};
|