@stemtrooper/learningcode 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -66,12 +66,23 @@ The LEARNINGCODE block banner replaces Pi's header at startup:
66
66
  The figlet "ANSI Shadow" face, 97 columns wide, kept verbatim because the
67
67
  double-line box characters only align if every row keeps its exact offset.
68
68
 
69
- Colour comes from the active theme, so it stays legible in light and dark.
69
+ It steps down through three tiers, because a phone will never have 97 columns and
70
+ clipping box characters looks like a rendering bug rather than a design:
71
+
72
+ | Terminal width | Shows |
73
+ |---|---|
74
+ | 99 or more | the figlet face above |
75
+ | 51 to 98 | a condensed 4 row face, 47 columns |
76
+ | under 51 | the wordmark |
70
77
 
71
- **It needs a 99 column terminal.** Below that it collapses to a wordmark,
72
- rather than drawing art that would be clipped into something that looks broken.
73
- An 80 column terminal will show the compact form, so widen the window or reduce
74
- the art.
78
+ The condensed tier still spells LEARNINGCODE:
79
+
80
+ \\n █ ███ █ ███ █ █ ███ █ █ ███ ███ ███ ███ ███
81
+ █ █ █ █ █ █ ███ █ ███ █ █ █ █ █ █ █
82
+ █ ███ ███ ███ █ █ █ █ █ █ █ █ █ █ █ █ ███
83
+ ███ ███ █ █ █ █ █ █ ███ █ █ ███ ███ ███ ███ ███
84
+ \\n
85
+ Colour comes from the active theme, so it stays legible in light and dark.
75
86
 
76
87
  It installs via `ctx.ui.setHeader`, the supported way to brand a fork.
77
88
 
@@ -1,26 +1,36 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { foregroundAnsi, rgbColor } from "@earendil-works/pi-tui";
2
3
 
3
4
  /**
4
5
  * LEARNINGCODE startup banner.
5
6
  *
6
7
  * Pi lets an extension replace the whole header, which is the supported way to
7
- * brand a fork without touching Pi's internals. The built-in header is Pi's own
8
- * logo plus key hints; we trade that for the TLC banner and a line of slash
9
- * commands, so nothing is claimed about keybindings we cannot read.
8
+ * brand a fork without touching Pi's internals.
10
9
  *
11
- * The art is the figlet "ANSI Shadow" face, kept verbatim rather than rebuilt from
12
- * a glyph map: the double-line box characters only line up if every row keeps
13
- * its exact offset, and a one-space drift makes the whole thing look broken.
10
+ * Three tiers, because the full face does not fit everywhere. A phone will never
11
+ * have 97 columns, and clipping box characters looks like a rendering bug rather
12
+ * than a design, so the banner steps down instead:
14
13
  *
15
- * Rendering uses the active theme's colour tokens (`accent`, `border`, `dim`,
16
- * `muted`) so the banner stays legible in light and dark terminals instead of
17
- * hard-coding colours that break on one of them.
14
+ * >= 99 columns figlet "ANSI Shadow", 97 wide, 6 rows
15
+ * >= 51 columns condensed 4 row face, 47 wide
16
+ * below that the wordmark
17
+ *
18
+ * The full art is stored verbatim rather than rebuilt from a glyph map. That face
19
+ * only aligns because every row keeps its exact offset, the top row flush left
20
+ * and the rest flush too; a one-space drift makes the whole banner look broken.
21
+ * A test asserts those offsets.
18
22
  */
19
23
 
20
- /** Structural types, so this file needs no runtime import from Pi's packages. */
24
+ /**
25
+ * Only the parts of Pi's Theme this file touches, to keep the runtime import down
26
+ * to the two colour helpers actually needed.
27
+ */
21
28
  type ThemeLike = {
22
29
  fg(token: string, text: string): string;
23
- getColorMode?(): "light" | "dark";
30
+ /** "light" | "dark" - the background the active theme is designed for. */
31
+ appearance?: "light" | "dark";
32
+ /** The terminal's colour capability, e.g. "truecolor". Not light or dark. */
33
+ getColorMode?(): string;
24
34
  };
25
35
  type HeaderComponent = { render(width: number): string[] };
26
36
 
@@ -35,10 +45,23 @@ type HeaderComponent = { render(width: number): string[] };
35
45
  *
36
46
  * Two values because the logo ships two: the darker cyan holds contrast on a
37
47
  * white field, the brighter one on black.
48
+ *
49
+ * Note this must not go through `theme.fg()`. That resolves theme *tokens*, and
50
+ * `Theme.tokenAnsi` throws `Unknown theme color: #29c8f2` for anything that is
51
+ * not one, so a raw hex there crashes the header at render time.
52
+ * `foregroundAnsi` takes a colour value directly, and unlike a hand-rolled escape
53
+ * it still degrades to 256 colour on terminals without truecolour support.
38
54
  */
39
- const CYAN_DARK_BG = "#29c8f2";
40
- const CYAN_LIGHT_BG = "#0dacd6";
55
+ const CYAN_DARK_BG = rgbColor(0x29, 0xc8, 0xf2);
56
+ const CYAN_LIGHT_BG = rgbColor(0x0d, 0xac, 0xd6);
57
+ const RESET = "\x1b[0m";
58
+
59
+ const WORD = "LEARNINGCODE";
60
+ const PAD = " ";
61
+ const TAGLINE = "The Learning Curve · Sarawak";
62
+ const HINTS = "/help commands · /quota today's spend · /hotkeys keys";
41
63
 
64
+ /** figlet "ANSI Shadow", verbatim. 97 columns, 6 rows. */
42
65
  const ART: readonly string[] = [
43
66
  "██╗ ███████╗ █████╗ ██████╗ ███╗ ██╗██╗███╗ ██╗ ██████╗ ██████╗ ██████╗ ██████╗ ███████╗",
44
67
  "██║ ██╔════╝██╔══██╗██╔══██╗████╗ ██║██║████╗ ██║██╔════╝ ██╔════╝██╔═══██╗██╔══██╗██╔════╝",
@@ -48,45 +71,77 @@ const ART: readonly string[] = [
48
71
  "╚══════╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝╚═╝ ╚═══╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝",
49
72
  ];
50
73
 
51
- const TAGLINE = "The Learning Curve · Sarawak";
52
- const HINTS = "/help commands · /quota today's spend · /hotkeys keys";
74
+ /**
75
+ * Condensed fallback for narrow terminals. Three columns per glyph, which is
76
+ * the most a twelve letter word can be compressed before the letterforms stop
77
+ * reading: an N needs four columns to show its diagonal, and dropping it to
78
+ * three turns the letter into a filled block.
79
+ */
80
+ const CONDENSED_GLYPHS: Record<string, readonly string[]> = {
81
+ L: ["█ ", "█ ", "█ ", "███"],
82
+ E: ["███", "█ ", "███", "███"],
83
+ A: [" █ ", "█ █", "███", "█ █"],
84
+ R: ["███", "█ █", "███", "█ █"],
85
+ N: ["█ █", "███", "█ █", "█ █"],
86
+ I: ["███", " █ ", " █ ", "███"],
87
+ G: ["███", "█ ", "█ █", "███"],
88
+ C: ["███", "█ ", "█ ", "███"],
89
+ O: ["███", "█ █", "█ █", "███"],
90
+ D: ["███", "█ █", "█ █", "███"],
91
+ };
53
92
 
54
- const PAD = " ";
93
+ const condensedArt = (): string[] => {
94
+ const rows = CONDENSED_GLYPHS[WORD[0]].length;
95
+ return Array.from({ length: rows }, (_, row) =>
96
+ [...WORD].map((letter) => CONDENSED_GLYPHS[letter][row]).join(" ").trimEnd(),
97
+ );
98
+ };
55
99
 
56
100
  /** A copy, so a caller mutating the result cannot corrupt later renders. */
57
101
  export const bannerArt = (): string[] => [...ART];
58
102
 
103
+ export const condensedArt_ = condensedArt;
104
+
59
105
  export const bannerWidth = (): number => Math.max(...ART.map((line) => line.length));
106
+ export const condensedWidth = (): number => Math.max(...condensedArt().map((line) => line.length));
60
107
 
61
- /**
62
- * The art is 97 columns, which does not fit the classic 80 column terminal.
63
- * Below the art plus its indent we show a wordmark instead, because clipped box
64
- * characters look like a rendering bug rather than a design.
65
- */
108
+ /** Art plus its indent. Derived, never guessed. */
66
109
  const MIN_FULL_WIDTH = bannerWidth() + PAD.length;
110
+ const MIN_CONDENSED_WIDTH = condensedWidth() + PAD.length;
67
111
 
68
112
  export function createBanner(theme: ThemeLike): HeaderComponent {
69
- const art = bannerArt();
70
- const artWidth = bannerWidth();
113
+ const full = bannerArt();
114
+ const small = condensedArt();
71
115
 
72
- // Fall back to the theme's accent only if a caller cannot report its colour
73
- // mode, which keeps the banner legible rather than dropping colour entirely.
74
- const mode = typeof theme.getColorMode === "function" ? theme.getColorMode() : "dark";
75
- const brand = mode === "light" ? CYAN_LIGHT_BG : CYAN_DARK_BG;
76
- const ink = (text: string) => theme.fg(brand as never, text);
116
+ // Two different things, easy to swap by accident:
117
+ // appearance light or dark, decides which brand cyan is readable
118
+ // getColorMode the terminal's colour capability, decides how to emit it
119
+ // Default to dark so the banner keeps its colour on a host that reports
120
+ // neither, rather than dropping the brand.
121
+ const isLight = theme.appearance === "light";
122
+ const mode = typeof theme.getColorMode === "function" ? theme.getColorMode() : "truecolor";
123
+ const brand = foregroundAnsi(isLight ? CYAN_LIGHT_BG : CYAN_DARK_BG, mode as never);
124
+ const ink = (text: string) => `${brand}${text}${RESET}`;
77
125
 
78
126
  return {
79
127
  render(width: number): string[] {
80
- if (width < MIN_FULL_WIDTH) {
81
- return ["", ink(PAD + "learningcode"), theme.fg("muted", PAD + TAGLINE)];
128
+ if (width >= MIN_FULL_WIDTH) {
129
+ const lines = ["", ...full.map((line) => ink(PAD + line))];
130
+ lines.push(theme.fg("border", PAD + "═".repeat(bannerWidth())));
131
+ lines.push(theme.fg("muted", PAD + TAGLINE));
132
+ lines.push(theme.fg("dim", PAD + HINTS));
133
+ lines.push("");
134
+ return lines;
135
+ }
136
+
137
+ if (width >= MIN_CONDENSED_WIDTH) {
138
+ const lines = ["", ...small.map((line) => ink(PAD + line))];
139
+ lines.push(theme.fg("muted", PAD + TAGLINE));
140
+ lines.push("");
141
+ return lines;
82
142
  }
83
143
 
84
- const lines = ["", ...art.map((line) => ink(PAD + line))];
85
- lines.push(theme.fg("border", PAD + "═".repeat(artWidth)));
86
- lines.push(theme.fg("muted", PAD + TAGLINE));
87
- lines.push(theme.fg("dim", PAD + HINTS));
88
- lines.push("");
89
- return lines;
144
+ return ["", ink(PAD + "learningcode"), theme.fg("muted", PAD + TAGLINE)];
90
145
  },
91
146
  };
92
147
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stemtrooper/learningcode",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "TLC Spark coding agent for students: Pi wired to the Spark OpenAI-compatible endpoint with per-student tokens, quota and seat-queue awareness.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -17,10 +17,12 @@
17
17
  ],
18
18
  "scripts": {
19
19
  "start": "node ./bin/learningcode.mjs",
20
- "test": "node --test \"test/**/*.test.mjs\""
20
+ "test": "node --test \"test/**/*.test.mjs\"",
21
+ "preview": "node ./scripts/preview.mjs"
21
22
  },
22
23
  "dependencies": {
23
- "@earendil-works/pi-coding-agent": "1.0.4"
24
+ "@earendil-works/pi-coding-agent": "1.0.4",
25
+ "@earendil-works/pi-tui": "1.0.4"
24
26
  },
25
27
  "devDependencies": {
26
28
  "jiti": "2.7.0"