@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.
- package/README.md +16 -11
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +323 -323
- package/dist/client/static/js/{index-4vyzz0z4.js → index-enkbbffs.js} +643 -643
- package/dist/client/static/js/index-yn17t04b.css +1 -0
- package/package.json +2 -1
- package/public/kabmo-wordmark.svg +31 -0
- package/src/cli/lib/commands/update.js +17 -2
- package/src/cli/lib/commands/update.test.js +36 -0
- package/src/client/components/auth/login-screen/index.test.tsx +93 -0
- package/src/client/components/auth/login-screen/index.tsx +43 -22
- package/src/client/components/auth/login-screen/password-toggle.test.tsx +121 -0
- package/src/client/components/common/batch-selection/BatchActionBar.test.ts +132 -0
- package/src/client/components/common/batch-selection/BatchActionBar.tsx +118 -0
- package/src/client/components/common/batch-selection/BatchDeleteModal.tsx +177 -0
- package/src/client/components/common/batch-selection/SelectionCheckbox.tsx +58 -0
- package/src/client/components/common/batch-selection/folder-state.test.ts +130 -0
- package/src/client/components/common/batch-selection/folder-state.ts +101 -0
- package/src/client/components/common/batch-selection/types.ts +42 -0
- package/src/client/components/common/brand-wordmark/Mark.tsx +62 -0
- package/src/client/components/common/brand-wordmark/index.test.tsx +29 -7
- package/src/client/components/common/brand-wordmark/index.tsx +33 -11
- package/src/client/components/common/brand-wordmark/wordmark-svg.test.ts +88 -0
- package/src/client/components/common/workspace-options-menu/index.tsx +25 -11
- package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +169 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +72 -91
- package/src/client/components/layout/session-sidebar/SessionItem.batch.test.ts +162 -0
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +35 -6
- package/src/client/components/layout/session-sidebar/SessionList.tsx +10 -1
- package/src/client/components/layout/session-sidebar/Toolbar.test.ts +2 -0
- package/src/client/components/layout/session-sidebar/Toolbar.tsx +28 -1
- package/src/client/components/layout/session-sidebar/index.tsx +87 -0
- package/src/client/components/mobile/SessionSidebar.tsx +78 -0
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +56 -12
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +8 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +27 -6
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.test.ts +2 -0
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +26 -1
- package/src/client/hooks/workspace/batch-delete-run.ts +95 -0
- package/src/client/hooks/workspace/batch-delete.ts +63 -0
- package/src/client/hooks/workspace/batch-selection.ts +98 -0
- package/src/client/tailwind.css +6 -0
- package/src/server/lib/lifecycle/restart.ts +52 -14
- package/src/server/lib/omp/session/delete-flow.server.ts +101 -0
- package/src/server/lib/updates/apply.ts +8 -0
- package/src/server/lib/updates/install-steps.ts +81 -0
- package/src/server/lib/updates/install.ts +19 -67
- package/src/server/lib/updates/installed-cli.test.ts +222 -0
- package/src/server/lib/updates/installed-cli.ts +139 -0
- package/src/server/lib/updates/omp.ts +1 -1
- package/src/server/routes/folders/batch-delete.ts +68 -0
- package/src/server/routes/folders/index.ts +4 -0
- package/src/server/routes/sessions/batch-delete.test.ts +139 -0
- package/src/server/routes/sessions/batch-delete.ts +60 -0
- package/src/server/routes/sessions/delete.ts +13 -51
- package/src/server/routes/sessions/index.ts +5 -0
- package/src/server/routes/updates/apply.ts +1 -1
- package/src/shared/lib/workspace/batch-select.test.ts +92 -0
- package/src/shared/lib/workspace/batch-select.ts +86 -0
- package/src/shared/lib/workspace/sidebar-toolbar.ts +6 -0
- 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
|
|
73
|
+
test('each run carries its own colour', () => {
|
|
74
74
|
const runs = runsOf(mount());
|
|
75
|
-
// The K
|
|
76
|
-
//
|
|
77
|
-
|
|
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-
|
|
82
|
-
expect(runs.get('ab')?.className).toContain('to-
|
|
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-
|
|
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`**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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-
|
|
30
|
-
<
|
|
31
|
-
<span className="
|
|
32
|
-
<span className="text-
|
|
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
|
+
});
|