@kabmo-labs/kabmo 0.0.1 → 0.2.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.
Files changed (127) hide show
  1. package/README.md +16 -11
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +333 -333
  4. package/dist/client/static/js/index-pdp5dwc8.css +1 -0
  5. package/dist/client/static/js/{index-4vyzz0z4.js → index-zsskckw7.js} +681 -681
  6. package/package.json +2 -1
  7. package/public/kabmo-wordmark.svg +31 -0
  8. package/src/cli/lib/commands/update.js +17 -2
  9. package/src/cli/lib/commands/update.test.js +36 -0
  10. package/src/client/bootstrap.d.ts +8 -0
  11. package/src/client/components/auth/login-screen/index.test.tsx +93 -0
  12. package/src/client/components/auth/login-screen/index.tsx +43 -22
  13. package/src/client/components/auth/login-screen/password-toggle.test.tsx +121 -0
  14. package/src/client/components/common/batch-selection/BatchActionBar.test.ts +132 -0
  15. package/src/client/components/common/batch-selection/BatchActionBar.tsx +118 -0
  16. package/src/client/components/common/batch-selection/BatchDeleteModal.tsx +177 -0
  17. package/src/client/components/common/batch-selection/SelectionCheckbox.tsx +58 -0
  18. package/src/client/components/common/batch-selection/folder-state.test.ts +130 -0
  19. package/src/client/components/common/batch-selection/folder-state.ts +101 -0
  20. package/src/client/components/common/batch-selection/types.ts +42 -0
  21. package/src/client/components/common/brand-wordmark/Mark.tsx +62 -0
  22. package/src/client/components/common/brand-wordmark/index.test.tsx +29 -7
  23. package/src/client/components/common/brand-wordmark/index.tsx +33 -11
  24. package/src/client/components/common/brand-wordmark/wordmark-svg.test.ts +88 -0
  25. package/src/client/components/common/header-controls/MoreMenu.tsx +136 -0
  26. package/src/client/components/common/header-controls/index.test.tsx +77 -11
  27. package/src/client/components/common/header-controls/index.tsx +53 -4
  28. package/src/client/components/common/session-state-provider/index.test.ts +22 -1
  29. package/src/client/components/common/workspace-options-menu/index.tsx +25 -11
  30. package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +169 -0
  31. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +72 -91
  32. package/src/client/components/layout/session-sidebar/Footer.tsx +20 -2
  33. package/src/client/components/layout/session-sidebar/OverflowMenu.tsx +23 -5
  34. package/src/client/components/layout/session-sidebar/SessionItem.batch.test.ts +162 -0
  35. package/src/client/components/layout/session-sidebar/SessionItem.tsx +35 -6
  36. package/src/client/components/layout/session-sidebar/SessionList.tsx +10 -1
  37. package/src/client/components/layout/session-sidebar/Toolbar.test.ts +2 -0
  38. package/src/client/components/layout/session-sidebar/Toolbar.tsx +30 -34
  39. package/src/client/components/layout/session-sidebar/index.tsx +92 -0
  40. package/src/client/components/mobile/LayoutWrapper.tsx +32 -1
  41. package/src/client/components/mobile/SessionSidebar.tsx +83 -0
  42. package/src/client/components/mobile/mobile-main-view/Header.tsx +27 -5
  43. package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +21 -1
  44. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +56 -12
  45. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +8 -0
  46. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +27 -6
  47. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.test.ts +106 -0
  48. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +178 -93
  49. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +9 -0
  50. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +6 -0
  51. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +4 -1
  52. package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +1 -1
  53. package/src/client/components/workspace/chat-timeline/chat-input/GoalBanner.tsx +6 -1
  54. package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +4 -1
  55. package/src/client/components/workspace/chat-timeline/chat-input/VoiceNotice.tsx +2 -2
  56. package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +72 -1
  57. package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +28 -4
  58. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +20 -2
  59. package/src/client/components/workspace/chat-timeline/chat-input/props.ts +7 -0
  60. package/src/client/components/workspace/chat-timeline/index.tsx +9 -3
  61. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.test.tsx +127 -0
  62. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.tsx +71 -0
  63. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/PluginPendingInteractions.test.ts +1 -0
  64. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -2
  65. package/src/client/components/workspace/git-panel/BranchToolbar.tsx +4 -0
  66. package/src/client/components/workspace/git-panel/index.test.ts +15 -0
  67. package/src/client/components/workspace/search-panel/Body.test.ts +149 -0
  68. package/src/client/components/workspace/search-panel/Body.tsx +53 -9
  69. package/src/client/hooks/chat/timeline/ask-frames-grouped.test.ts +46 -2
  70. package/src/client/hooks/chat/timeline/ask-frames.test.ts +1 -1
  71. package/src/client/hooks/chat/timeline/ask-frames.ts +79 -29
  72. package/src/client/hooks/chat/timeline/index.ts +2 -1
  73. package/src/client/hooks/chat/timeline/scroll-probe.test-util.ts +227 -0
  74. package/src/client/hooks/chat/timeline/scroll-reading-back.test.ts +67 -0
  75. package/src/client/hooks/chat/timeline/scroll.test.ts +84 -226
  76. package/src/client/hooks/chat/timeline/scroll.ts +34 -1
  77. package/src/client/hooks/ui/media-query.ts +41 -0
  78. package/src/client/hooks/ui/updates/android-download.test.ts +73 -0
  79. package/src/client/hooks/ui/updates/android-download.ts +106 -0
  80. package/src/client/hooks/workspace/batch-delete-run.ts +95 -0
  81. package/src/client/hooks/workspace/batch-delete.ts +63 -0
  82. package/src/client/hooks/workspace/batch-selection.ts +98 -0
  83. package/src/client/hooks/workspace/file-tabs.test.ts +165 -0
  84. package/src/client/hooks/workspace/file-tabs.ts +24 -11
  85. package/src/client/hooks/workspace/session-state/index.ts +24 -1
  86. package/src/client/hooks/workspace/toolbar-row-width.ts +43 -0
  87. package/src/client/lib/native/back.test.ts +108 -0
  88. package/src/client/lib/native/back.ts +60 -0
  89. package/src/client/main.tsx +6 -0
  90. package/src/client/tailwind.css +6 -0
  91. package/src/server/lib/fs/git-actions-branch.ts +18 -1
  92. package/src/server/lib/fs/git-actions-sync.test.ts +144 -0
  93. package/src/server/lib/fs/git-publish.test.ts +96 -0
  94. package/src/server/lib/fs/git-publish.ts +35 -3
  95. package/src/server/lib/fs/listing.test.ts +86 -0
  96. package/src/server/lib/fs/listing.ts +20 -6
  97. package/src/server/lib/lifecycle/restart.ts +52 -14
  98. package/src/server/lib/omp/session/delete-flow.server.ts +101 -0
  99. package/src/server/lib/updates/android-release.test.ts +92 -0
  100. package/src/server/lib/updates/android-release.ts +148 -0
  101. package/src/server/lib/updates/apply.ts +8 -0
  102. package/src/server/lib/updates/install-steps.ts +81 -0
  103. package/src/server/lib/updates/install.ts +19 -67
  104. package/src/server/lib/updates/installed-cli.test.ts +222 -0
  105. package/src/server/lib/updates/installed-cli.ts +139 -0
  106. package/src/server/lib/updates/omp.ts +1 -1
  107. package/src/server/plugins/ssr.ts +9 -2
  108. package/src/server/routes/folders/batch-delete.ts +68 -0
  109. package/src/server/routes/folders/index.ts +4 -0
  110. package/src/server/routes/fs/read.ts +24 -9
  111. package/src/server/routes/sessions/batch-delete.test.ts +142 -0
  112. package/src/server/routes/sessions/batch-delete.ts +60 -0
  113. package/src/server/routes/sessions/delete.ts +13 -51
  114. package/src/server/routes/sessions/index.ts +5 -0
  115. package/src/server/routes/updates/android.ts +22 -0
  116. package/src/server/routes/updates/apply.ts +1 -1
  117. package/src/server/routes/updates/index.ts +2 -0
  118. package/src/shared/lib/updates/shell-ua.test.ts +54 -0
  119. package/src/shared/lib/updates/shell-ua.ts +36 -0
  120. package/src/shared/lib/workspace/batch-select.test.ts +92 -0
  121. package/src/shared/lib/workspace/batch-select.ts +86 -0
  122. package/src/shared/lib/workspace/session-state/listeners.ts +38 -0
  123. package/src/shared/lib/workspace/session-state/replacement.test.ts +121 -0
  124. package/src/shared/lib/workspace/session-state/store.ts +13 -2
  125. package/src/shared/lib/workspace/sidebar-toolbar.test.ts +98 -0
  126. package/src/shared/lib/workspace/sidebar-toolbar.ts +84 -0
  127. package/dist/client/static/js/index-x9cehh1a.css +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kabmo-labs/kabmo",
3
- "version": "0.0.1",
3
+ "version": "0.2.0",
4
4
  "description": "Web console and diagnostic workbench for AI coding agents — Elysia + Preact on Bun.",
5
5
  "license": "UNLICENSED",
6
6
  "author": "Wijanarko Putra Rajeb <wijanarko.rajeb@gmail.com>",
@@ -53,6 +53,7 @@
53
53
  "lint": "tsc --noEmit",
54
54
  "test": "bun test",
55
55
  "test:e2e": "playwright test --config tests/e2e/playwright.config.ts",
56
+ "readme:media": "bun run scripts/readme-media.ts",
56
57
  "typecheck:e2e": "tsc --noEmit -p tests/e2e",
57
58
  "publish:plugins": "bash .github/scripts/publish-plugin-packages.sh packages/kabmo-sdk packages/kabmo-ui"
58
59
  },
@@ -0,0 +1,31 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!-- Kabmo wordmark — the app icon's K lockup with the word beside it.
3
+ The word is Inter ExtraBold (the face index.html loads), outlines at a
4
+ 100-unit cap height; the plate is the icon's own 64-unit tile scaled to
5
+ 1.5625 x cap so the K keeps its proportions. Tracking -0.025em, the a
6
+ tucked 0.28em into the plate, the b->m gap widened 0.02em — the same
7
+ values the app's BrandWordmark uses, measured there. -->
8
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -128.12 486.38 156.25" width="486.38" height="156.25" role="img" aria-label="Kabmo">
9
+ <title>Kabmo</title>
10
+ <defs>
11
+ <linearGradient id="kabmo-ab" gradientUnits="userSpaceOnUse" x1="117.76" y1="0" x2="279.42" y2="0">
12
+ <stop offset="0" stop-color="#f59e0b"/>
13
+ <stop offset="1" stop-color="#ea580c"/>
14
+ </linearGradient>
15
+ <style>
16
+ /* One ink for every background. A README embeds this as an image, and a
17
+ prefers-color-scheme query inside such an image follows the CONTAINER
18
+ only in Chromium — WebKit resolves it from the OS, so a GitHub-dark
19
+ page on a light OS rendered near-black on near-black. */
20
+ .kabmo-mo { fill: #7e7a72; }
21
+ </style>
22
+ </defs>
23
+ <rect width="156.25" height="156.25" y="-128.12" rx="34.18" fill="#0b5d3b"/>
24
+ <g fill="none" stroke-width="17.09" stroke-linecap="round" stroke-linejoin="round">
25
+ <path d="M48.83 -91.5V-8.5" stroke="#f7f4ea"/>
26
+ <path d="M48.83 -50 102.54 -91.5" stroke="#f26522"/>
27
+ <path d="M48.83 -50 102.54 -8.5" stroke="#f7f4ea"/>
28
+ </g>
29
+ <path fill="url(#kabmo-ab)" d="M146.49 1.34Q139.31 1.34 133.7 -1.11Q128.1 -3.56 124.95 -8.49Q121.79 -13.42 121.79 -20.81Q121.79 -27.05 123.97 -31.34Q126.15 -35.64 130.05 -38.32Q133.94 -41.01 138.97 -42.42Q144.01 -43.83 149.71 -44.3Q156.09 -44.83 159.98 -45.47Q163.87 -46.11 165.68 -47.38Q167.5 -48.66 167.5 -50.94L167.5 -51.21Q167.5 -53.83 166.29 -55.64Q165.08 -57.45 162.8 -58.39Q160.52 -59.33 157.29 -59.33Q154.07 -59.33 151.56 -58.39Q149.04 -57.45 147.43 -55.7Q145.82 -53.96 145.15 -51.61L123.94 -54.36Q125.42 -60.81 129.74 -65.67Q134.07 -70.54 141.09 -73.26Q148.1 -75.97 157.5 -75.97Q164.48 -75.97 170.55 -74.33Q176.62 -72.68 181.19 -69.5Q185.75 -66.31 188.3 -61.64Q190.85 -56.98 190.85 -50.94L190.85 0L168.84 0L168.84 -10.54L168.23 -10.54Q166.22 -6.64 163.1 -4.03Q159.98 -1.41 155.85 -0.03Q151.72 1.34 146.49 1.34ZM153.6 -14.16Q157.56 -14.16 160.75 -15.77Q163.94 -17.38 165.82 -20.2Q167.7 -23.02 167.7 -26.71L167.7 -33.89Q166.69 -33.36 165.18 -32.89Q163.67 -32.42 161.86 -32.05Q160.05 -31.68 158.23 -31.38Q156.42 -31.07 154.74 -30.81Q151.32 -30.34 148.91 -29.19Q146.49 -28.05 145.21 -26.24Q143.94 -24.43 143.94 -21.88Q143.94 -19.4 145.21 -17.68Q146.49 -15.97 148.64 -15.07Q150.78 -14.16 153.6 -14.16ZM247.95 1.07Q242.25 1.07 238.05 -0.84Q233.86 -2.75 231.11 -5.77Q228.35 -8.79 226.88 -12.15L226.07 -12.15L226.07 0L202.92 0L202.92 -100L226.34 -100L226.34 -62.15L226.88 -62.15Q228.29 -65.57 231.01 -68.72Q233.72 -71.88 237.92 -73.93Q242.11 -75.97 248.09 -75.97Q256.01 -75.97 262.82 -71.85Q269.63 -67.72 273.82 -59.16Q278.02 -50.6 278.02 -37.45Q278.02 -24.7 273.99 -16.14Q269.97 -7.58 263.12 -3.26Q256.27 1.07 247.95 1.07ZM239.97 -17.18Q244.46 -17.18 247.58 -19.7Q250.7 -22.21 252.31 -26.81Q253.93 -31.41 253.93 -37.45Q253.93 -43.56 252.31 -48.09Q250.7 -52.62 247.62 -55.13Q244.53 -57.65 239.97 -57.65Q235.47 -57.65 232.31 -55.2Q229.16 -52.75 227.48 -48.22Q225.8 -43.69 225.8 -37.45Q225.8 -31.34 227.48 -26.78Q229.16 -22.21 232.35 -19.7Q235.54 -17.18 239.97 -17.18Z"/>
30
+ <path class="kabmo-mo" fill="#7e7a72" d="M289.88 0L289.88 -75.03L311.43 -75.03L312.77 -55.57L311.16 -55.57Q312.9 -62.89 316.36 -67.38Q319.82 -71.88 324.41 -73.96Q329.01 -76.04 334.11 -76.04Q342.16 -76.04 347.1 -70.87Q352.03 -65.7 354.71 -53.96L352.1 -53.96Q353.91 -61.81 357.8 -66.71Q361.69 -71.61 366.93 -73.83Q372.16 -76.04 377.8 -76.04Q384.85 -76.04 390.25 -72.99Q395.65 -69.93 398.74 -64.26Q401.83 -58.59 401.83 -50.6L401.83 0L378.47 0L378.47 -45.3Q378.47 -51.01 375.49 -53.96Q372.5 -56.91 367.87 -56.91Q364.58 -56.91 362.13 -55.44Q359.68 -53.96 358.41 -51.31Q357.13 -48.66 357.13 -45.03L357.13 0L334.65 0L334.65 -45.64Q334.65 -50.81 331.76 -53.86Q328.88 -56.91 324.18 -56.91Q320.96 -56.91 318.51 -55.44Q316.06 -53.96 314.68 -51.24Q313.31 -48.52 313.31 -44.56L313.31 0ZM448.73 1.41Q436.98 1.41 428.53 -3.46Q420.07 -8.32 415.54 -17.01Q411.01 -25.7 411.01 -37.25Q411.01 -48.86 415.54 -57.55Q420.07 -66.24 428.53 -71.11Q436.98 -75.97 448.73 -75.97Q460.47 -75.97 468.93 -71.11Q477.39 -66.24 481.88 -57.55Q486.38 -48.86 486.38 -37.25Q486.38 -25.7 481.88 -17.01Q477.39 -8.32 468.93 -3.46Q460.47 1.41 448.73 1.41ZM448.73 -16.51Q453.22 -16.51 456.31 -19.13Q459.4 -21.74 460.98 -26.44Q462.55 -31.14 462.55 -37.38Q462.55 -43.62 460.98 -48.29Q459.4 -52.95 456.31 -55.5Q453.22 -58.05 448.73 -58.05Q444.23 -58.05 441.14 -55.5Q438.06 -52.95 436.48 -48.29Q434.9 -43.62 434.9 -37.38Q434.9 -31.14 436.48 -26.44Q438.06 -21.74 441.14 -19.13Q444.23 -16.51 448.73 -16.51Z"/>
31
+ </svg>
@@ -26,6 +26,21 @@ async function liveInstances(options) {
26
26
  return selectRestartTargets(await listLiveInstances(), Boolean(options?.all));
27
27
  }
28
28
 
29
+ /**
30
+ * The context a replacement is spawned from.
31
+ *
32
+ * After a RENAME the installed package is not the one this command runs out of
33
+ * (`bun add -g @kabmo-labs/kabmo` leaves the pre-rename package untouched on
34
+ * disk), so restarting from `ctx.pkgRoot` relaunches the build the update just
35
+ * replaced — measured: the console came back as the OLD version after a
36
+ * successful move to the new one. The installed copy's own root wins whenever
37
+ * the update reported one.
38
+ */
39
+ export function restartCtx(ctx, installed) {
40
+ if (!installed || typeof installed.root !== 'string' || installed.root.length === 0) return ctx;
41
+ return { ...ctx, pkgRoot: installed.root, bin: installed.bin ?? ctx.bin };
42
+ }
43
+
29
44
  async function restartInstances(options, ctx) {
30
45
  const restarted = [];
31
46
  const skipped = [];
@@ -103,7 +118,7 @@ export async function run(options, ctx) {
103
118
  // one JSON document.
104
119
  if (result.success && result.updated && shouldRestart) configure({ json: false, quiet: true });
105
120
  const { restarted, skipped, failed } = result.success && result.updated && shouldRestart
106
- ? await restartInstances(options, ctx)
121
+ ? await restartInstances(options, restartCtx(ctx, result.installed))
107
122
  : { restarted: [], skipped: [], failed: [] };
108
123
  printJson({ ...result, restarted, skipped, failed });
109
124
  if (!result.success || failed.length > 0) process.exitCode = 1;
@@ -124,7 +139,7 @@ export async function run(options, ctx) {
124
139
  return;
125
140
  }
126
141
 
127
- const { restarted, skipped, failed } = await restartInstances(options, ctx);
142
+ const { restarted, skipped, failed } = await restartInstances(options, restartCtx(ctx, result.installed));
128
143
  if (restarted.length === 0 && skipped.length === 0) {
129
144
  log('No running instance to restart.');
130
145
  }
@@ -0,0 +1,36 @@
1
+ // The context `kabmo update` restarts from.
2
+ //
3
+ // Pinned because getting it wrong is silent and expensive: after a RENAME the
4
+ // installed package is a different directory from the one this command runs
5
+ // out of, and restarting from `ctx.pkgRoot` relaunches the build the update
6
+ // just replaced. Measured on the 4.23.1 → 0.0.1 move: the console came back as
7
+ // the old version while the update reported success.
8
+
9
+ import { describe, expect, test } from 'bun:test';
10
+
11
+ import { restartCtx } from '@/cli/lib/commands/update.js';
12
+
13
+ const ctx = { cwd: '/work', pkgRoot: '/global/node_modules/ompchamber', version: '4.23.1', bin: 'ompchamber' };
14
+
15
+ describe('restartCtx', () => {
16
+ test('spawns the replacement from the copy the update installed', () => {
17
+ const installed = {
18
+ root: '/global/node_modules/@kabmo-labs/kabmo',
19
+ packageName: '@kabmo-labs/kabmo',
20
+ bin: 'kabmo',
21
+ cli: '/global/node_modules/@kabmo-labs/kabmo/src/cli/kabmo.js',
22
+ };
23
+ expect(restartCtx(ctx, installed)).toEqual({
24
+ ...ctx,
25
+ pkgRoot: '/global/node_modules/@kabmo-labs/kabmo',
26
+ bin: 'kabmo',
27
+ });
28
+ });
29
+
30
+ test('keeps this command\u2019s own root when the update reported none', () => {
31
+ // A checkout, or a run whose install could not be read: the copy that was
32
+ // updated IS this one, so nothing moves.
33
+ expect(restartCtx(ctx, null)).toBe(ctx);
34
+ expect(restartCtx(ctx, { root: '' })).toBe(ctx);
35
+ });
36
+ });
@@ -22,6 +22,14 @@ export interface KabmoBootstrap {
22
22
  * while this fact was missing, and the only way to ask was a second request.
23
23
  */
24
24
  authEnabled?: boolean;
25
+ /**
26
+ * The Android shell's version, when this page was requested by the APK
27
+ * (`KabmoAndroid/<version>` in the User-Agent). Null in every browser.
28
+ *
29
+ * The sidebar uses it to decide whether a download button applies at all, and
30
+ * whether this install is behind the published APK.
31
+ */
32
+ shellVersion?: string | null;
25
33
  }
26
34
 
27
35
  declare global {
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The login screen's header.
3
+ *
4
+ * It is the product's own lockup — the mark's green plate and the word — and
5
+ * nothing else: the key glyph that used to sit above it was a second icon for a
6
+ * screen whose only control is already labelled "Password". The assertions are
7
+ * about the RENDERED tree, because the failure this guards is a screen that
8
+ * still looks right in a class list while drawing two marks.
9
+ */
10
+
11
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
12
+ import { Window } from 'happy-dom';
13
+ import { h, render } from 'preact';
14
+ import { act } from 'preact/test-utils';
15
+
16
+ import { LoginScreen } from '@/client/components/auth/login-screen';
17
+
18
+ const DOM_GLOBALS = ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'HTMLInputElement', 'Event', 'MouseEvent', 'KeyboardEvent'] as const;
19
+ /** The runner's own globals, restored on teardown — deleting them would strip natives later files need. */
20
+ const nativeGlobals: Partial<Record<(typeof DOM_GLOBALS)[number], unknown>> = {};
21
+ const nativeFetch = globalThis.fetch;
22
+
23
+ let container: HTMLElement;
24
+
25
+ beforeAll(() => {
26
+ const win = new Window({ url: 'http://localhost' });
27
+ const target = globalThis as unknown as Record<string, unknown>;
28
+ for (const key of DOM_GLOBALS) {
29
+ if (!(key in nativeGlobals)) nativeGlobals[key] = target[key];
30
+ target[key] = (win as unknown as Record<string, unknown>)[key];
31
+ }
32
+ // The screen re-checks the session on mount; an unanswerable probe leaves the
33
+ // form alone, which is the state under test.
34
+ target.fetch = async () => {
35
+ throw new Error('offline');
36
+ };
37
+ });
38
+
39
+ afterAll(() => {
40
+ const target = globalThis as unknown as Record<string, unknown>;
41
+ for (const key of DOM_GLOBALS) {
42
+ if (nativeGlobals[key] === undefined) delete target[key];
43
+ else target[key] = nativeGlobals[key];
44
+ }
45
+ target.fetch = nativeFetch;
46
+ });
47
+
48
+ afterEach(() => {
49
+ if (container) render(null, container);
50
+ container?.remove();
51
+ });
52
+
53
+ async function mount(): Promise<HTMLElement> {
54
+ container = document.createElement('div');
55
+ document.body.appendChild(container);
56
+ await act(async () => {
57
+ render(h(LoginScreen, {}), container);
58
+ });
59
+ return container;
60
+ }
61
+
62
+ describe('LoginScreen header', () => {
63
+ test('draws the wordmark as the header, with the mark in it', async () => {
64
+ const el = await mount();
65
+ const heading = el.querySelector('h1');
66
+ expect(heading).toBeTruthy();
67
+ // The mark's own geometry, not a font letter: the plate plus three strokes.
68
+ const mark = heading?.querySelector('svg[viewBox="0 0 64 64"]');
69
+ expect(mark).toBeTruthy();
70
+ expect(mark?.querySelector('rect')).toBeTruthy();
71
+ expect(mark?.querySelectorAll('path').length).toBe(3);
72
+ // Hero size, so the lockup is the screen's identity rather than a caption.
73
+ expect(heading?.querySelector('span')?.className).toContain('text-3xl');
74
+ });
75
+
76
+ test('carries no second icon', async () => {
77
+ const el = await mount();
78
+ // The header holds exactly one SVG — the mark. A key glyph above it would be
79
+ // a second. The field's eye toggle is a CONTROL, not a mark, so it is
80
+ // deliberately outside this count.
81
+ const heading = el.querySelector('h1');
82
+ expect(heading?.querySelectorAll('svg').length).toBe(1);
83
+ expect(heading?.querySelector('svg')?.getAttribute('viewBox')).toBe('0 0 64 64');
84
+ });
85
+
86
+ test('still names the product for assistive tech and keeps the form', async () => {
87
+ const el = await mount();
88
+ expect(el.textContent).toContain('Kabmo');
89
+ const password = el.querySelector('input[type="password"]');
90
+ expect(password?.getAttribute('aria-label')).toBe('Password');
91
+ expect(el.querySelector('button[type="submit"]')?.textContent).toContain('Sign in');
92
+ });
93
+ });
@@ -20,12 +20,13 @@
20
20
 
21
21
  import { useEffect, useState } from 'preact/hooks';
22
22
  import type { FunctionComponent } from 'preact/compat';
23
- import { KeyRound, Loader2 } from 'lucide-preact';
23
+ import { Eye, EyeOff, Loader2 } from 'lucide-preact';
24
24
  import { login, probeSession } from '@/client/hooks/ui/auth';
25
25
  import { BrandWordmark } from '@/client/components/common/brand-wordmark';
26
26
 
27
27
  export const LoginScreen: FunctionComponent = () => {
28
28
  const [password, setPassword] = useState('');
29
+ const [showPassword, setShowPassword] = useState(false);
29
30
  const [trustDevice, setTrustDevice] = useState(false);
30
31
  const [error, setError] = useState('');
31
32
  const [busy, setBusy] = useState(false);
@@ -76,31 +77,51 @@ export const LoginScreen: FunctionComponent = () => {
76
77
  return (
77
78
  <div className="min-h-dvh flex items-center justify-center bg-canvas text-ink px-4">
78
79
  <form onSubmit={submit} className="w-full max-w-xs space-y-4">
79
- <div className="space-y-1 text-center">
80
- <KeyRound size={28} className="mx-auto text-ink/60" />
81
- {/* The sidebar header's brand: one wordmark, so the login screen and
82
- the app cannot disagree about what the product is called. */}
83
- <h1 className="font-bold text-sm tracking-tight flex items-center justify-center">
84
- <BrandWordmark />
80
+ <div className="space-y-2 text-center">
81
+ {/* The product's own lockup is the header here: one wordmark, drawn
82
+ at hero size, so the login screen and the app cannot disagree
83
+ about what the product is called. No second icon — the K *is* the
84
+ icon, and a key glyph above it said nothing the field's own label
85
+ does not. The eye in the field is a control, not a second mark. */}
86
+ <h1 className="flex items-center justify-center">
87
+ <BrandWordmark className="text-3xl" />
85
88
  </h1>
86
89
  <p className="text-xs text-ink/60">This instance is password protected.</p>
87
90
  </div>
88
91
 
89
- <input
90
- type="password"
91
- autoComplete="current-password"
92
- autoFocus
93
- value={password}
94
- onInput={(event) => {
95
- setPassword(event.currentTarget.value);
96
- if (error) setError('');
97
- }}
98
- placeholder="Password"
99
- aria-label="Password"
100
- aria-invalid={error ? true : undefined}
101
- disabled={busy}
102
- className="w-full bg-paper border border-ink/20 rounded-lg px-3 py-2 text-sm text-ink placeholder-ink/40 focus:outline-none focus:border-ink/50 transition-colors"
103
- />
92
+ <div className="relative">
93
+ <input
94
+ type={showPassword ? 'text' : 'password'}
95
+ autoComplete="current-password"
96
+ autoFocus
97
+ value={password}
98
+ onInput={(event) => {
99
+ setPassword(event.currentTarget.value);
100
+ if (error) setError('');
101
+ }}
102
+ placeholder="Password"
103
+ aria-label="Password"
104
+ aria-invalid={error ? true : undefined}
105
+ disabled={busy}
106
+ className="w-full bg-paper border border-ink/20 rounded-lg pl-3 pr-10 py-2 text-sm text-ink placeholder-ink/40 focus:outline-none focus:border-ink/50 transition-colors"
107
+ />
108
+ {/* The eye is a `type` toggle, not a second field: the value lives in
109
+ one state slot either way, so revealing it cannot change what is
110
+ submitted. It is a real button with its own label because the
111
+ alternative — a bare `<svg onClick>` — is unreachable by keyboard
112
+ and unnamed for assistive tech. */}
113
+ <button
114
+ type="button"
115
+ onClick={() => setShowPassword((shown) => !shown)}
116
+ disabled={busy}
117
+ title={showPassword ? 'Hide password' : 'Show password'}
118
+ aria-label={showPassword ? 'Hide password' : 'Show password'}
119
+ aria-pressed={showPassword}
120
+ className="absolute inset-y-0 right-0 flex items-center px-3 text-ink/50 hover:text-ink disabled:opacity-40 transition-colors cursor-pointer disabled:cursor-default"
121
+ >
122
+ {showPassword ? <EyeOff size={15} /> : <Eye size={15} />}
123
+ </button>
124
+ </div>
104
125
 
105
126
  <label className="flex items-center gap-2 text-xs text-ink/70">
106
127
  <input
@@ -0,0 +1,121 @@
1
+ /**
2
+ * The password field's visibility toggle.
3
+ *
4
+ * The eye is a `type` switch on the SAME input, so what it must not do is move
5
+ * the value: a reveal that rebuilt the field would clear the credential the
6
+ * user just typed. The assertions are about the rendered tree and the input's
7
+ * own `type`/`value`, because a class list proves nothing about either.
8
+ *
9
+ * The header's own assertions live in `index.test.tsx`; this file covers the
10
+ * control beside the field and nothing else.
11
+ */
12
+
13
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
14
+ import { Window } from 'happy-dom';
15
+ import { h, render } from 'preact';
16
+ import { act } from 'preact/test-utils';
17
+
18
+ import { LoginScreen } from '@/client/components/auth/login-screen';
19
+
20
+ const DOM_GLOBALS = ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'HTMLInputElement', 'Event', 'MouseEvent', 'KeyboardEvent'] as const;
21
+ /** The runner's own globals, restored on teardown — deleting them would strip natives later files need. */
22
+ const nativeGlobals: Partial<Record<(typeof DOM_GLOBALS)[number], unknown>> = {};
23
+ const nativeFetch = globalThis.fetch;
24
+
25
+ let container: HTMLElement;
26
+
27
+ beforeAll(() => {
28
+ const win = new Window({ url: 'http://localhost' });
29
+ const target = globalThis as unknown as Record<string, unknown>;
30
+ for (const key of DOM_GLOBALS) {
31
+ if (!(key in nativeGlobals)) nativeGlobals[key] = target[key];
32
+ target[key] = (win as unknown as Record<string, unknown>)[key];
33
+ }
34
+ // The screen re-checks the session on mount; an unanswerable probe leaves the
35
+ // form alone, which is the state under test.
36
+ target.fetch = async () => {
37
+ throw new Error('offline');
38
+ };
39
+ });
40
+
41
+ afterAll(() => {
42
+ const target = globalThis as unknown as Record<string, unknown>;
43
+ for (const key of DOM_GLOBALS) {
44
+ if (nativeGlobals[key] === undefined) delete target[key];
45
+ else target[key] = nativeGlobals[key];
46
+ }
47
+ target.fetch = nativeFetch;
48
+ });
49
+
50
+ afterEach(() => {
51
+ if (container) render(null, container);
52
+ container?.remove();
53
+ });
54
+
55
+ async function mount(): Promise<HTMLElement> {
56
+ container = document.createElement('div');
57
+ document.body.appendChild(container);
58
+ await act(async () => {
59
+ render(h(LoginScreen, {}), container);
60
+ });
61
+ return container;
62
+ }
63
+
64
+ /** The one field the form submits — matched by its label, never by `type`, which is what changes. */
65
+ function passwordField(el: HTMLElement): HTMLInputElement | null {
66
+ return el.querySelector<HTMLInputElement>('input[aria-label="Password"]');
67
+ }
68
+
69
+ describe('LoginScreen password visibility', () => {
70
+ test('starts masked, with a labelled toggle beside the field', async () => {
71
+ const el = await mount();
72
+ expect(passwordField(el)?.type).toBe('password');
73
+ const toggle = el.querySelector<HTMLButtonElement>('button[aria-label="Show password"]');
74
+ expect(toggle).toBeTruthy();
75
+ // A real button, not an svg with a click handler: keyboard reachable.
76
+ expect(toggle?.getAttribute('type')).toBe('button');
77
+ expect(toggle?.getAttribute('title')).toBe('Show password');
78
+ expect(toggle?.getAttribute('aria-pressed')).toBe('false');
79
+ });
80
+
81
+ test('reveals the same field and puts it back, keeping the value', async () => {
82
+ const el = await mount();
83
+ await act(async () => {
84
+ const input = passwordField(el);
85
+ if (input) {
86
+ input.value = 's3cret';
87
+ input.dispatchEvent(new Event('input', { bubbles: true }));
88
+ }
89
+ });
90
+ expect(passwordField(el)?.value).toBe('s3cret');
91
+
92
+ await act(async () => el.querySelector<HTMLButtonElement>('button[aria-label="Show password"]')?.click());
93
+ expect(passwordField(el)?.type).toBe('text');
94
+ expect(passwordField(el)?.value).toBe('s3cret');
95
+ const hide = el.querySelector<HTMLButtonElement>('button[aria-label="Hide password"]');
96
+ expect(hide?.getAttribute('aria-pressed')).toBe('true');
97
+ expect(hide?.getAttribute('title')).toBe('Hide password');
98
+
99
+ await act(async () => hide?.click());
100
+ expect(passwordField(el)?.type).toBe('password');
101
+ expect(passwordField(el)?.value).toBe('s3cret');
102
+ });
103
+
104
+ test('the toggle is disabled while a sign-in is in flight', async () => {
105
+ const el = await mount();
106
+ await act(async () => {
107
+ const input = passwordField(el);
108
+ if (input) {
109
+ input.value = 's3cret';
110
+ input.dispatchEvent(new Event('input', { bubbles: true }));
111
+ }
112
+ });
113
+ // The submit handler awaits `login`, so the in-flight state is observable
114
+ // in the same tick the form is submitted.
115
+ await act(async () => {
116
+ el.querySelector<HTMLFormElement>('form')?.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
117
+ });
118
+ expect(el.querySelector<HTMLButtonElement>('button[aria-label="Show password"]')?.disabled).toBe(true);
119
+ expect(passwordField(el)?.disabled).toBe(true);
120
+ });
121
+ });
@@ -0,0 +1,132 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The batch bar's contract, rendered.
8
+ *
9
+ * Three properties are load-bearing and each is a way a batch delete can go
10
+ * wrong: the bar names the mode's noun (a session batch that says "workspace"
11
+ * deletes the wrong thing), Delete is refused with nothing selected, and
12
+ * Select-all is refused once everything already is — a control that looks
13
+ * available but does nothing is how a user concludes the feature is broken.
14
+ */
15
+
16
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
17
+ import { Window } from 'happy-dom';
18
+ import { h, render } from 'preact';
19
+ import { act } from 'preact/test-utils';
20
+
21
+ import { BatchActionBar } from '@/client/components/common/batch-selection/BatchActionBar';
22
+ import type { BatchActionBarProps } from '@/client/components/common/batch-selection/BatchActionBar';
23
+
24
+ const DOM_GLOBALS = ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'HTMLInputElement', 'Event', 'MouseEvent', 'KeyboardEvent'] as const;
25
+ const nativeGlobals: Partial<Record<(typeof DOM_GLOBALS)[number], unknown>> = {};
26
+
27
+ let container: HTMLElement;
28
+
29
+ beforeAll(() => {
30
+ const win = new Window({ url: 'http://localhost' });
31
+ const target = globalThis as unknown as Record<string, unknown>;
32
+ for (const key of DOM_GLOBALS) {
33
+ nativeGlobals[key] = target[key];
34
+ target[key] = (win as unknown as Record<string, unknown>)[key];
35
+ }
36
+ });
37
+
38
+ afterAll(() => {
39
+ const target = globalThis as unknown as Record<string, unknown>;
40
+ for (const key of DOM_GLOBALS) {
41
+ if (nativeGlobals[key] === undefined) delete target[key];
42
+ else target[key] = nativeGlobals[key];
43
+ }
44
+ });
45
+
46
+ afterEach(() => {
47
+ container?.remove();
48
+ });
49
+
50
+ async function mount(overrides: Partial<BatchActionBarProps> = {}) {
51
+ const calls = { selectAll: 0, unselectAll: 0, del: 0, cancel: 0 };
52
+ container = document.createElement('div');
53
+ document.body.appendChild(container);
54
+ await act(async () => {
55
+ render(h(BatchActionBar, {
56
+ mode: 'session',
57
+ count: 0,
58
+ total: 3,
59
+ busy: false,
60
+ onSelectAll: () => { calls.selectAll += 1; },
61
+ onUnselectAll: () => { calls.unselectAll += 1; },
62
+ onDelete: () => { calls.del += 1; },
63
+ onCancel: () => { calls.cancel += 1; },
64
+ ...overrides,
65
+ }), container);
66
+ });
67
+ return { el: container, calls };
68
+ }
69
+
70
+ function buttonContaining(el: HTMLElement, text: string): HTMLButtonElement | null {
71
+ return Array.from(el.querySelectorAll('button'))
72
+ .find((button) => (button.textContent ?? '').includes(text)) as HTMLButtonElement ?? null;
73
+ }
74
+
75
+ describe('BatchActionBar', () => {
76
+ test('counts the selection against the scope, in the mode\'s noun', async () => {
77
+ const { el } = await mount({ count: 2, total: 5, scopeLabel: 'kabmo' });
78
+ expect(el.textContent).toContain('2');
79
+ expect(el.textContent).toContain('of 5 sessions selected');
80
+ expect(el.textContent).toContain('in kabmo');
81
+ });
82
+
83
+ test('workspace mode names workspaces and drops the scope clause', async () => {
84
+ const { el } = await mount({ mode: 'workspace', count: 1, total: 4, scopeLabel: 'kabmo' });
85
+ expect(el.textContent).toContain('of 4 workspaces selected');
86
+ expect(el.textContent).not.toContain('in kabmo');
87
+ });
88
+
89
+ test('Delete is refused with nothing selected', async () => {
90
+ const { el, calls } = await mount({ count: 0 });
91
+ const del = buttonContaining(el, 'Delete');
92
+ expect(del?.disabled).toBe(true);
93
+ await act(async () => { del?.click(); });
94
+ expect(calls.del).toBe(0);
95
+ });
96
+
97
+ test('Delete is enabled with a selection and reports its handler', async () => {
98
+ const { el, calls } = await mount({ count: 2 });
99
+ const del = buttonContaining(el, 'Delete');
100
+ expect(del?.disabled).toBe(false);
101
+ await act(async () => { del?.click(); });
102
+ expect(calls.del).toBe(1);
103
+ });
104
+
105
+ test('Select all is refused once everything is selected', async () => {
106
+ const { el } = await mount({ count: 3, total: 3 });
107
+ expect(buttonContaining(el, 'Select all')?.disabled).toBe(true);
108
+ });
109
+
110
+ test('Unselect all is refused with nothing selected', async () => {
111
+ const { el } = await mount({ count: 0 });
112
+ expect(buttonContaining(el, 'Unselect all')?.disabled).toBe(true);
113
+ });
114
+
115
+ test('both directions are separate controls and both report', async () => {
116
+ const { el, calls } = await mount({ count: 1, total: 3 });
117
+ await act(async () => {
118
+ buttonContaining(el, 'Select all')?.click();
119
+ buttonContaining(el, 'Unselect all')?.click();
120
+ });
121
+ expect(calls.selectAll).toBe(1);
122
+ expect(calls.unselectAll).toBe(1);
123
+ });
124
+
125
+ test('a running delete disables every control and says so', async () => {
126
+ const { el } = await mount({ count: 2, busy: true });
127
+ expect(el.textContent).toContain('Deleting…');
128
+ for (const button of Array.from(el.querySelectorAll('button'))) {
129
+ expect((button as HTMLButtonElement).disabled).toBe(true);
130
+ }
131
+ });
132
+ });