@kabmo-labs/kabmo 0.0.1 → 0.1.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 (61) hide show
  1. package/README.md +16 -11
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +323 -323
  4. package/dist/client/static/js/{index-4vyzz0z4.js → index-enkbbffs.js} +643 -643
  5. package/dist/client/static/js/index-yn17t04b.css +1 -0
  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/components/auth/login-screen/index.test.tsx +93 -0
  11. package/src/client/components/auth/login-screen/index.tsx +43 -22
  12. package/src/client/components/auth/login-screen/password-toggle.test.tsx +121 -0
  13. package/src/client/components/common/batch-selection/BatchActionBar.test.ts +132 -0
  14. package/src/client/components/common/batch-selection/BatchActionBar.tsx +118 -0
  15. package/src/client/components/common/batch-selection/BatchDeleteModal.tsx +177 -0
  16. package/src/client/components/common/batch-selection/SelectionCheckbox.tsx +58 -0
  17. package/src/client/components/common/batch-selection/folder-state.test.ts +130 -0
  18. package/src/client/components/common/batch-selection/folder-state.ts +101 -0
  19. package/src/client/components/common/batch-selection/types.ts +42 -0
  20. package/src/client/components/common/brand-wordmark/Mark.tsx +62 -0
  21. package/src/client/components/common/brand-wordmark/index.test.tsx +29 -7
  22. package/src/client/components/common/brand-wordmark/index.tsx +33 -11
  23. package/src/client/components/common/brand-wordmark/wordmark-svg.test.ts +88 -0
  24. package/src/client/components/common/workspace-options-menu/index.tsx +25 -11
  25. package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +169 -0
  26. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +72 -91
  27. package/src/client/components/layout/session-sidebar/SessionItem.batch.test.ts +162 -0
  28. package/src/client/components/layout/session-sidebar/SessionItem.tsx +35 -6
  29. package/src/client/components/layout/session-sidebar/SessionList.tsx +10 -1
  30. package/src/client/components/layout/session-sidebar/Toolbar.test.ts +2 -0
  31. package/src/client/components/layout/session-sidebar/Toolbar.tsx +28 -1
  32. package/src/client/components/layout/session-sidebar/index.tsx +87 -0
  33. package/src/client/components/mobile/SessionSidebar.tsx +78 -0
  34. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +56 -12
  35. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +8 -0
  36. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +27 -6
  37. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.test.ts +2 -0
  38. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +26 -1
  39. package/src/client/hooks/workspace/batch-delete-run.ts +95 -0
  40. package/src/client/hooks/workspace/batch-delete.ts +63 -0
  41. package/src/client/hooks/workspace/batch-selection.ts +98 -0
  42. package/src/client/tailwind.css +6 -0
  43. package/src/server/lib/lifecycle/restart.ts +52 -14
  44. package/src/server/lib/omp/session/delete-flow.server.ts +101 -0
  45. package/src/server/lib/updates/apply.ts +8 -0
  46. package/src/server/lib/updates/install-steps.ts +81 -0
  47. package/src/server/lib/updates/install.ts +19 -67
  48. package/src/server/lib/updates/installed-cli.test.ts +222 -0
  49. package/src/server/lib/updates/installed-cli.ts +139 -0
  50. package/src/server/lib/updates/omp.ts +1 -1
  51. package/src/server/routes/folders/batch-delete.ts +68 -0
  52. package/src/server/routes/folders/index.ts +4 -0
  53. package/src/server/routes/sessions/batch-delete.test.ts +139 -0
  54. package/src/server/routes/sessions/batch-delete.ts +60 -0
  55. package/src/server/routes/sessions/delete.ts +13 -51
  56. package/src/server/routes/sessions/index.ts +5 -0
  57. package/src/server/routes/updates/apply.ts +1 -1
  58. package/src/shared/lib/workspace/batch-select.test.ts +92 -0
  59. package/src/shared/lib/workspace/batch-select.ts +86 -0
  60. package/src/shared/lib/workspace/sidebar-toolbar.ts +6 -0
  61. package/dist/client/static/js/index-x9cehh1a.css +0 -1
@@ -0,0 +1,58 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The checkbox a sidebar row grows while a batch selection is active.
8
+ *
9
+ * A native `<input type="checkbox">` rather than a styled span: it carries the
10
+ * checked/indeterminate state, the keyboard behaviour and the announcement for
11
+ * free, and `indeterminate` (a workspace whose sessions are partly selected) is
12
+ * a DOM PROPERTY with no attribute equivalent — it has to be assigned, which is
13
+ * what the ref below is for.
14
+ *
15
+ * The click is stopped from propagating: the control sits inside a row that
16
+ * selects or expands on click, so a checkbox that bubbled would do both.
17
+ */
18
+
19
+ import { useEffect, useRef } from 'preact/hooks';
20
+
21
+ export interface SelectionCheckboxProps {
22
+ checked: boolean;
23
+ /** Draw the dash state (a header whose children are partly selected). */
24
+ indeterminate?: boolean;
25
+ /** The accessible name — every row's checkbox names the row it belongs to. */
26
+ label: string;
27
+ onChange: () => void;
28
+ /** `sm` is the row-sized control; `md` is the header's. */
29
+ size?: 'sm' | 'md';
30
+ }
31
+
32
+ export function SelectionCheckbox({
33
+ checked,
34
+ indeterminate = false,
35
+ label,
36
+ onChange,
37
+ size = 'sm',
38
+ }: SelectionCheckboxProps) {
39
+ const ref = useRef<HTMLInputElement>(null);
40
+
41
+ useEffect(() => {
42
+ if (ref.current) ref.current.indeterminate = indeterminate;
43
+ }, [indeterminate]);
44
+
45
+ return (
46
+ <input
47
+ ref={ref}
48
+ type="checkbox"
49
+ checked={checked}
50
+ aria-label={label}
51
+ onChange={onChange}
52
+ onClick={(event) => event.stopPropagation()}
53
+ className={`shrink-0 accent-ink cursor-pointer rounded border-ink/30 ${
54
+ size === 'md' ? 'w-3.5 h-3.5' : 'w-3 h-3'
55
+ }`}
56
+ />
57
+ );
58
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The folder-level batch rules, in isolation.
8
+ *
9
+ * This is the arithmetic both sidebars share, and every case below is a way the
10
+ * two could disagree — or a way "select all" could reach something the user
11
+ * cannot see. The pagination case is the one a browser found: a window would
12
+ * tick rows with no checkbox on screen and no way to untick them.
13
+ */
14
+
15
+ import { describe, expect, test } from 'bun:test';
16
+ import { folderBatchState } from '@/client/components/common/batch-selection/folder-state';
17
+ import type { BatchBinding } from '@/client/components/common/batch-selection/types';
18
+ import type { SessionItemData, WorkspaceFolderData } from '@/shared/types';
19
+
20
+ const folder = { id: 7, name: 'kabmo', isExpanded: true, sessions: [], hasMore: false, totalSessions: 0 } as WorkspaceFolderData;
21
+
22
+ function sessions(count: number, archived = 0): SessionItemData[] {
23
+ return Array.from({ length: count }, (_, index) => ({
24
+ id: `s${index}`,
25
+ folder_id: 7,
26
+ title: `session ${index}`,
27
+ is_archived: archived,
28
+ }));
29
+ }
30
+
31
+ /** A binding whose selection is `selected` over `mode`. */
32
+ function binding(
33
+ mode: 'session' | 'workspace',
34
+ selected: string[],
35
+ scopeFolderId: number | null = 7,
36
+ ): BatchBinding {
37
+ const all = new Set(selected);
38
+ return {
39
+ mode,
40
+ scopeFolderId,
41
+ selected,
42
+ toggle: () => {},
43
+ selectAllOf: () => {},
44
+ unselectAllOf: () => {},
45
+ summaryOf: (available) => {
46
+ const count = available.filter((id) => all.has(id)).length;
47
+ return {
48
+ count,
49
+ all: available.length > 0 && count === available.length,
50
+ some: count > 0 && count < available.length,
51
+ };
52
+ },
53
+ };
54
+ }
55
+
56
+ function state(overrides: Partial<Parameters<typeof folderBatchState>[0]> = {}) {
57
+ return folderBatchState({
58
+ folder,
59
+ sessions: sessions(3),
60
+ showArchived: false,
61
+ visibleCount: 1,
62
+ activeSessionId: null,
63
+ ...overrides,
64
+ });
65
+ }
66
+
67
+ describe('folderBatchState without a mode', () => {
68
+ test('no checkboxes and the window applies', () => {
69
+ const result = state();
70
+ expect(result.folderCheckbox).toBe(false);
71
+ expect(result.sessionCheckboxes).toBe(false);
72
+ expect(result.folderSummary).toBeNull();
73
+ // `visibleCount` 1 over 3 rows: the normal list paginates.
74
+ expect(result.rendered).toHaveLength(1);
75
+ expect(result.hiddenCount).toBe(2);
76
+ });
77
+ });
78
+
79
+ describe('folderBatchState in session mode', () => {
80
+ test('the scoped folder draws a header checkbox and row checkboxes', () => {
81
+ const result = state({ batch: binding('session', []) });
82
+ expect(result.folderCheckbox).toBe(true);
83
+ expect(result.sessionCheckboxes).toBe(true);
84
+ expect(result.selectableIds).toEqual(['s0', 's1', 's2']);
85
+ });
86
+
87
+ test('the window is lifted so every selectable row is on screen', () => {
88
+ const result = state({ batch: binding('session', []) });
89
+ expect(result.rendered).toHaveLength(3);
90
+ expect(result.hiddenCount).toBe(0);
91
+ });
92
+
93
+ test('a workspace that did not start the selection stays ordinary', () => {
94
+ const result = state({ batch: binding('session', [], 99) });
95
+ expect(result.folderCheckbox).toBe(false);
96
+ expect(result.sessionCheckboxes).toBe(false);
97
+ expect(result.rendered).toHaveLength(1);
98
+ });
99
+
100
+ test('the summary reports partial selection against the offered rows', () => {
101
+ const result = state({ batch: binding('session', ['s1']) });
102
+ expect(result.folderSummary).toEqual({ count: 1, all: false, some: true });
103
+ });
104
+
105
+ test('archived rows are not offered while the filter hides them', () => {
106
+ const result = state({
107
+ batch: binding('session', []),
108
+ sessions: [...sessions(2), ...sessions(1, 1)],
109
+ showArchived: false,
110
+ });
111
+ expect(result.selectableIds).toEqual(['s0', 's1']);
112
+ });
113
+ });
114
+
115
+ describe('folderBatchState in workspace mode', () => {
116
+ test('the header owns the FOLDER id, and rows stay ordinary', () => {
117
+ const result = state({ batch: binding('workspace', []) });
118
+ expect(result.folderCheckbox).toBe(true);
119
+ expect(result.sessionCheckboxes).toBe(false);
120
+ expect(result.selectableIds).toEqual(['7']);
121
+ // The window is untouched: workspace mode selects folders, not rows.
122
+ expect(result.rendered).toHaveLength(1);
123
+ expect(result.hiddenCount).toBe(2);
124
+ });
125
+
126
+ test('a selected folder reads as all of its own one-id scope', () => {
127
+ const result = state({ batch: binding('workspace', ['7']) });
128
+ expect(result.folderSummary).toEqual({ count: 1, all: true, some: false });
129
+ });
130
+ });
@@ -0,0 +1,101 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * One folder's batch state, shared by the desktop category and the phone's
8
+ * drawer item.
9
+ *
10
+ * The two layouts draw the same rules — which checkbox the header carries, what
11
+ * its tri-state reports, which rows are selectable, and how the pagination
12
+ * window behaves — and they had drifted into two copies of that arithmetic.
13
+ * Stating it once is what keeps "select all" counting the same rows on a phone
14
+ * and on a desktop.
15
+ *
16
+ * Three rules are load-bearing:
17
+ *
18
+ * - a workspace checkbox exists in BOTH modes, but means different things: the
19
+ * folder itself in `workspace` mode, its rows in `session` mode;
20
+ * - the summary is computed over the rows the ARCHIVE FILTER offers, so
21
+ * "select all" can never reach a row the user cannot see or unselect;
22
+ * - a session selection lifts the pagination window, because a window would
23
+ * tick rows that have no checkbox on screen.
24
+ */
25
+
26
+ import { selectableSessionIds } from '@/shared/lib/workspace/batch-select';
27
+ import { selectVisibleSessions } from '@/shared/lib/workspace/session-visibility';
28
+ import { headerSelectable, rowsSelectable } from '@/client/components/common/batch-selection/types';
29
+ import type { BatchBinding } from '@/client/components/common/batch-selection/types';
30
+ import type { SessionItemData, WorkspaceFolderData } from '@/shared/types';
31
+
32
+ export interface FolderBatchInput {
33
+ batch?: BatchBinding;
34
+ folder: WorkspaceFolderData;
35
+ /** The rows the archive filter currently offers. */
36
+ sessions: SessionItemData[];
37
+ showArchived: boolean;
38
+ /** The list's page size (`useShowMore`), unused while selecting. */
39
+ visibleCount: number;
40
+ activeSessionId: number | string | null;
41
+ sessionStatus?: Record<string, string | undefined>;
42
+ }
43
+
44
+ export interface FolderBatchState {
45
+ /** The header draws a checkbox (workspace mode, or this folder's session mode). */
46
+ folderCheckbox: boolean;
47
+ /** The rows draw checkboxes. */
48
+ sessionCheckboxes: boolean;
49
+ /** Null when the header has no checkbox. */
50
+ folderSummary: { count: number; all: boolean; some: boolean } | null;
51
+ /** What the header checkbox selects. */
52
+ selectableIds: string[];
53
+ /** Header click: select or unselect everything this header owns. */
54
+ toggleFolderSelection: () => void;
55
+ /** Rows to render — every offered row while selecting, else the window. */
56
+ rendered: SessionItemData[];
57
+ /** Rows the next "Show more" reveals; 0 while selecting. */
58
+ hiddenCount: number;
59
+ }
60
+
61
+ export function folderBatchState({
62
+ batch,
63
+ folder,
64
+ sessions,
65
+ showArchived,
66
+ visibleCount,
67
+ activeSessionId,
68
+ sessionStatus,
69
+ }: FolderBatchInput): FolderBatchState {
70
+ const folderCheckbox = batch ? headerSelectable(batch, folder.id) : false;
71
+ const sessionCheckboxes = batch ? rowsSelectable(batch, folder.id) : false;
72
+ const selectableIds = sessionCheckboxes
73
+ ? selectableSessionIds(sessions, showArchived)
74
+ : folderCheckbox
75
+ ? [String(folder.id)]
76
+ : [];
77
+ const folderSummary = batch && folderCheckbox ? batch.summaryOf(selectableIds) : null;
78
+
79
+ const windowed = selectVisibleSessions(sessions, visibleCount, { activeSessionId, sessionStatus });
80
+
81
+ const toggleFolderSelection = () => {
82
+ if (!batch) return;
83
+ if (folderCheckbox && !sessionCheckboxes) {
84
+ batch.toggle(String(folder.id));
85
+ return;
86
+ }
87
+ if (!sessionCheckboxes) return;
88
+ if (folderSummary?.all) batch.unselectAllOf(selectableIds);
89
+ else batch.selectAllOf(selectableIds);
90
+ };
91
+
92
+ return {
93
+ folderCheckbox,
94
+ sessionCheckboxes,
95
+ folderSummary,
96
+ selectableIds,
97
+ toggleFolderSelection,
98
+ rendered: sessionCheckboxes ? sessions : windowed.rendered,
99
+ hiddenCount: sessionCheckboxes ? 0 : windowed.hiddenCount,
100
+ };
101
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The props a category/folder hands down to its rows while a batch selection is
8
+ * active, shared by the desktop category and the phone's drawer item.
9
+ *
10
+ * It is one object rather than six props because every field is only meaningful
11
+ * together: a `selected` list without a `mode` is a selection nothing draws, and
12
+ * a `scopeFolderId` without the session mode is a scope nothing applies to. Both
13
+ * layouts read the same shape, so a row cannot grow a checkbox on one layout and
14
+ * not the other.
15
+ */
16
+
17
+ import type { BatchMode, SelectionSummary } from '@/shared/lib/workspace/batch-select';
18
+
19
+ export interface BatchBinding {
20
+ /** The active mode, or null when the sidebar is not selecting. */
21
+ mode: BatchMode | null;
22
+ /** The workspace a SESSION selection is scoped to (null in workspace mode). */
23
+ scopeFolderId: number | null;
24
+ /** Ids currently selected, in click order. */
25
+ selected: readonly string[];
26
+ toggle: (id: string) => void;
27
+ selectAllOf: (ids: readonly string[]) => void;
28
+ unselectAllOf: (ids: readonly string[]) => void;
29
+ /** Tri-state summary over a set of ids the folder can offer. */
30
+ summaryOf: (available: readonly string[]) => SelectionSummary;
31
+ }
32
+
33
+ /** True when this folder's own header should draw a checkbox. */
34
+ export function headerSelectable(binding: BatchBinding, folderId: number): boolean {
35
+ return binding.mode === 'workspace'
36
+ || (binding.mode === 'session' && binding.scopeFolderId === folderId);
37
+ }
38
+
39
+ /** True when this folder's SESSION rows should draw checkboxes. */
40
+ export function rowsSelectable(binding: BatchBinding, folderId: number): boolean {
41
+ return binding.mode === 'session' && binding.scopeFolderId === folderId;
42
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The `K` of the wordmark: the app icon itself.
3
+ *
4
+ * The wordmark used to spell its leading letter in the UI font and colour it
5
+ * orange, which made the K a coloured character rather than the brand mark.
6
+ * This is `public/icon.svg` — the same rounded green tile, the same three
7
+ * strokes at the same 7/64 weight, the same orange upper arm — drawn inline so
8
+ * the word starts with the logo.
9
+ *
10
+ * **The tile is sized to the cap band, not to the line box.** The icon's ink is
11
+ * 41 of its 64 units tall (0.64), so a tile of `1.5625 × cap` puts the K's own
12
+ * height exactly on the cap height — the letter does not shrink when it gains
13
+ * its plate. That makes the plate taller than the cap band, and a plate whose
14
+ * bottom sits on the baseline would lift the K half that difference; the
15
+ * `vertical-align` below shifts the whole box down by the overhang so the ink's
16
+ * bottom lands on the baseline and its top on the cap line. Both numbers are
17
+ * derived from the same two constants (`1.5625 = 1 / 0.64`, overhang
18
+ * `(1.5625 − 1) / 2`), so they cannot drift apart.
19
+ *
20
+ * Both are expressed in `cap`, which is the font's own cap height — the mark
21
+ * then aligns on every face the stack resolves to, and not just on the one this
22
+ * was measured against. `0.7046em` is that cap height on the current stack
23
+ * (10.5688px at 15px, 11.2734 at 16, 16.9102 at 24 — 0.70459 each) and the
24
+ * class carries it as the fallback for engines without the unit; an unsupported
25
+ * inline declaration is dropped, so the class applies there.
26
+ *
27
+ * `inline-block` is not decoration: Tailwind's preflight makes every `svg`
28
+ * `display: block`, and a block box inside the wordmark's inline flow breaks the
29
+ * word onto its own line.
30
+ *
31
+ * **The `a` is tucked into the plate by `0.28em` of negative right margin**, so
32
+ * the word reads as a lockup rather than a plate with a word after it. The limit
33
+ * is the icon's lower arm, which ends 2.1px inside the plate at 15px: measured
34
+ * at five depths, `-0.20em` left 2.12px of clearance to that stroke and
35
+ * `-0.28em` leaves 0.93px — the deepest value that still keeps the letter off
36
+ * the arm's round cap, while putting its ink 3.8px inside a 16.5px plate.
37
+ *
38
+ * The tile and cream are `--color-brand-tile` / `--color-brand-cream`, not theme
39
+ * tokens: the icon is the same mark in every palette, and on a dark canvas a
40
+ * themed plate would be a hole rather than a plate. They are set as inline
41
+ * `style` declarations rather than presentation attributes, because a `var()`
42
+ * in a presentation attribute is not resolved (measured: the rect computed to
43
+ * black and the stem to `none`).
44
+ */
45
+ export function BrandMark({ className = '' }: { className?: string }) {
46
+ return (
47
+ <svg
48
+ viewBox="0 0 64 64"
49
+ className={`inline-block h-[1.1009em] w-auto flex-none [vertical-align:-0.1982em] ${className}`}
50
+ style={{ height: '1.5625cap', verticalAlign: '-0.28125cap', marginRight: '-0.28em' }}
51
+ aria-hidden="true"
52
+ focusable="false"
53
+ >
54
+ <rect width="64" height="64" rx="14" style={{ fill: 'var(--color-brand-tile)' }} />
55
+ <g fill="none" strokeWidth={7} strokeLinecap="round" strokeLinejoin="round">
56
+ <path d="M20 15v34" style={{ stroke: 'var(--color-brand-cream)' }} />
57
+ <path d="M20 32 42 15" style={{ stroke: 'var(--color-brand)' }} />
58
+ <path d="M20 32 42 49" style={{ stroke: 'var(--color-brand-cream)' }} />
59
+ </g>
60
+ </svg>
61
+ );
62
+ }
@@ -70,24 +70,46 @@ describe('BrandWordmark', () => {
70
70
  expect([...runs.keys()]).toEqual(['K', 'ab', 'mo']);
71
71
  });
72
72
 
73
- test('each run carries its own colour class', () => {
73
+ test('each run carries its own colour', () => {
74
74
  const runs = runsOf(mount());
75
- // The K takes the icon's accent token, never a themed colour: the logo does
76
- // not change hue with the palette, and the K is the letter that carries it.
77
- expect(runs.get('K')?.className).toContain('text-brand');
75
+ // The K is the app icon, so its colours live on the SVG's own paint: the
76
+ // green plate, the cream strokes and the brand's orange upper arm.
77
+ const mark = runs.get('K')?.parentElement?.querySelector('svg');
78
+ expect(mark).toBeTruthy();
79
+ expect(mark?.getAttribute('aria-hidden')).toBe('true');
80
+ const tile = mark?.querySelector('rect');
81
+ expect(tile?.getAttribute('style')).toContain('--color-brand-tile');
82
+ const strokes = [...(mark?.querySelectorAll('path') ?? [])].map((p) => p.getAttribute('style'));
83
+ expect(strokes).toEqual([
84
+ 'stroke: var(--color-brand-cream);',
85
+ 'stroke: var(--color-brand);',
86
+ 'stroke: var(--color-brand-cream);',
87
+ ]);
88
+ expect(runs.get('K')?.className).toContain('sr-only');
78
89
  // `ab` keeps the previous wordmark's gradient, as a gradient (not a fill).
79
90
  expect(runs.get('ab')?.className).toContain('bg-clip-text');
80
91
  expect(runs.get('ab')?.className).toContain('bg-gradient-to-r');
81
- expect(runs.get('ab')?.className).toContain('from-orange-600');
82
- expect(runs.get('ab')?.className).toContain('to-amber-500');
92
+ expect(runs.get('ab')?.className).toContain('from-amber-500');
93
+ expect(runs.get('ab')?.className).toContain('to-orange-600');
83
94
  // `mo` stays surface ink, which is what keeps it legible on dark palettes.
84
95
  expect(runs.get('mo')?.className).toContain('text-ink');
85
96
  });
86
97
 
98
+ test('the gradient span pays for the b overhang and mo is spaced off it', () => {
99
+ const runs = runsOf(mount());
100
+ // `background-clip: text` clips to the span's box, and the b's right
101
+ // sidebearing is negative — without the padding its last pixels vanish.
102
+ const ab = runs.get('ab')!;
103
+ expect(ab.style.paddingRight).toBe('0.06em');
104
+ expect(ab.style.marginRight).toBe('-0.06em');
105
+ // b→m is the tight pair in this face; the margin makes it match a→b.
106
+ expect(runs.get('mo')!.style.marginLeft).toBe('0.02em');
107
+ });
108
+
87
109
  test('a caller sets the size and cannot drop the wordmark classes', () => {
88
110
  const root = mount('text-2xl').firstElementChild as HTMLElement;
89
111
  expect(root.className).toContain('text-2xl');
90
- expect(root.className).toContain('tracking-tighter');
112
+ expect(root.className).toContain('tracking-tight');
91
113
  expect(root.className).toContain('font-extrabold');
92
114
  });
93
115
  });
@@ -10,26 +10,48 @@
10
10
  * The word is drawn in three runs, each with its own colour, and the split is
11
11
  * deliberate rather than decorative:
12
12
  *
13
- * - **`K`** takes the icon's own accent (`--color-brand`, the orange of the
14
- * logo's upper arm). It is the letter that carries the mark, so it uses the
15
- * mark's colour exactly — a themed token here would make the K change hue
16
- * with the palette while the icon never does.
17
- * - **`ab`** keeps the gradient the previous wordmark used for its leading run
18
- * (`orange-600 → amber-500`), so the word still reads as a continuation of the
19
- * product it was renamed from.
13
+ * - **`K`** is the icon's mark, not a letter: `BrandMark` draws the logo's three
14
+ * strokes (ink stem and lower arm, `--color-brand` upper arm) so the word
15
+ * begins with the same shape the app icon and the favicon carry.
16
+ * - **`ab`** keeps the previous wordmark's gradient (`amber-500 → orange-600`),
17
+ * run from light to dark so the brightest end sits against the icon's dark
18
+ * green plate: the `a` is tucked into that plate, and a dark `a` on dark green
19
+ * is the one place the lockup would lose a letter. The warm end still lands
20
+ * where the word returns to ink.
20
21
  * - **`mo`** is plain surface ink, the colour the trailing run has always had.
21
22
  * Ink is what keeps that run legible on every palette, dark ones included.
22
23
  *
24
+ * Two measurements are encoded in the render, and both fix a letter that was
25
+ * being cut rather than merely crowded:
26
+ *
27
+ * - **`ab` carries a right padding of `0.06em` and an equal negative margin.**
28
+ * `background-clip: text` clips the gradient to the span's box, and the `b`'s
29
+ * right sidebearing is NEGATIVE — the glyph's ink ends past its advance — so
30
+ * the last 2-3 px of the `b` were clipped away at 15px and the letter read as
31
+ * if `m` were covering it. Measured on the live wordmark at 4x: the span's box
32
+ * ended 0.5px past the `b`'s ink with the clip and 3px past it once padded, so
33
+ * the padding pays for the overhang and the negative margin cancels it from
34
+ * the layout. A tracked-out `tracking-tight` (`-0.025em`) was part of the same
35
+ * crowding: at `-0.05em` the ink gap between `b` and `m` measured 0.017em.
36
+ * - **`mo` carries `margin-left: 0.02em`.** With the tracking loosened, `a`→`b`
37
+ * measures 0.063em of ink gap while `b`→`m` measures 0.043em — the pair reads
38
+ * tight because `m`'s left stem is a full vertical beside `b`'s bowl. The
39
+ * margin brings the second gap to 0.063em, the same as the first, and leaves
40
+ * `m`→`o` (0.042em, the font's own spacing) alone.
41
+ *
23
42
  * `className` carries the SIZE (each surface sets its own) and nothing else: the
24
43
  * colour split and the tracking are the brand and are not a per-call-site
25
44
  * decision.
26
45
  */
46
+ import { BrandMark } from '@/client/components/common/brand-wordmark/Mark';
47
+
27
48
  export function BrandWordmark({ className = 'text-[15px]' }: { className?: string }) {
28
49
  return (
29
- <span className={`font-extrabold tracking-tighter ${className}`}>
30
- <span className="text-brand">K</span>
31
- <span className="text-transparent bg-clip-text bg-gradient-to-r from-orange-600 to-amber-500">ab</span>
32
- <span className="text-ink">mo</span>
50
+ <span className={`font-extrabold tracking-tight ${className}`}>
51
+ <BrandMark />
52
+ <span className="sr-only">K</span>
53
+ <span className="text-transparent bg-clip-text bg-gradient-to-r from-amber-500 to-orange-600" style={{ paddingRight: '0.06em', marginRight: '-0.06em' }}>ab</span>
54
+ <span className="text-ink" style={{ marginLeft: '0.02em' }}>mo</span>
33
55
  </span>
34
56
  );
35
57
  }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The README wordmark (`public/kabmo-wordmark.svg`).
3
+ *
4
+ * It is a standalone asset — no app stylesheet, no font, no component — so a
5
+ * README can embed it from any renderer. That independence is exactly why it
6
+ * needs a test: nothing else in the repository reads the file, and every
7
+ * property below is one a hand edit would break silently.
8
+ *
9
+ * The numbers are the app's own, measured from `BrandWordmark`: a 100-unit cap
10
+ * height, the plate at `1.5625 x cap` (the icon's ink is 0.64 of its tile, so
11
+ * the K keeps its proportions), the `a` tucked 0.28em into the plate, the
12
+ * `b`→`m` gap widened 0.02em, and `tracking-tight` at -0.025em.
13
+ */
14
+
15
+ import { describe, expect, test } from 'bun:test';
16
+ import { readFileSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+
19
+ const SOURCE = readFileSync(join(import.meta.dir, '../../../../../public/kabmo-wordmark.svg'), 'utf8');
20
+
21
+ function attr(name: string): string {
22
+ const match = SOURCE.match(new RegExp(`${name}="([^"]+)"`));
23
+ if (!match) throw new Error(`missing ${name}`);
24
+ return match[1];
25
+ }
26
+
27
+ describe('public/kabmo-wordmark.svg', () => {
28
+ test('is a self-contained, accessible image', () => {
29
+ expect(SOURCE.startsWith('<?xml')).toBe(true);
30
+ expect(SOURCE).toContain('xmlns="http://www.w3.org/2000/svg"');
31
+ // A README's <img> gets its accessible name from the SVG itself when the
32
+ // renderer honours it; `alt` on the <img> is the other half, and both say
33
+ // the same thing.
34
+ expect(attr('role')).toBe('img');
35
+ expect(attr('aria-label')).toBe('Kabmo');
36
+ expect(SOURCE).toContain('<title>Kabmo</title>');
37
+ // No external dependency: a font reference or a fetched resource would fail
38
+ // in a renderer that sandboxes images. (The xmlns URI is a namespace name,
39
+ // never fetched, so it is not a dependency.)
40
+ expect(SOURCE).not.toMatch(/@font-face|(?:href|src)="https?:|url\(['"]?https?:/);
41
+ });
42
+
43
+ test('draws the icon plate at the size the K needs', () => {
44
+ const viewBox = attr('viewBox').split(/\s+/).map(Number);
45
+ expect(viewBox).toHaveLength(4);
46
+ const plate = SOURCE.match(/<rect width="([\d.]+)" height="([\d.]+)" y="([-\d.]+)"/);
47
+ expect(plate).toBeTruthy();
48
+ const [, w, h, y] = plate!.map(Number);
49
+ // 1.5625 x a 100-unit cap: the icon's ink is 41 of its 64 units, so this is
50
+ // the tile that puts the K exactly on the cap height.
51
+ expect(w).toBe(156.25);
52
+ expect(h).toBe(156.25);
53
+ // The ink (41/64 of the tile) is centred on the cap band, whose top is at
54
+ // -100 in this coordinate system.
55
+ const inkTop = y + (h - 100) / 2;
56
+ expect(Math.abs(inkTop + 100)).toBeLessThan(0.02);
57
+ // The viewBox is the artwork's own bounds, so a renderer adds no padding.
58
+ expect(Math.abs(viewBox[1] - y)).toBeLessThan(0.02);
59
+ expect(viewBox[3]).toBeCloseTo(156.25, 1);
60
+ });
61
+
62
+ test('keeps the brand colours, with one ink that works on any README', () => {
63
+ expect(SOURCE).toContain('fill="#0b5d3b"'); // plate
64
+ expect(SOURCE).toContain('stroke="#f7f4ea"'); // stem + lower arm
65
+ expect(SOURCE).toContain('stroke="#f26522"'); // upper arm
66
+ expect(SOURCE).toContain('stop-color="#f59e0b"'); // a, the light end
67
+ expect(SOURCE).toContain('stop-color="#ea580c"'); // b, the dark end
68
+ // One ink, not a media query. A README embeds this as an image, and inside
69
+ // an image `prefers-color-scheme` follows the CONTAINER only in Chromium —
70
+ // WebKit resolves it from the OS. Measured: GitHub dark on a light OS
71
+ // rendered #141310 on #0d1117 (1.02:1) in WebKit. #7e7a72 holds 4.27 on
72
+ // white and 4.43 on GitHub dark, so it needs no switch at all.
73
+ expect(SOURCE).toContain('.kabmo-mo { fill: #7e7a72; }');
74
+ // No media rule at all: the ink must not depend on how the renderer
75
+ // resolves a colour scheme, which differs between engines inside an image.
76
+ expect(SOURCE).not.toContain('@media');
77
+ expect(SOURCE).toContain('fill="#7e7a72"');
78
+ });
79
+
80
+ test('outlines the word, so no font is required', () => {
81
+ // Three strokes plus two outlined runs: `ab` and `mo`.
82
+ expect(SOURCE.match(/<path /g)?.length).toBe(5);
83
+ expect(SOURCE).toContain('fill="url(#kabmo-ab)"');
84
+ expect(SOURCE).toContain('class="kabmo-mo"');
85
+ // Outlines are curves, not text elements a renderer could re-flow.
86
+ expect(SOURCE).not.toContain('<text');
87
+ });
88
+ });