cursedbelt 5.67.0 → 5.69.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 (143) hide show
  1. package/dist/looks.css +41 -0
  2. package/dist/react/canvas-hud/CanvasToolbar.d.ts +67 -0
  3. package/dist/react/canvas-hud/CanvasToolbar.d.ts.map +1 -0
  4. package/dist/react/canvas-hud/CanvasToolbar.js +84 -0
  5. package/dist/react/canvas-hud/CanvasToolbar.js.map +1 -0
  6. package/dist/react/canvas-hud/index.d.ts +2 -0
  7. package/dist/react/canvas-hud/index.d.ts.map +1 -1
  8. package/dist/react/canvas-hud/index.js +1 -0
  9. package/dist/react/canvas-hud/index.js.map +1 -1
  10. package/dist/react/components/ActionBar.d.ts.map +1 -1
  11. package/dist/react/components/ActionBar.js +9 -1
  12. package/dist/react/components/ActionBar.js.map +1 -1
  13. package/dist/react/components/DataTable/DataTable.d.ts.map +1 -1
  14. package/dist/react/components/DataTable/DataTable.js +3 -3
  15. package/dist/react/components/DataTable/DataTable.js.map +1 -1
  16. package/dist/react/components/EntityCard.d.ts +35 -0
  17. package/dist/react/components/EntityCard.d.ts.map +1 -0
  18. package/dist/react/components/EntityCard.js +18 -0
  19. package/dist/react/components/EntityCard.js.map +1 -0
  20. package/dist/react/components/Grid.d.ts.map +1 -1
  21. package/dist/react/components/Grid.js +4 -2
  22. package/dist/react/components/Grid.js.map +1 -1
  23. package/dist/react/components/ScrollArea.d.ts +1 -1
  24. package/dist/react/components/ScrollArea.js +1 -1
  25. package/dist/react/components/SecretInput.d.ts.map +1 -1
  26. package/dist/react/components/SecretInput.js +8 -2
  27. package/dist/react/components/SecretInput.js.map +1 -1
  28. package/dist/react/components/SegmentedControl.d.ts.map +1 -1
  29. package/dist/react/components/SegmentedControl.js +5 -1
  30. package/dist/react/components/SegmentedControl.js.map +1 -1
  31. package/dist/react/hooks/useKeyboardOpen.d.ts +13 -0
  32. package/dist/react/hooks/useKeyboardOpen.d.ts.map +1 -0
  33. package/dist/react/hooks/useKeyboardOpen.js +30 -0
  34. package/dist/react/hooks/useKeyboardOpen.js.map +1 -0
  35. package/dist/react/index.d.ts +3 -0
  36. package/dist/react/index.d.ts.map +1 -1
  37. package/dist/react/index.js +3 -0
  38. package/dist/react/index.js.map +1 -1
  39. package/dist/react/look/AppearanceMenu.d.ts +29 -0
  40. package/dist/react/look/AppearanceMenu.d.ts.map +1 -0
  41. package/dist/react/look/AppearanceMenu.js +39 -0
  42. package/dist/react/look/AppearanceMenu.js.map +1 -0
  43. package/dist/react/look/AppearancePanel.d.ts +18 -0
  44. package/dist/react/look/AppearancePanel.d.ts.map +1 -0
  45. package/dist/react/look/AppearancePanel.js +20 -0
  46. package/dist/react/look/AppearancePanel.js.map +1 -0
  47. package/dist/react/look/LookPicker.d.ts +6 -1
  48. package/dist/react/look/LookPicker.d.ts.map +1 -1
  49. package/dist/react/look/LookPicker.js +2 -2
  50. package/dist/react/look/LookPicker.js.map +1 -1
  51. package/dist/react/media/denseGallery.css +2 -0
  52. package/dist/styles-areas/canvas-hud.css +6 -4
  53. package/dist/styles-areas/core.css +45 -44
  54. package/dist/styles-areas/fields.css +2 -2
  55. package/dist/styles-areas/filter-rail.css +5 -5
  56. package/dist/styles-areas/nav.css +6 -6
  57. package/dist/styles-areas/notifications.css +3 -3
  58. package/dist/styles-areas/place-bar.css +2 -2
  59. package/dist/styles-utilities.css +43 -8
  60. package/dist/testing/appleDevices.d.ts +68 -0
  61. package/dist/testing/appleDevices.d.ts.map +1 -0
  62. package/dist/testing/appleDevices.js +65 -0
  63. package/dist/testing/appleDevices.js.map +1 -0
  64. package/dist/testing/appleWalk.d.ts +72 -0
  65. package/dist/testing/appleWalk.d.ts.map +1 -0
  66. package/dist/testing/appleWalk.js +209 -0
  67. package/dist/testing/appleWalk.js.map +1 -0
  68. package/dist/testing/designAudit.d.ts +1 -1
  69. package/dist/testing/designAudit.d.ts.map +1 -1
  70. package/dist/testing/designAudit.js +12 -0
  71. package/dist/testing/designAudit.js.map +1 -1
  72. package/dist/testing/designBaseline.d.ts +14 -0
  73. package/dist/testing/designBaseline.d.ts.map +1 -1
  74. package/dist/testing/designBaseline.js +26 -28
  75. package/dist/testing/designBaseline.js.map +1 -1
  76. package/dist/testing/index.d.ts +6 -0
  77. package/dist/testing/index.d.ts.map +1 -1
  78. package/dist/testing/index.js +9 -0
  79. package/dist/testing/index.js.map +1 -1
  80. package/dist/testing/lookPrePaint.d.ts +19 -0
  81. package/dist/testing/lookPrePaint.d.ts.map +1 -0
  82. package/dist/testing/lookPrePaint.js +56 -0
  83. package/dist/testing/lookPrePaint.js.map +1 -0
  84. package/dist/testing/phoneRules.d.ts +98 -0
  85. package/dist/testing/phoneRules.d.ts.map +1 -0
  86. package/dist/testing/phoneRules.js +282 -0
  87. package/dist/testing/phoneRules.js.map +1 -0
  88. package/dist/testing/safariDriver.d.ts +103 -0
  89. package/dist/testing/safariDriver.d.ts.map +1 -0
  90. package/dist/testing/safariDriver.js +288 -0
  91. package/dist/testing/safariDriver.js.map +1 -0
  92. package/dist/testing/safariRules.d.ts +57 -0
  93. package/dist/testing/safariRules.d.ts.map +1 -0
  94. package/dist/testing/safariRules.js +191 -0
  95. package/dist/testing/safariRules.js.map +1 -0
  96. package/package.json +2 -1
  97. package/scripts/styleAreas.ts +1 -0
  98. package/src/fonts/inter-OFL.txt +93 -0
  99. package/src/fonts/inter-latin-wght-normal.woff2 +0 -0
  100. package/src/looks.css +41 -0
  101. package/src/looks.spec.ts +59 -1
  102. package/src/react/canvas-hud/CanvasToolbar.tsx +177 -0
  103. package/src/react/canvas-hud/index.ts +2 -0
  104. package/src/react/components/ActionBar.spec.tsx +49 -1
  105. package/src/react/components/ActionBar.tsx +10 -2
  106. package/src/react/components/DataTable/DataTable.tsx +6 -1
  107. package/src/react/components/EntityCard.tsx +76 -0
  108. package/src/react/components/Grid.tsx +4 -2
  109. package/src/react/components/ScrollArea.tsx +1 -1
  110. package/src/react/components/SecretInput.spec.tsx +14 -0
  111. package/src/react/components/SecretInput.tsx +13 -1
  112. package/src/react/components/SegmentedControl.spec.tsx +1 -1
  113. package/src/react/components/SegmentedControl.tsx +5 -1
  114. package/src/react/components/tooltipWarm.spec.tsx +15 -0
  115. package/src/react/hooks/useKeyboardOpen.ts +29 -0
  116. package/src/react/index.ts +3 -0
  117. package/src/react/look/AppearanceMenu.spec.tsx +90 -0
  118. package/src/react/look/AppearanceMenu.tsx +64 -0
  119. package/src/react/look/AppearancePanel.tsx +54 -0
  120. package/src/react/look/LookPicker.tsx +14 -2
  121. package/src/react/media/denseGallery.css +2 -0
  122. package/src/styles-areas/canvas-hud.css +6 -4
  123. package/src/styles-areas/core.css +45 -44
  124. package/src/styles-areas/fields.css +2 -2
  125. package/src/styles-areas/filter-rail.css +5 -5
  126. package/src/styles-areas/nav.css +6 -6
  127. package/src/styles-areas/notifications.css +3 -3
  128. package/src/styles-areas/place-bar.css +2 -2
  129. package/src/styles-utilities.css +43 -8
  130. package/src/testing/appleDevices.spec.ts +36 -0
  131. package/src/testing/appleDevices.ts +97 -0
  132. package/src/testing/appleWalk.ts +232 -0
  133. package/src/testing/designAudit.ts +12 -0
  134. package/src/testing/designBaseline.spec.ts +9 -0
  135. package/src/testing/designBaseline.ts +26 -34
  136. package/src/testing/index.ts +18 -0
  137. package/src/testing/lookPrePaint.spec.ts +43 -0
  138. package/src/testing/lookPrePaint.ts +60 -0
  139. package/src/testing/phoneRules.spec.ts +75 -0
  140. package/src/testing/phoneRules.ts +332 -0
  141. package/src/testing/safariDriver.ts +331 -0
  142. package/src/testing/safariRules.spec.ts +92 -0
  143. package/src/testing/safariRules.ts +195 -0
@@ -0,0 +1,93 @@
1
+ Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter)
2
+
3
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
4
+ This license is copied below, and is also available with a FAQ at:
5
+ http://scripts.sil.org/OFL
6
+
7
+
8
+ -----------------------------------------------------------
9
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10
+ -----------------------------------------------------------
11
+
12
+ PREAMBLE
13
+ The goals of the Open Font License (OFL) are to stimulate worldwide
14
+ development of collaborative font projects, to support the font creation
15
+ efforts of academic and linguistic communities, and to provide a free and
16
+ open framework in which fonts may be shared and improved in partnership
17
+ with others.
18
+
19
+ The OFL allows the licensed fonts to be used, studied, modified and
20
+ redistributed freely as long as they are not sold by themselves. The
21
+ fonts, including any derivative works, can be bundled, embedded,
22
+ redistributed and/or sold with any software provided that any reserved
23
+ names are not used by derivative works. The fonts and derivatives,
24
+ however, cannot be released under any other type of license. The
25
+ requirement for fonts to remain under this license does not apply
26
+ to any document created using the fonts or their derivatives.
27
+
28
+ DEFINITIONS
29
+ "Font Software" refers to the set of files released by the Copyright
30
+ Holder(s) under this license and clearly marked as such. This may
31
+ include source files, build scripts and documentation.
32
+
33
+ "Reserved Font Name" refers to any names specified as such after the
34
+ copyright statement(s).
35
+
36
+ "Original Version" refers to the collection of Font Software components as
37
+ distributed by the Copyright Holder(s).
38
+
39
+ "Modified Version" refers to any derivative made by adding to, deleting,
40
+ or substituting -- in part or in whole -- any of the components of the
41
+ Original Version, by changing formats or by porting the Font Software to a
42
+ new environment.
43
+
44
+ "Author" refers to any designer, engineer, programmer, technical
45
+ writer or other person who contributed to the Font Software.
46
+
47
+ PERMISSION & CONDITIONS
48
+ Permission is hereby granted, free of charge, to any person obtaining
49
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
50
+ redistribute, and sell modified and unmodified copies of the Font
51
+ Software, subject to the following conditions:
52
+
53
+ 1) Neither the Font Software nor any of its individual components,
54
+ in Original or Modified Versions, may be sold by itself.
55
+
56
+ 2) Original or Modified Versions of the Font Software may be bundled,
57
+ redistributed and/or sold with any software, provided that each copy
58
+ contains the above copyright notice and this license. These can be
59
+ included either as stand-alone text files, human-readable headers or
60
+ in the appropriate machine-readable metadata fields within text or
61
+ binary files as long as those fields can be easily viewed by the user.
62
+
63
+ 3) No Modified Version of the Font Software may use the Reserved Font
64
+ Name(s) unless explicit written permission is granted by the corresponding
65
+ Copyright Holder. This restriction only applies to the primary font name as
66
+ presented to the users.
67
+
68
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69
+ Software shall not be used to promote, endorse or advertise any
70
+ Modified Version, except to acknowledge the contribution(s) of the
71
+ Copyright Holder(s) and the Author(s) or with their explicit written
72
+ permission.
73
+
74
+ 5) The Font Software, modified or unmodified, in part or in whole,
75
+ must be distributed entirely under this license, and must not be
76
+ distributed under any other license. The requirement for fonts to
77
+ remain under this license does not apply to any document created
78
+ using the Font Software.
79
+
80
+ TERMINATION
81
+ This license becomes null and void if any of the above conditions are
82
+ not met.
83
+
84
+ DISCLAIMER
85
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93
+ OTHER DEALINGS IN THE FONT SOFTWARE.
package/src/looks.css CHANGED
@@ -235,3 +235,44 @@
235
235
  --field-placeholder: var(--muted-foreground);
236
236
  --field-ring: var(--ring);
237
237
  }
238
+
239
+ /* ─────────────────────────────── cb-font-inter ─────────────────────────────── */
240
+ /*
241
+ * A TYPE scope, not a look: it changes the face and nothing else, so it composes with any look
242
+ * (`<html class="cb-obsidian cb-font-inter">`). The belt's stack is the system font — SF on a Mac,
243
+ * which is what Linear falls back to — but Windows and Android fall to Segoe and Roboto, so the
244
+ * same app read as two products depending on the OS (forge task 2915). An app opts in by putting
245
+ * the class on <html> beside its look (in index.html, so the first paint asks for the face).
246
+ *
247
+ * Self-hosted: the file ships INSIDE the belt (`src/fonts/`, with its OFL licence), copied from
248
+ * `@fontsource-variable/inter` (a devDependency — looks.spec holds the two byte-identical), and the
249
+ * relative url below is rebased by the app's Tailwind and copied into its own assets, so it is
250
+ * served from the app's origin — never a Google Fonts link: no third-party request, nothing for a
251
+ * CSP to refuse. Not a runtime dependency on purpose: a bare package url resolves from the APP's
252
+ * node_modules, which a library preview (tools/lib-preview.ts) never installs into, and which a
253
+ * nested install would miss. LATIN ONLY, one variable file for
254
+ * every weight: the fleet's copy is English, and the rest of the alphabet falls through to the
255
+ * system stack behind it. `font-display: swap` paints in the system face at once and swaps when the
256
+ * file lands; the metrics of the two are close enough that a TopBar does not move (the app's
257
+ * browser gate reads the layout shift). cv11 is the single-storey a, ss01 the open digits — the
258
+ * pair Linear sets.
259
+ */
260
+ @font-face {
261
+ font-family: 'Inter Variable';
262
+ font-style: normal;
263
+ font-display: swap;
264
+ font-weight: 100 900;
265
+ src: url('./fonts/inter-latin-wght-normal.woff2') format('woff2-variations');
266
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
267
+ U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
268
+ }
269
+
270
+ .cb-font-inter {
271
+ --font-sans: 'Inter Variable', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
272
+ 'Noto Sans', Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
273
+ /* Tailwind's preflight reads these two on <html>; re-run them here, where --font-sans changed. */
274
+ --default-font-family: var(--font-sans);
275
+ --default-font-feature-settings: 'cv11', 'ss01';
276
+ font-family: var(--font-sans);
277
+ font-feature-settings: 'cv11', 'ss01';
278
+ }
package/src/looks.spec.ts CHANGED
@@ -69,7 +69,8 @@ describe('looks.css', () => {
69
69
  }
70
70
  expect(declarationsOf(LOOKS_CSS, '.cb-aurora')).toBeNull();
71
71
  const plain = LOOKS_CSS.replace(/\/\*[\s\S]*?\*\//g, '');
72
- const scopes = new Set([...plain.matchAll(/\.cb-([a-z]+)/g)].map((m) => m[1]));
72
+ // The type scopes (`cb-font-*`) change the face and no surface, so they are not looks.
73
+ const scopes = new Set([...plain.matchAll(/\.cb-([a-z-]+)/g)].map((m) => m[1]).filter((s) => !s?.startsWith('font-')));
73
74
  expect([...scopes].sort()).toEqual(SHIPPED.map((l) => l.id).sort());
74
75
  });
75
76
 
@@ -126,3 +127,60 @@ describe('looks.css', () => {
126
127
  expect(new Set(LOOKS.map((l) => l.id)).size).toBe(LOOKS.length);
127
128
  });
128
129
  });
130
+
131
+ /**
132
+ * `cb-font-inter` (forge task 2915): the one face the belt self-hosts. The woff2 ships in
133
+ * `src/fonts/` and its `url()` is relative, so the app's Tailwind rebases it and the app's bundler
134
+ * copies it; the copy is held to the fontsource devDependency it came from, so a bump of that
135
+ * package without re-copying the file is red here rather than a stale face in every app.
136
+ */
137
+ describe('cb-font-inter', () => {
138
+ const faces = [...LOOKS_CSS.replace(/\/\*[\s\S]*?\*\//g, '').matchAll(/@font-face\s*\{([^}]*)\}/g)].map((m) => m[1] ?? '');
139
+
140
+ /** Every face's `src: url(…)`, as written. */
141
+ const urls = (css: string[]): string[] => css.flatMap((face) => [...face.matchAll(/url\('([^']+)'\)/g)].map((m) => m[1] as string));
142
+
143
+ /** Where a face's url lands on disk, read the way the app's bundler reads it: relative to looks.css. */
144
+ const onDisk = (url: string): string => join(ROOT, 'src', url);
145
+
146
+ test('is one latin, variable, swap face, shipped inside the package beside its licence', async () => {
147
+ expect(faces).toHaveLength(1);
148
+ const face = faces[0] as string;
149
+ expect(face).toMatch(/font-family:\s*'Inter Variable'/);
150
+ expect(face).toMatch(/font-display:\s*swap/);
151
+ expect(face).toMatch(/font-weight:\s*100 900/);
152
+ expect(urls(faces)).toEqual(['./fonts/inter-latin-wght-normal.woff2']);
153
+ // Bun.file, not node:fs: this package's test preload virtualizes node:fs.
154
+ expect(await Bun.file(onDisk('./fonts/inter-latin-wght-normal.woff2')).exists()).toBe(true);
155
+ expect(await Bun.file(onDisk('./fonts/inter-OFL.txt')).text()).toContain('SIL Open Font License');
156
+ const pkg = (await Bun.file(join(ROOT, 'package.json')).json()) as { files: string[]; dependencies?: Record<string, string> };
157
+ expect(pkg.files).toContain('src');
158
+ expect(pkg.dependencies?.['@fontsource-variable/inter']).toBeUndefined();
159
+ });
160
+
161
+ test('the shipped file is byte-for-byte the one the fontsource devDependency ships', async () => {
162
+ const shipped = new Uint8Array(await Bun.file(onDisk('./fonts/inter-latin-wght-normal.woff2')).arrayBuffer());
163
+ const source = Bun.resolveSync('@fontsource-variable/inter/files/inter-latin-wght-normal.woff2', ROOT);
164
+ const upstream = new Uint8Array(await Bun.file(source).arrayBuffer());
165
+ expect(shipped.length).toBeGreaterThan(10_000);
166
+ expect(Bun.hash(shipped)).toBe(Bun.hash(upstream));
167
+ });
168
+
169
+ test('🔴 the failure path: a face pointing at a file the package does not ship is caught', async () => {
170
+ const stale = faces.map((face) => face.replace('inter-latin-wght-normal', 'inter-latin-variable-normal'));
171
+ for (const url of urls(stale)) expect(await Bun.file(onDisk(url)).exists()).toBe(false);
172
+ });
173
+
174
+ test('re-binds the sans stack with Inter first, and the cv11 + ss01 features', () => {
175
+ const decls = declarationsOf(LOOKS_CSS, '.cb-font-inter');
176
+ expect(decls?.get('--font-sans')).toStartWith("'Inter Variable',");
177
+ expect(decls?.get('--default-font-family')).toBe('var(--font-sans)');
178
+ expect(decls?.get('--default-font-feature-settings')).toBe("'cv11', 'ss01'");
179
+ });
180
+
181
+ test('is a type scope, not a look: no surface token, and no look the picker offers', () => {
182
+ const decls = declarationsOf(LOOKS_CSS, '.cb-font-inter');
183
+ expect(decls?.has('--background')).toBe(false);
184
+ expect(LOOKS.some((look) => lookClass(look.id) === 'cb-font-inter')).toBe(false);
185
+ });
186
+ });
@@ -0,0 +1,177 @@
1
+ import type { KeyboardEvent, MouseEvent, ReactNode } from 'react';
2
+ import { ScrollArea } from '../components/ScrollArea.js';
3
+ import { cn } from '../lib/cn.js';
4
+
5
+ export interface CanvasToolbarProps {
6
+ /** The left half: what the canvas shows (a reach, a layer switch). Hidden while the bar is
7
+ * under 768px wide when `compactFilters` is given — five stops beside the tools would scroll out of a thumb's sight. */
8
+ filters?: ReactNode;
9
+ /** The same choice as a phone wants it, usually the OS picker (`NativeSelect`): shown only while
10
+ * the bar is under 768px wide, on row two beside the legend. */
11
+ compactFilters?: ReactNode;
12
+ /** A key or chip strip (houses, layers, tags): ONE line that scrolls inside itself, its ends
13
+ * fading where there is more. Beside the filters at every width; never under 10rem. */
14
+ legend?: ReactNode;
15
+ /** Names the legend strip for a screen reader. */
16
+ legendLabel?: string;
17
+ /** The right half: the find box and the views. Never shrinks — the legend does. */
18
+ tools?: ReactNode;
19
+ /**
20
+ * Where the keyboard goes after a control in the bar acts: a POINTER click on a button, tab or
21
+ * radio, or Escape from one. Default: the `[role="application"]` beside the bar (the canvas the
22
+ * bar sits above). `false` keeps focus on the control, the browser's default.
23
+ */
24
+ returnFocus?: (() => void) | false;
25
+ 'aria-label'?: string;
26
+ className?: string;
27
+ /** Rides on the bar's root, for an app's own selectors (`data-view`, …). */
28
+ [data: `data-${string}`]: string | undefined;
29
+ }
30
+
31
+ /**
32
+ * Whether a POINTER click landed on a control that should hand the keyboard back to the canvas
33
+ * once it has acted — a filter stop, a chip, a HUD button. A field keeps focus (it is typed into);
34
+ * a click the keyboard made (`detail` 0 — Enter or Space on a focused button) is a reader moving
35
+ * by Tab, whose focus stays where they put it.
36
+ *
37
+ * 🔴 The failure this exists for (family, 2026-10-06): the reach stops are a tab list, and a click
38
+ * leaves focus on the stop. Every arrow after that went to the stop — ← and → re-rooted and
39
+ * re-packed the tree — while the reader believed they were walking the family. Figma's rule: a
40
+ * toolbar click acts and the canvas keeps the keyboard.
41
+ */
42
+ export function controlReleasesFocus(event: Pick<MouseEvent, 'target' | 'currentTarget' | 'detail'>): boolean {
43
+ if (event.detail === 0) return false;
44
+ const target = event.target;
45
+ if (!(target instanceof Element) || target === event.currentTarget) return false;
46
+ if (target.closest("input, select, textarea, [contenteditable='true'], [role='combobox'], [role='listbox'], [role='menu']")) {
47
+ return false;
48
+ }
49
+ return target.closest("button, [role='tab'], [role='radio']") !== null;
50
+ }
51
+
52
+ /** A key on a field is the field's whole language — Escape there is not the way back to the canvas. */
53
+ export const isTextControl = (target: EventTarget | null): boolean =>
54
+ target instanceof Element && target.closest("input, select, textarea, [contenteditable='true'], [role='combobox']") !== null;
55
+
56
+ /** The canvas drawn under a bar — the `application` in the box the bar shares with it. */
57
+ const canvasBeside = (bar: Element): HTMLElement | null =>
58
+ bar.parentElement?.querySelector<HTMLElement>("[role='application']") ?? null;
59
+
60
+ /**
61
+ * The toolbar a canvas wears ABOVE itself, in flow — filters left, find and views right, a legend
62
+ * strip that scrolls inside itself, the OS picker in place of the filters below 768px.
63
+ *
64
+ * 🔴 ONE BAR, IN FLOW — never five overlays over the drawing. family's tree floated its reach
65
+ * buttons, house legend, find box and view switch over the canvas's top edge until 2026-10-06, and
66
+ * their collisions were fought width by width while cards were drawn under them at every width. A
67
+ * bar above the canvas, like Figma's and Linear's, cannot collide with anything by construction:
68
+ * the drawing starts where the bar ends. Built from family's `TreeBar` (forge task 2917) so
69
+ * patterns' and desk's boards never hand-roll a second one. Not `ListToolbar`: that one is a
70
+ * search box and a chip filter above a list; a canvas's bar carries its own reach, a legend of
71
+ * anything, and gives the keyboard back to the drawing.
72
+ *
73
+ * Narrow (the BAR under 768px — a container query, so a split pane counts): row one is the
74
+ * `tools`, right-aligned; row two is what the canvas shows — `compactFilters` (or `filters`) and the
75
+ * legend strip beside it. A phone's find button and four views do not leave a picker room beside
76
+ * them (family at 390: 300px of tools in a 366px row), and the picker and the legend are the same
77
+ * question — who is drawn — so they share a row. Wide: one line when the filters, a
78
+ * 10rem legend and the tools fit; when they do not (a 900px window), the tools ride down to row two
79
+ * as a group, right-aligned, rather than the filters being cut — the stops never shrink on a wide
80
+ * bar. Two rows at most at every width: the legend scrolls rather than wrapping.
81
+ *
82
+ * The bar hands the keyboard back to the canvas after a control acts (`returnFocus`), because a
83
+ * bar in flow sits OUTSIDE the canvas, so the canvas's own hand-back never sees its clicks.
84
+ */
85
+ export function CanvasToolbar({
86
+ filters,
87
+ compactFilters,
88
+ legend,
89
+ legendLabel,
90
+ tools,
91
+ returnFocus,
92
+ 'aria-label': ariaLabel,
93
+ className,
94
+ ...data
95
+ }: CanvasToolbarProps) {
96
+ const giveBack = (bar: Element) => {
97
+ if (returnFocus === false) return false;
98
+ if (returnFocus) {
99
+ returnFocus();
100
+ return true;
101
+ }
102
+ const canvas = canvasBeside(bar);
103
+ canvas?.focus({ preventScroll: true });
104
+ return canvas !== null;
105
+ };
106
+ const onClick = (event: MouseEvent<HTMLDivElement>) => {
107
+ if (controlReleasesFocus(event)) giveBack(event.currentTarget);
108
+ };
109
+ const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
110
+ if (event.key !== 'Escape' || event.target === event.currentTarget || isTextControl(event.target)) return;
111
+ if (giveBack(event.currentTarget)) event.preventDefault();
112
+ };
113
+ return (
114
+ // The handlers delegate — the bar's buttons, tabs and fields are the controls; these only hand
115
+ // the keyboard back to the canvas after one of them acts.
116
+ // biome-ignore lint/a11y/useSemanticElements: a <fieldset> is a FORM's grouping (its legend, its min-width quirk); this is a toolbar's layout box, and `group` only names it. No `toolbar` role: it promises arrow-key roving, which would take the arrows from the find box (ListToolbar's reasoning).
117
+ <div
118
+ {...data}
119
+ data-slot='canvas-toolbar'
120
+ role='group'
121
+ aria-label={ariaLabel}
122
+ onClick={onClick}
123
+ onKeyDown={onKeyDown}
124
+ // `@container`: the bar answers to ITS width, not the window's — a canvas in a split pane
125
+ // or a demo column is a phone-width bar on a desk-width screen.
126
+ className={cn('@container border-b border-border bg-card', className)}
127
+ >
128
+ <div className='flex flex-wrap items-center gap-x-3 gap-y-2 px-3 py-2'>
129
+ {filters ? (
130
+ <div
131
+ data-slot='canvas-toolbar-filters'
132
+ className={cn(
133
+ 'order-2 min-w-0 items-center gap-2 @3xl:order-none @3xl:shrink-0',
134
+ compactFilters ? 'hidden @3xl:flex' : 'flex',
135
+ )}
136
+ >
137
+ {filters}
138
+ </div>
139
+ ) : null}
140
+ {compactFilters ? (
141
+ <div
142
+ data-slot='canvas-toolbar-compact-filters'
143
+ className='order-2 flex min-w-26 max-w-48 flex-1 items-center @3xl:hidden [&>*]:w-full [&_select]:w-full'
144
+ >
145
+ {compactFilters}
146
+ </div>
147
+ ) : null}
148
+ {legend ? (
149
+ <ScrollArea
150
+ axis='x'
151
+ space='none'
152
+ safeArea={false}
153
+ fade
154
+ role='group'
155
+ aria-label={legendLabel}
156
+ data-slot='canvas-toolbar-legend'
157
+ className={cn(
158
+ 'order-3 flex min-w-40 basis-0 items-center gap-1 px-0.5 @3xl:order-none',
159
+ // The strip's own scrollbar is hidden; its fading ends are the "there is more" cue.
160
+ '[scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
161
+ )}
162
+ >
163
+ {legend}
164
+ </ScrollArea>
165
+ ) : null}
166
+ {tools ? (
167
+ <div
168
+ data-slot='canvas-toolbar-tools'
169
+ className='order-1 ml-auto flex flex-none basis-full items-center justify-end gap-2 @3xl:order-none @3xl:basis-auto'
170
+ >
171
+ {tools}
172
+ </div>
173
+ ) : null}
174
+ </div>
175
+ </div>
176
+ );
177
+ }
@@ -7,6 +7,8 @@ export type { CanvasNodeMenuItem, CanvasNodeMenuProps, CanvasNodeMenuSection } f
7
7
  export { CanvasNodeMenu } from './CanvasNodeMenu.js';
8
8
  export type { CanvasSearchFieldProps } from './CanvasSearchField.js';
9
9
  export { CanvasSearchField } from './CanvasSearchField.js';
10
+ export type { CanvasToolbarProps } from './CanvasToolbar.js';
11
+ export { CanvasToolbar, controlReleasesFocus, isTextControl } from './CanvasToolbar.js';
10
12
  export type { CanvasZoomHintProps, HudButtonProps, HudZoomControlsProps } from './HudControls.js';
11
13
  export { CanvasZoomHint, HudButton, HudZoomControls } from './HudControls.js';
12
14
  export type { CanvasWheelMode, CanvasWheelOptions } from './useCanvasWheel.js';
@@ -1,5 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
- import { cleanup, render } from '@testing-library/react';
2
+ import { act, cleanup, render } from '@testing-library/react';
3
3
  import { ActionBar, FooterSlot } from './ActionBar.js';
4
4
  import { Button } from './Button.js';
5
5
 
@@ -291,3 +291,51 @@ describe('FooterSlot — the footer is ordered by variant, not by the children (
291
291
  expect(labels(slot)).toEqual(['Back', 'Skip']);
292
292
  });
293
293
  });
294
+
295
+ describe('ActionBar — mobile, with the keyboard up', () => {
296
+ const realVV = Object.getOwnPropertyDescriptor(window, 'visualViewport');
297
+ beforeEach(() => stubMatchMedia(true));
298
+ afterEach(() => {
299
+ if (realVV) Object.defineProperty(window, 'visualViewport', realVV);
300
+ else Reflect.deleteProperty(window, 'visualViewport');
301
+ });
302
+
303
+ /** iOS's keyboard, stood in: the visible viewport loses `px` from the bottom. */
304
+ function standInKeyboard() {
305
+ const vv = new EventTarget() as EventTarget & { height: number; offsetTop: number };
306
+ vv.height = window.innerHeight;
307
+ vv.offsetTop = 0;
308
+ Object.defineProperty(window, 'visualViewport', { configurable: true, get: () => vv });
309
+ return {
310
+ raise(px: number) {
311
+ vv.height = window.innerHeight - px;
312
+ vv.dispatchEvent(new Event('resize'));
313
+ },
314
+ };
315
+ }
316
+
317
+ test('🔴 steps aside while the keyboard is up, and is back when it goes (LAY-15)', async () => {
318
+ const keyboard = standInKeyboard();
319
+ const { container } = render(
320
+ <ActionBar
321
+ destructive={<Button tooltip='Delete' variant='destructive'>Delete</Button>}
322
+ primary={<Button tooltip='Save' variant='primary'>Save</Button>}
323
+ secondary={<Button tooltip='Cancel' variant='secondary'>Cancel</Button>}
324
+ />,
325
+ );
326
+ const bar = () => container.querySelector('[data-cb-action-bar]') as HTMLElement;
327
+ expect(bar().hidden).toBe(false);
328
+ await act(() => keyboard.raise(336));
329
+ expect(bar().hidden).toBe(true);
330
+ expect(bar().getAttribute('data-cb-keyboard')).toBe('aside');
331
+ await act(() => keyboard.raise(0));
332
+ expect(bar().hidden).toBe(false);
333
+ });
334
+
335
+ test("the URL bar collapsing (under 120px) is not a keyboard", async () => {
336
+ const keyboard = standInKeyboard();
337
+ const { container } = render(<ActionBar primary={<Button tooltip='Save' variant='primary'>Save</Button>} />);
338
+ await act(() => keyboard.raise(60));
339
+ expect((container.querySelector('[data-cb-action-bar]') as HTMLElement).hidden).toBe(false);
340
+ });
341
+ });
@@ -1,4 +1,5 @@
1
1
  import { Children, Fragment, isValidElement, type ReactNode } from 'react';
2
+ import { useKeyboardOpen } from '../hooks/useKeyboardOpen.js';
2
3
  import { useMediaQuery } from '../hooks/useMediaQuery.js';
3
4
  import { cn } from '../lib/cn.js';
4
5
  import { devWarnOnce } from '../lib/devWarn.js';
@@ -84,6 +85,7 @@ export function ActionBar({
84
85
  className,
85
86
  }: ActionBarProps) {
86
87
  const isMobile = useMediaQuery(MOBILE_QUERY);
88
+ const keyboardOpen = useKeyboardOpen();
87
89
  if (hasPrimaryChild(children)) {
88
90
  devWarnOnce(
89
91
  'ActionBar:primary-child',
@@ -92,6 +94,12 @@ export function ActionBar({
92
94
  );
93
95
  }
94
96
 
97
+ if (isMobile && keyboardOpen) {
98
+ // 🔴 Aside while the keyboard is up. Lifted by `--keyboard-inset` the bar sat IN the strip the
99
+ // keyboard leaves, over the line being typed on (family's editor, three rows deep, Apple pass
100
+ // 2026-10-06 — LAY-15). Nobody saves mid-keystroke: the keyboard goes, the bar is back.
101
+ return <div data-cb-action-bar='mobile' data-cb-keyboard='aside' hidden />;
102
+ }
95
103
  if (isMobile) {
96
104
  return (
97
105
  <div
@@ -109,8 +117,8 @@ export function ActionBar({
109
117
  )}
110
118
  {/* `*`, not `button`: a Download is `<Button asChild><a download>`, and a link primary
111
119
  sat half-width under a phone's full-width Cancel (desk's Export, forge task 2750). */}
112
- {primary && <div className='[&>*]:w-full'>{primary}</div>}
113
- {secondary && <div className='[&>*]:w-full'>{secondary}</div>}
120
+ {primary && <div className='[&>*]:w-full [&>*>button]:w-full'>{primary}</div>}
121
+ {secondary && <div className='[&>*]:w-full [&>*>button]:w-full'>{secondary}</div>}
114
122
  </div>
115
123
  );
116
124
  }
@@ -2234,6 +2234,10 @@ export function DataTable<TRow extends Record<string, unknown>>({
2234
2234
  {selectionShown ? (
2235
2235
  <th
2236
2236
  scope='col'
2237
+ // The seam an app's own cell padding leaves alone: this cell is exactly the 44px
2238
+ // the checkbox's label needs on a touchscreen (music's 6px pushed the label under
2239
+ // the next cell and cut a finger's reach to 37px — the Apple pass, 2026-10-06).
2240
+ data-cb-selection-cell=''
2237
2241
  className={cn(
2238
2242
  'w-11 border-b border-border px-0 text-center',
2239
2243
  stickyHeader && 'sticky top-0 z-10 bg-card/95 backdrop-blur-sm',
@@ -2408,6 +2412,7 @@ export function DataTable<TRow extends Record<string, unknown>>({
2408
2412
  } as CSSProperties)
2409
2413
  : undefined
2410
2414
  }
2415
+ data-cb-selection-cell=''
2411
2416
  className={cn(
2412
2417
  'w-11 cursor-pointer px-0 text-center align-middle',
2413
2418
  stickyFirstColumn && 'sticky left-0 z-10 bg-[var(--cb-frozen-bg)]',
@@ -2473,7 +2478,7 @@ export function DataTable<TRow extends Record<string, unknown>>({
2473
2478
  <tfoot data-cb-table-footer=''>
2474
2479
  <tr>
2475
2480
  {footerHeaders.filter((h) => h.column.columnDef.meta?.leading === true).map((h) => footerCell(h, -1))}
2476
- {selectionShown ? <td className='w-11 border-t border-border px-0' /> : null}
2481
+ {selectionShown ? <td data-cb-selection-cell='' className='w-11 border-t border-border px-0' /> : null}
2477
2482
  {footerHeaders.filter((h) => h.column.columnDef.meta?.leading !== true).map(footerCell)}
2478
2483
  {hasActions ? <td className='border-t border-border px-2' /> : null}
2479
2484
  </tr>
@@ -0,0 +1,76 @@
1
+ import type { ReactNode } from 'react';
2
+ import { cn } from '../lib/cn.js';
3
+ import { CardTapTarget } from './CardTapTarget.js';
4
+
5
+ export interface EntityCardProps {
6
+ /** The face: an `Avatar`, a portrait, a cover thumbnail. Drawn at its own size, never squeezed. */
7
+ media: ReactNode;
8
+ /** The name — one line, ellipsed. */
9
+ name: ReactNode;
10
+ /** One line under the name (dates, a role, a count) — muted, tabular figures, ellipsed. */
11
+ line?: ReactNode;
12
+ /** Fired on tap/click and on Enter/Space — the whole card is the target. */
13
+ onClick: () => void;
14
+ /** What a tap does, for a screen reader ("Open Ada Lovelace") — the name alone says what, not why. */
15
+ 'aria-label'?: string;
16
+ /**
17
+ * Skip `onClick` when the click only completes a text selection inside the card. Default
18
+ * `false`: a face, a name and one line hold nothing worth selecting, and a press that the
19
+ * selection check swallows reads as a dead card on a phone.
20
+ */
21
+ selectionSafe?: boolean;
22
+ className?: string;
23
+ /** Rides on the card's root, for an app's own selectors (`data-ended`, `data-self`, …). */
24
+ [data: `data-${string}`]: string | undefined;
25
+ }
26
+
27
+ /**
28
+ * A person, a title, a game — a face, a name, one line, the whole card one tap target that dips
29
+ * on press. Built from family's close-family `KinCard` (forge task 2918), on `CardTapTarget`, so
30
+ * the tap floor (`TAP_TARGET_HEIGHT`), the press dip (`PRESS_SURFACE`) and the iPhone-safe
31
+ * `div[role=button]` come from the one place they are kept.
32
+ *
33
+ * The card is a row: the face left, the text beside it. It fills its grid cell, and its text
34
+ * truncates rather than wrapping, so every card in a grid row is the same height whatever the
35
+ * name. `relative isolate` keeps anything the face positions from painting over the next card and
36
+ * eating its taps (`CardTapTarget`'s other half).
37
+ */
38
+ export function EntityCard({
39
+ media,
40
+ name,
41
+ line,
42
+ onClick,
43
+ 'aria-label': ariaLabel,
44
+ selectionSafe = false,
45
+ className,
46
+ ...data
47
+ }: EntityCardProps) {
48
+ return (
49
+ <CardTapTarget
50
+ {...data}
51
+ onClick={onClick}
52
+ aria-label={ariaLabel}
53
+ selectionSafe={selectionSafe}
54
+ className={cn(
55
+ 'relative isolate flex min-h-14 min-w-0 items-center gap-3 rounded-lg border border-border bg-card px-3 py-2',
56
+ 'hover:border-border-strong hover:bg-card-raised',
57
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
58
+ className,
59
+ )}
60
+ >
61
+ <span data-slot='entity-card-media' className='flex flex-none items-center'>
62
+ {media}
63
+ </span>
64
+ <span className='flex min-w-0 flex-col gap-0.5'>
65
+ <span data-slot='entity-card-name' className='truncate text-sm leading-5 font-medium text-foreground'>
66
+ {name}
67
+ </span>
68
+ {line ? (
69
+ <span data-slot='entity-card-line' className='truncate text-xs leading-4 text-muted-foreground tabular-nums'>
70
+ {line}
71
+ </span>
72
+ ) : null}
73
+ </span>
74
+ </CardTapTarget>
75
+ );
76
+ }
@@ -23,8 +23,10 @@ export interface GridProps extends HTMLAttributes<HTMLDivElement> {
23
23
  // grid is pulled out by half a gutter on both sides, so content still aligns with the
24
24
  // edges, and the wrapper clips what hangs over — which is exactly the first column of
25
25
  // every row's border, wherever the row wraps. The clip margin keeps a focus ring at the
26
- // edge visible (it is far less than the half gutter the border sits out at).
27
- const DIVIDED_WRAPPER = 'overflow-x-clip [overflow-clip-margin:calc(var(--spacing)*1)]';
26
+ // edge visible (it is far less than the half gutter the border sits out at). Under `@supports`:
27
+ // Safari has no `overflow-clip-margin` (27.0.1, read by the shipped-Safari check, FND-18) and
28
+ // the ring is clipped there — an enhancement, said so, rather than a declaration it drops.
29
+ const DIVIDED_WRAPPER = 'overflow-x-clip supports-[overflow-clip-margin:1px]:[overflow-clip-margin:calc(var(--spacing)*1)]';
28
30
  // The extra 1px on the left is the clipped rule's own width: without it the first
29
31
  // column's content would sit a pixel in from the edge every other block lines up on.
30
32
  const DIVIDED_GRID =
@@ -106,7 +106,7 @@ const REGION_HEIGHT: Record<ScrollAreaHeight, string> = {
106
106
  *
107
107
  * Rules:
108
108
  * - NEVER write `overflow-y-auto` directly. Use `ScrollArea`.
109
- * - NEVER use `h-[calc(100dvh-…)]` to compensate for layout — `dvh` does NOT
109
+ * - NEVER use a `100dvh` calc height to compensate for layout — `dvh` does NOT
110
110
  * shrink for the keyboard. Height comes from the shell's flex column
111
111
  * (`min-h-0` + `flex-1`), not the component.
112
112
  * - Modals/drawers/panels use this same `ScrollArea` for their scrollable body.
@@ -65,3 +65,17 @@ describe('SecretInput', () => {
65
65
  expect(copyButton(container)).toBeDefined();
66
66
  });
67
67
  });
68
+
69
+ describe('SecretInput — the frame is the field to a finger (LAY-13, the Apple pass)', () => {
70
+ test('a tap on the frame itself focuses the input; one on a button inside does not steal focus', () => {
71
+ const { container } = render(<SecretInput value='hunter2' onChange={() => {}} label='Password' />);
72
+ const frame = container.querySelector('[data-cb-field-control]') as HTMLElement;
73
+ const input = container.querySelector('input') as HTMLInputElement;
74
+ expect(frame).toBeTruthy();
75
+ fireEvent.pointerDown(frame);
76
+ expect(document.activeElement).toBe(input);
77
+ input.blur();
78
+ fireEvent.pointerDown(copyButton(container) as HTMLElement);
79
+ expect(document.activeElement).not.toBe(input);
80
+ });
81
+ });