eklavya 1.88.0 → 1.89.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
@@ -52,7 +52,7 @@ Code's learning loop.
52
52
  |---|---|
53
53
  | Learner state and concepts | `get_learner_profile`, `log_session_concepts`, `upsert_concepts`, `get_concept_graph` |
54
54
  | Questions and assessment (automatic questions wait for a code change, not a `git pull`; see `quiz.only_on_changes`) | `get_session_quiz_plan`, `record_attempt`, `get_gate_status` |
55
- | Quiz side panel (experimental) | `present_question` for the model; `panel_sync` and `panel_answer`, called only by the panel mod, never by the model; its header stacks the mark above the topic in a pane under 60 cells wide; on the desktop app each option's note is its own line under the option, and a click on the option picks it; `/eklavya-panel-demo` opens a mock question the panel answers itself, calling none of these tools |
55
+ | Quiz side panel (experimental) | `present_question` for the model; `panel_sync` and `panel_answer`, called only by the panel mod, never by the model; its header stacks the mark above the topic in a pane under 60 cells wide; on the desktop app each option is a numbered card drawn as one pressable region (a click anywhere on it picks it), while VS Code and the terminal keep native buttons; `/eklavya-panel-demo` opens a mock question the panel answers itself, calling none of these tools |
56
56
  | Settings | `get_config`, `set_config` |
57
57
  | Find and read memory (`memory_status` includes a `health` verdict with reasons) | `memory_search`, `memory_get`, `memory_timeline`, `memory_file_history`, `memory_status` |
58
58
  | Maintain memory | `memory_write`, `memory_correct`, `memory_delete`, `memory_collections` |
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "eklavya",
3
3
  "displayName": "Eklavya",
4
- "version": "1.88.0",
4
+ "version": "1.89.0",
5
5
  "description": "Learn while your agent works. Turns coding-agent generation time into adaptive, Socratic learning grounded in the code being written.",
6
6
  "author": {
7
7
  "name": "Ajay Kumar"
@@ -56,7 +56,7 @@ returns `{disabled: true}` and the mod opens and draws nothing. `presentation` i
56
56
  the Stop sweep and the delegation lines (`delegation-lib.ts`) read it, and an
57
57
  open panel question silences the checkpoint and the Stop sweep. Read
58
58
  `docs/verified-schemas.md` (mods section) before changing anything it records.
59
- `/eklavya-panel-demo` (`DEMO_COMMAND`) is the maintainer's shortcut for trying the pane: it seeds `PanelState.demo` with a mock question and never calls the server, so it works with `quiz.panel` off. While `demo` is set the heartbeat (`beat`) does not sync, `submit` grades locally with `demoResult` (no `panel_answer`, grader or explainer) and Next restarts the mock; `/eklavya-panel` and a real `present_question` sync replace it. On a non-terminal surface it also offers the Answer cards switch: `button` is the production layout, `client` draws each option as a `Client` region from `card.tsx` that posts `{pick}` to the `ui.message` hook, which accepts only an id of the open question. Under the Teach me button the demo also draws an experiment box (`experiment` in `register.tsx`): `Image` with `DEMO_PNG` on the terminal, two `Svg` (`demoSvg`, `demoSvgWithPng`) on every other surface, never both, because a tree holding an element its surface lacks is replaced whole by the host's own pane. The switch is demo-only until the whole-card press is observed on Desktop; then make it the default and say so in `docs/verified-schemas.md`.
59
+ `/eklavya-panel-demo` (`DEMO_COMMAND`) is the maintainer's shortcut for trying the pane: it seeds `PanelState.demo` with a mock question and never calls the server, so it works with `quiz.panel` off. While `demo` is set the heartbeat (`beat`) does not sync, `submit` grades locally with `demoResult` (no `panel_answer`, grader or explainer) and Next restarts the mock; `/eklavya-panel` and a real `present_question` sync replace it. Only the desktop draws `Client` regions (VS Code has `Svg` but no `Client`, and a tree holding an element its surface lacks is replaced whole): there every answer is a numbered whole-card region from `card.tsx`, Teach me is a compact `pill.tsx` region, the Other header is a `bare` card over an `Input` of three rows, and the header mark is `MASCOT_FACE` as an `Svg`; they post `{pick}` or `{press}` to the `ui.message` hook, which accepts only an id of the open question, `other`, or `teach`. VS Code and the terminal keep native `Button`s (VS Code numbers the labels itself, the terminal's plain Button draws `1:`). The demo's Answer cards switch (desktop only) compares the whole-card default with the native buttons. Under the Teach me button the demo also draws an experiment box (`experiment` in `register.tsx`): `Image` with `DEMO_PNG` on the terminal, two `Svg` (`demoSvg`, `demoSvgWithPng`) on every other surface, never both, because a tree holding an element its surface lacks is replaced whole by the host's own pane.
60
60
  Run `scripts/test-panel-mod.sh`: it stages the mod as its own plugin, validates
61
61
  it, runs `claude plugin test`, then validates the distributed plugin.
62
62
 
@@ -2,19 +2,25 @@ import type { ClientModule } from 'claude-code'
2
2
 
3
3
  /**
4
4
  * One answer card drawn as a `Client` region, so a press anywhere on it (border,
5
- * padding, title or note) picks it. A `Button` is only as large as its label, and a
5
+ * padding, number, title or note) picks it. A `Button` is only as large as its label, and a
6
6
  * `Box` takes no press, so this is the one way to make the whole card the target.
7
7
  *
8
8
  * It gets no mods API: it holds only its hover and posts `{ pick: id }` to the hooks
9
9
  * module, whose `ui.message` hook validates the id and records the pick. The colours
10
10
  * come in as props, so the module imports nothing.
11
+ *
12
+ * `bare` draws only the pressable header (padding and text, no border or fill): the Other
13
+ * card's frame and its text box belong to the hooks module, and this is the row that selects it.
11
14
  */
12
15
  export type CardProps = {
13
16
  id: string
14
17
  label: string
15
18
  note: string
19
+ /** The answer's number, drawn before the text; absent for Other. */
20
+ n?: number
16
21
  chosen: boolean
17
22
  locked: boolean
23
+ bare?: boolean
18
24
  brand: string
19
25
  fill: string
20
26
  text: string
@@ -30,16 +36,29 @@ const Card: ClientModule<CardProps, { hover: boolean }> = (p, s) => {
30
36
  else if (e.type === 'leave' && hover) s.setState({ hover: false })
31
37
  else if (e.type === 'up' && !p.locked && (e.button === undefined || e.button === 'left')) s.post({ pick: p.id })
32
38
  })
33
- return Box({
39
+ const words = Box({
34
40
  flexDirection: 'column',
41
+ flexShrink: 1,
42
+ children: [
43
+ Text({ wrap: 'wrap', bold: p.chosen && !p.bare, color: p.chosen && !p.bare ? p.text : undefined, children: [p.label] }),
44
+ ...(p.note ? [Text({ wrap: 'wrap', dimColor: !p.chosen, color: p.chosen ? p.noteText : undefined, children: [p.note] })] : []),
45
+ ],
46
+ })
47
+ return Box({
48
+ flexDirection: p.n === undefined ? 'column' : 'row',
49
+ gap: 2,
35
50
  paddingX: 2,
36
51
  paddingY: 1,
37
- borderStyle: p.chosen ? 'bold' : 'single',
38
- borderColor: p.chosen || (hover && !p.locked) ? p.brand : 'inactive',
39
- backgroundColor: p.chosen ? p.fill : undefined,
52
+ ...(p.bare
53
+ ? {}
54
+ : {
55
+ borderStyle: p.chosen ? 'bold' : 'single',
56
+ borderColor: p.chosen || (hover && !p.locked) ? p.brand : 'inactive',
57
+ backgroundColor: p.chosen ? p.fill : undefined,
58
+ }),
40
59
  children: [
41
- Text({ wrap: 'wrap', bold: p.chosen, color: p.chosen ? p.text : undefined, children: [p.label] }),
42
- Text({ wrap: 'wrap', dimColor: !p.chosen, color: p.chosen ? p.noteText : undefined, children: [p.note] }),
60
+ ...(p.n === undefined ? [] : [Text({ bold: true, dimColor: !p.chosen, color: p.chosen ? p.text : undefined, children: [String(p.n)] })]),
61
+ words,
43
62
  ],
44
63
  })
45
64
  }
@@ -23,7 +23,7 @@ export const BRAND = '#79D5C4'
23
23
  /** The fill and text of the picked answer: set together so the contrast holds in a light or a dark theme. */
24
24
  export const PICKED = { bg: '#1E5A52', text: '#FFFFFF', note: '#C9E8E3' }
25
25
  /** Teach me is Claude's orange (the logo's mascot), fill and border. The text is dark, as white on that orange is too faint at body size. */
26
- export const TEACH = { bg: '#D97757', text: '#1F1E1D' }
26
+ export const TEACH = { bg: '#D97757', text: '#1F1E1D', hover: '#E48866' }
27
27
 
28
28
  /**
29
29
  * The logo: the Claude mascot (orange) holding an Eklavya bow and arrow (white), drawn on a
@@ -171,9 +171,11 @@ export const STR = {
171
171
  demoStyleClient: 'Whole card',
172
172
  demoExplanation: 'A rename inside one folder is atomic: the poller sees the finished file or none, never half of it. (This is a mock, so it was not recorded.)',
173
173
  demoTyped: 'Typed answers are not graded in the demo. (Nothing was sent or recorded.)',
174
+ mascotAlt: 'The Eklavya mascot',
174
175
  experimentBanner: 'EXPERIMENT · picture test. Nothing here is part of a real question.',
175
176
  experimentTerminal: 'Image: a PNG, drawn as a picture in kitty, Ghostty and iTerm2 (fullscreen); other terminals show the dim text instead. SVG is not drawn in the terminal.',
176
177
  experimentImageAlt: 'The Eklavya mark: the Claude mascot holding a bow and arrow',
178
+ experimentNone: 'This surface draws neither an SVG nor an image, so there is nothing to test here.',
177
179
  experimentSvg: 'SVG: the mark drawn as vector rectangles.',
178
180
  experimentSvgPng: 'Image: the same mark as a PNG inside an SVG (Desktop has no Image element).',
179
181
  } as const
@@ -246,6 +248,14 @@ export function demoResult(kind: 'choice' | 'text' | 'skip' | 'dont_know', optio
246
248
  return { phase: 'answered', attempt_id: 0, correct, correct_label: right, explanation: kind === 'text' ? STR.demoTyped : STR.demoExplanation }
247
249
  }
248
250
 
251
+ /**
252
+ * The mascot's neutral face, the header mark on every surface that draws `Svg`. A copy of
253
+ * `web/public/brand/mascot/expressions/neutral-face.svg` (the mod has no filesystem); a test keeps the two equal,
254
+ * so regenerate with `npm run mascot:generate` and paste the file. The terminal keeps the block-character logo.
255
+ */
256
+ export const MASCOT_FACE =
257
+ "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"30 12 126 128\" width=\"200\" height=\"200\" class=\"ek-mascot\" data-expression=\"neutral\" data-variant=\"face\" role=\"img\"><style>:root {/* ---------- MASCOT IDENTITY ----------\n Archer Cloak artwork keeps its approved identity across both grounds.\n UI chrome continues to use the surface, text and accent roles below. */\n --mascot-coral: #ef9875;\n --mascot-coral-dark: #c96d51;\n --mascot-highlight: #ffc8a6;\n --mascot-cloak: #0E6E66;\n --mascot-cloak-dark: #07403C;\n --mascot-thread: #C4EDE4;\n --mascot-ink: #302c2a;\n --mascot-metal: #5b7c73;\n --mascot-wood: #bc9569;\n --mascot-wood-dark: #74583f;\n --mascot-string: #EAE7E1;\n --mascot-shadow: #26262a;\n /* ---------- END MASCOT IDENTITY ---------- */}/* Identity colours and UI surfaces use Eklavya's semantic tokens. */\neklavya-mascot { display:inline-flex; flex-shrink:0; vertical-align:middle; line-height:0; }\n.ek-mascot { display:block; overflow:visible; flex-shrink:0; }\n.ek-mascot[data-variant=\"face\"] { overflow:hidden; }\n.ek-mascot__face path:not([fill]) { fill:none; }\n.ek-companion { display:flex;align-items:center;gap:12px;color:var(--dim);font:13px/1.5 var(--font-body); }\n.ek-companion--hero { margin-bottom:16px; }\n.ek-companion--hero b { display:block;color:var(--ink);font-weight:600; }\n.ek-mascot-message { display:flex;align-items:center;gap:16px;padding:16px 0; }\n.ek-mascot-message p {margin:0}\n/* Secondary loader: draws and releases an arrow over 2.8 s. */\n.ek-mascot--draw .ek-mascot__character { animation:ek-breathe 2.8s ease-in-out infinite; transform-origin:95px 185px; }\n.ek-mascot--draw .ek-mascot__arrow { animation:ek-release 2.8s cubic-bezier(.3,0,.3,1) infinite; }\n.ek-mascot--draw .ek-mascot__string { animation:ek-draw 2.8s ease-in-out infinite; transform-origin:157px 125px; }\n.ek-mascot--draw .ek-mascot__ripple { animation:ek-hit 2.8s ease-out infinite; transform-origin:196px 128px; opacity:0; }\n@keyframes ek-breathe { 0%,100% {transform:translateY(0)} 35%,55% {transform:translateY(-3px) rotate(-1deg)} }\n@keyframes ek-release { 0%,12% {transform:translateX(0);opacity:1} 40%,52% {transform:translateX(-10px);opacity:1} 68% {transform:translateX(22px);opacity:0} 69%,85% {transform:translateX(0);opacity:0} 100% {transform:translateX(0);opacity:1} }\n@keyframes ek-draw { 0%,100% {transform:scaleX(1)} 40%,52% {transform:scaleX(1.11)} 60% {transform:scaleX(.08)} 68% {transform:scaleX(1)} }\n@keyframes ek-hit { 0%,60%,100% {opacity:0;transform:scale(.4)} 68% {opacity:.8;transform:scale(.7)} 88% {opacity:0;transform:scale(1.7)} }\n/* Loader: walking toward the viewer, a 1.2 s stride (two steps). Ground rungs recede on an ease-out, as perspective slows them near the horizon. */\n.ek-mascot--loading .ek-mascot__walker { animation:ek-sway 1.2s ease-in-out infinite; transform-origin:95px 185px; }\n.ek-mascot--loading .ek-mascot__torso { animation:ek-bob .6s ease-in-out infinite; }\n.ek-mascot--loading .ek-mascot__leg, .ek-mascot--loading .ek-mascot__hand { animation:ek-step 1.2s ease-in-out infinite; }\n.ek-mascot--loading .ek-mascot__leg--r, .ek-mascot--loading .ek-mascot__hand--arrow { animation-delay:-.6s; }\n.ek-mascot--loading .ek-mascot__shadow { animation:ek-shadow .6s ease-in-out infinite; transform-origin:95px 186px; }\n.ek-mascot--loading .ek-mascot__rung { animation:ek-recede 1.2s cubic-bezier(.15,.6,.4,1) infinite; transform-origin:95px 162px; }\n.ek-mascot--loading .ek-mascot__rung:nth-child(3) { animation-delay:-.3s; }\n.ek-mascot--loading .ek-mascot__rung:nth-child(4) { animation-delay:-.6s; }\n.ek-mascot--loading .ek-mascot__rung:nth-child(5) { animation-delay:-.9s; }\n@keyframes ek-sway { 0%,100% {transform:rotate(-1.5deg)} 50% {transform:rotate(1.5deg)} }\n@keyframes ek-bob { 0%,100% {transform:translateY(1px)} 50% {transform:translateY(-3px)} }\n@keyframes ek-step { 0%,50%,100% {transform:translateY(0)} 25% {transform:translateY(-6px)} }\n@keyframes ek-shadow { 0%,100% {transform:scaleX(1)} 50% {transform:scaleX(.92)} }\n@keyframes ek-recede { 0% {transform:translateY(56px) scaleX(1);opacity:0} 10% {opacity:1} 85% {opacity:1} 100% {transform:translateY(0) scaleX(.4);opacity:0} }\n@media (prefers-reduced-motion:reduce) { .ek-mascot * {animation:none !important;transition:none !important} }\n\n.ek-companion--rail { padding:12px 20px; text-decoration:none; }\n.ek-mascot-message > :last-child { min-width:0; overflow-wrap:break-word; }\n.ek-mascot-status { display:inline-flex; align-items:center; gap:6px; }\n</style><title>Eklavya, calm and attentive</title><path d=\"M48 109q44-19 88 0l-8 30-33-15-31 15Z\" fill=\"var(--mascot-cloak)\"/><g class=\"ek-mascot__head\">\n <path d=\"M46 43h18V29h19V18h23v12h20v14h17v19h8v29h-8v16q0 12-16 15H62q-18-3-18-17V91h-9V63h11Z\" fill=\"var(--mascot-coral)\" stroke=\"var(--mascot-coral-dark)\" stroke-width=\"2\" stroke-linejoin=\"round\"/>\n <path d=\"M48 45h18V32h18V21h19\" fill=\"none\" stroke=\"var(--mascot-highlight)\" stroke-width=\"3\" stroke-linejoin=\"round\"/>\n <g class=\"ek-mascot__face\" fill=\"var(--mascot-ink)\" stroke=\"var(--mascot-ink)\" stroke-width=\"3.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"64\" y=\"64\" width=\"10\" height=\"19\" rx=\"5\"/><rect x=\"110\" y=\"64\" width=\"10\" height=\"19\" rx=\"5\"/><path d=\"M84 89q8 6 16 0\"/></g>\n </g><circle cx=\"95\" cy=\"126\" r=\"6\" fill=\"var(--mascot-metal)\"/></svg>"
258
+
249
259
  /** The logo as a PNG (208 by 96, one grid cell is 8 pixels), for the demo's picture test: the mod has no filesystem. */
250
260
  export const DEMO_PNG =
251
261
  'iVBORw0KGgoAAAANSUhEUgAAANAAAABgCAYAAABsbvwBAAABcElEQVR42u3dwQ2DMBBFQXdJKdRKNaYDDqDFu+t50j8mUpDnZCUZQ3pofswTFEAASQBJAEkASQABJAEkASTlBJL9/SWAJIAAEkAASQBJAEkAASSAAJJyHnDABBBAAgggCSAJIIAEEEACCCAJoLpd5zFt3aof4O2BOcQAAQQQQAABBBBAABlAABlAAAEEEEAAAQRQY0Ddga5+ftUPaHtgAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAAB5CLUXk7BfyDmkAEkgAwggAwggAwggAwgAWQEAGQAJQbkHskX6lykuogFCCCADCCADCCAAAIIIIAAAgggAwggAwigCGDRr9/98/lhRYAAAggggAACCCCAAHLAAAIIIIAAAggggAACCCCA1LPVBxQQAQSQAAJIAkgCCCABBJAAAkgCSKp5gAERQABJAEkASQABJIAAkgCSAJJqAgNEAkgCSAJIAgggCSAJIAkgSZIkSZIkSZIk6dduN5j9QcRn8BgAAAAASUVORK5CYII='
@@ -0,0 +1,38 @@
1
+ import type { ClientModule } from 'claude-code'
2
+
3
+ /**
4
+ * A compact filled button drawn as a `Client` region, so a press anywhere on it (fill, padding or
5
+ * text) counts. A `Button` takes no fill, and a `Box` takes no press, so a coloured button whose
6
+ * whole body is pressable has to be a region of its own. It sizes to its text and posts
7
+ * `{ press: id }` to the hooks module, which validates it. Colours come in as props.
8
+ */
9
+ export type PillProps = {
10
+ id: string
11
+ label: string
12
+ locked: boolean
13
+ fill: string
14
+ hoverFill: string
15
+ text: string
16
+ }
17
+
18
+ const Pill: ClientModule<PillProps, { hover: boolean }> = (p, s) => {
19
+ const { Box, Text } = s.elements
20
+ const hover = s.state?.hover ?? false
21
+ s.onPointer(e => {
22
+ if (e.type === 'enter' && !hover) s.setState({ hover: true })
23
+ else if (e.type === 'leave' && hover) s.setState({ hover: false })
24
+ else if (e.type === 'up' && !p.locked && (e.button === undefined || e.button === 'left')) s.post({ press: p.id })
25
+ })
26
+ const fill = hover && !p.locked ? p.hoverFill : p.fill
27
+ return Box({
28
+ flexDirection: 'row',
29
+ paddingX: 2,
30
+ paddingY: 0.5,
31
+ borderStyle: 'round',
32
+ borderColor: fill,
33
+ backgroundColor: fill,
34
+ children: [Text({ color: p.text, children: [p.label] })],
35
+ })
36
+ }
37
+
38
+ export default Pill
@@ -4,6 +4,7 @@ import type { EngineInterface, Register } from 'claude-code'
4
4
  import {
5
5
  BRAND,
6
6
  DEMO_PNG,
7
+ MASCOT_FACE,
7
8
  PICKED,
8
9
  TEACH,
9
10
  EMPTY,
@@ -37,6 +38,7 @@ import {
37
38
  } from './model'
38
39
  import type { PanelPending, PanelQuestion, PanelState } from './types'
39
40
  import type { CardProps } from './card'
41
+ import type { PillProps } from './pill'
40
42
 
41
43
  /**
42
44
  * The Eklavya quiz panel: a pane that shows the question the tutor model handed
@@ -406,21 +408,24 @@ export const register: Register = on => {
406
408
  // The mock question: opens at once, answered on this machine, so a card or a click can be tried with no real question waiting.
407
409
  on('command.run', { command: 'eklavya-panel-demo' }, async $ => {
408
410
  try {
409
- await startDemo($, 'button')
411
+ await startDemo($, 'client')
410
412
  return { text: STR.demoOpened }
411
413
  } catch {
412
414
  return { text: STR.demoFailed }
413
415
  }
414
416
  })
415
417
 
416
- // A press on a whole-card `Client` (card.tsx) arrives here as data, never as a fact: only an id of the open question counts.
418
+ // A press on a `Client` region (card.tsx, pill.tsx) arrives here as data, never as a fact: only an id of the open question, Other, or Teach me counts.
417
419
  on('ui.message', { component: 'Pane' }, async ($, e, next) => {
418
420
  if (e.requestId === PANE) {
419
421
  try {
420
422
  const started = Date.now()
421
- const id = (e.data as { pick?: unknown } | null)?.pick
423
+ const data = e.data as { pick?: unknown; press?: unknown } | null
424
+ const id = data?.pick
422
425
  const s: PanelState = (await $.state.get(REF)).value ?? EMPTY
423
- if (typeof id === 'string' && s.question?.options.some(o => o.id === id)) await pick($, id, 'whole card', started)
426
+ if (id === 'other') await chooseOther($)
427
+ else if (typeof id === 'string' && s.question?.options.some(o => o.id === id)) await pick($, id, 'whole card', started)
428
+ else if (data?.press === 'teach' && s.question && (s.step === 'awaiting' || s.step === 'error')) await teach($)
424
429
  } catch {
425
430
  /* Fail open: the card stays as it was. */
426
431
  }
@@ -449,6 +454,11 @@ export const register: Register = on => {
449
454
  // Any surface but the terminal is the desktop app's: it sets the question as a heading, spaces the
450
455
  // answers apart and draws the mark in boxes. The terminal keeps its text layout.
451
456
  const app = e.surface !== 'terminal'
457
+ // Only the desktop app draws a `Client` (the whole-card answers, the Teach me pill, the Other row). VS Code has `Svg` but no `Client`:
458
+ // a tree holding an element its surface lacks is replaced whole, so VS Code keeps the native buttons.
459
+ const desktop = e.surface === 'desktop'
460
+ // `Svg` is drawn by the desktop app and VS Code only (the elements table lists no other surface), so any other surface keeps the grid mark.
461
+ const vector = desktop || e.surface === 'vscode'
452
462
  // The mark is drawn in text, as Claude Code draws its own, so it shows in every terminal. The
453
463
  // desktop app sets text in a proportional font with its own line height, so glyph cells do not
454
464
  // tile there: it gets the same grid as filled boxes, one per run of a colour. A cell is one unit
@@ -467,6 +477,11 @@ export const register: Register = on => {
467
477
  </Box>
468
478
  ))}
469
479
  </Box>
480
+ ) : vector ? (
481
+ // The mascot's own neutral face, as the vector artwork the brand ships (the block grid is for the terminal's cells).
482
+ <Box flexDirection="column" flexShrink={0}>
483
+ <Svg alt={STR.mascotAlt} width={72} height={73} source={MASCOT_FACE} />
484
+ </Box>
470
485
  ) : (
471
486
  <Box flexDirection="column" flexShrink={0}>
472
487
  {boxRows().map((runs, y) => (
@@ -528,7 +543,7 @@ export const register: Register = on => {
528
543
  <Box key="demo-bar" flexDirection="column" gap={app ? 1 : 0}>
529
544
  <Text color="warning">{STR.demoBanner}</Text>
530
545
  <Text dimColor>{s.demo.last}</Text>
531
- {app ? (
546
+ {desktop ? (
532
547
  <Box flexDirection="row" gap={2} alignItems="center">
533
548
  <Text dimColor>{STR.demoStyleLabel}</Text>
534
549
  <Button key="style-button" label={STR.demoStyleButton} variant={s.demo.style === 'button' ? 'primary' : 'secondary'} onPress={() => setStyle($, 'button')} />
@@ -604,18 +619,21 @@ export const register: Register = on => {
604
619
  {note && !app ? <Text wrap="wrap" dimColor={!chosen} color={chosen ? PICKED.note : undefined}>{`\n ${note}`}</Text> : null}
605
620
  </Button>
606
621
  )
607
- const wholeCard = app && s.demo?.style === 'client'
622
+ // The desktop app draws each answer as one pressable region, so a click on its padding or note picks too (card.tsx);
623
+ // the demo can switch back to the native buttons to compare.
624
+ const wholeCard = desktop && (s.demo ? s.demo.style === 'client' : true)
625
+ const cardProps = (id: string, over: Partial<CardProps>): CardProps => ({ id, label: '', note: '', chosen: false, locked, brand: BRAND, fill: PICKED.bg, text: PICKED.text, noteText: PICKED.note, ...over })
608
626
  const options = q.options.map((o, i) => {
609
627
  const chosen = !s.draft.other && s.draft.picked === o.id
610
- // The whole card is one pressable region, so a click on its padding or note picks too (card.tsx).
611
628
  if (wholeCard) {
612
- const props: CardProps = { id: o.id, label: plainLabel(o.label), note: o.note ? plainLabel(o.note) : '', chosen, locked, brand: BRAND, fill: PICKED.bg, text: PICKED.text, noteText: PICKED.note }
629
+ const props = cardProps(o.id, { label: plainLabel(o.label), note: o.note ? plainLabel(o.note) : '', n: i + 1, chosen })
613
630
  return <Client key={`card-${o.id}`} module="./card.tsx" width="100%" props={props} />
614
631
  }
615
632
  return card(
616
633
  o.id,
617
634
  chosen,
618
- answer(o.id, `opt-${o.id}`, String(i + 1), plainLabel(o.label), chosen, o.note ? plainLabel(o.note) : undefined),
635
+ // The terminal's plain Button draws its own `1:`; the other surfaces number the label themselves.
636
+ answer(o.id, `opt-${o.id}`, String(i + 1), app ? `${i + 1} ${plainLabel(o.label)}` : plainLabel(o.label), chosen, o.note ? plainLabel(o.note) : undefined),
619
637
  // The desktop app draws only a Button's label, not its inline children, so there the note sits under the button.
620
638
  app && o.note
621
639
  ? [<Text key={`note-${o.id}`} wrap="wrap" dimColor={!chosen} color={chosen ? PICKED.note : undefined}>{plainLabel(o.note)}</Text>]
@@ -630,13 +648,17 @@ export const register: Register = on => {
630
648
  <Text color="warning" bold>
631
649
  {STR.experimentBanner}
632
650
  </Text>
633
- {app ? (
651
+ {vector ? (
634
652
  <Box flexDirection="column" gap={1}>
635
653
  <Text dimColor>{STR.experimentSvg}</Text>
636
654
  <Svg alt={STR.experimentImageAlt} width={208} height={96} source={demoSvg()} />
637
655
  <Text dimColor>{STR.experimentSvgPng}</Text>
638
656
  <Svg alt={STR.experimentImageAlt} width={208} height={96} source={demoSvgWithPng()} />
639
657
  </Box>
658
+ ) : app ? (
659
+ <Text dimColor wrap="wrap">
660
+ {STR.experimentNone}
661
+ </Text>
640
662
  ) : (
641
663
  <Box flexDirection="column">
642
664
  <Text dimColor wrap="wrap">
@@ -652,26 +674,48 @@ export const register: Register = on => {
652
674
  stem,
653
675
  <Box flexDirection="column" gap={app ? 2 : 0} marginTop={app ? 1 : 0}>
654
676
  {options}
655
- {card(
656
- 'other',
657
- s.draft.other,
658
- answer('other', 'opt-other', 'o', STR.other, s.draft.other),
659
- [
660
- s.draft.other ? (
661
- <Box key="other-box" paddingLeft={3}>
677
+ {wholeCard ? (
678
+ // The header row is the pressable region; the frame and the text box are ours. Three lines tall, saved on every keystroke,
679
+ // so the button the field draws beside itself has nothing to do: its label is left empty to try to hide it.
680
+ <Box key="card-other" flexDirection="column" borderStyle={s.draft.other ? 'bold' : 'single'} borderColor={s.draft.other ? BRAND : 'inactive'}>
681
+ <Client key="other-head" module="./card.tsx" width="100%" props={cardProps('other', { label: STR.other, chosen: s.draft.other, bare: true })} />
682
+ {s.draft.other ? (
683
+ <Box key="other-box" paddingX={2} paddingBottom={1}>
662
684
  <Input
663
685
  key="other-text"
664
686
  autoFocus
687
+ rows={3}
665
688
  placeholder={STR.otherPlaceholder}
666
689
  value={s.draft.text}
667
- submitLabel="keep"
690
+ submitLabel=""
668
691
  onInput={(value: string) => (locked ? undefined : typeOther($, value))}
669
692
  onSubmit={() => (locked ? undefined : keepOther($))}
670
693
  />
671
694
  </Box>
672
- ) : null,
673
- ],
674
- s.draft.other,
695
+ ) : null}
696
+ </Box>
697
+ ) : (
698
+ card(
699
+ 'other',
700
+ s.draft.other,
701
+ answer('other', 'opt-other', 'o', STR.other, s.draft.other),
702
+ [
703
+ s.draft.other ? (
704
+ <Box key="other-box" paddingLeft={3}>
705
+ <Input
706
+ key="other-text"
707
+ autoFocus
708
+ placeholder={STR.otherPlaceholder}
709
+ value={s.draft.text}
710
+ submitLabel="keep"
711
+ onInput={(value: string) => (locked ? undefined : typeOther($, value))}
712
+ onSubmit={() => (locked ? undefined : keepOther($))}
713
+ />
714
+ </Box>
715
+ ) : null,
716
+ ],
717
+ s.draft.other,
718
+ )
675
719
  )}
676
720
  </Box>,
677
721
  locked ? <Text dimColor>{STR.grading}</Text> : null,
@@ -689,7 +733,16 @@ export const register: Register = on => {
689
733
  // Teach me has a row of its own, under Submit. A Button takes no fill, and a desktop draws a plain
690
734
  // one without its grey chrome: the orange box is the button's body, and as a column it
691
735
  // stretches the Button across its width, so all but the thin padding is pressable.
692
- locked ? null : app ? (
736
+ locked ? null : desktop ? (
737
+ // A compact pill whose whole body is pressable (pill.tsx), sized to its text instead of the pane's width.
738
+ <Box key="teach-row" flexDirection="row">
739
+ <Client
740
+ key="teach-client"
741
+ module="./pill.tsx"
742
+ props={{ id: 'teach', label: STR.teach, locked, fill: TEACH.bg, hoverFill: TEACH.hover, text: TEACH.text } satisfies PillProps}
743
+ />
744
+ </Box>
745
+ ) : app ? (
693
746
  <Box key="teach-box" flexDirection="column" backgroundColor={TEACH.bg} borderStyle="round" borderColor={TEACH.bg} paddingX={1} paddingY={0.5}>
694
747
  <Button key="teach" plain label={STR.teach} onPress={() => teach($)}>
695
748
  <Text color={TEACH.text}>{STR.teach}</Text>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eklavya",
3
- "version": "1.88.0",
3
+ "version": "1.89.0",
4
4
  "description": "Learn while your agent works — local knowledge graph, spaced repetition, and quiz gating for agent-assisted development.",
5
5
  "license": "MIT",
6
6
  "repository": {