cursedbelt 5.68.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 (115) hide show
  1. package/dist/looks.css +41 -0
  2. package/dist/react/components/ActionBar.d.ts.map +1 -1
  3. package/dist/react/components/ActionBar.js +9 -1
  4. package/dist/react/components/ActionBar.js.map +1 -1
  5. package/dist/react/components/DataTable/DataTable.d.ts.map +1 -1
  6. package/dist/react/components/DataTable/DataTable.js +3 -3
  7. package/dist/react/components/DataTable/DataTable.js.map +1 -1
  8. package/dist/react/components/Grid.d.ts.map +1 -1
  9. package/dist/react/components/Grid.js +4 -2
  10. package/dist/react/components/Grid.js.map +1 -1
  11. package/dist/react/components/ScrollArea.d.ts +1 -1
  12. package/dist/react/components/ScrollArea.js +1 -1
  13. package/dist/react/components/SecretInput.d.ts.map +1 -1
  14. package/dist/react/components/SecretInput.js +8 -2
  15. package/dist/react/components/SecretInput.js.map +1 -1
  16. package/dist/react/components/SegmentedControl.d.ts.map +1 -1
  17. package/dist/react/components/SegmentedControl.js +5 -1
  18. package/dist/react/components/SegmentedControl.js.map +1 -1
  19. package/dist/react/hooks/useKeyboardOpen.d.ts +13 -0
  20. package/dist/react/hooks/useKeyboardOpen.d.ts.map +1 -0
  21. package/dist/react/hooks/useKeyboardOpen.js +30 -0
  22. package/dist/react/hooks/useKeyboardOpen.js.map +1 -0
  23. package/dist/react/index.d.ts +2 -0
  24. package/dist/react/index.d.ts.map +1 -1
  25. package/dist/react/index.js +2 -0
  26. package/dist/react/index.js.map +1 -1
  27. package/dist/react/look/AppearanceMenu.d.ts +29 -0
  28. package/dist/react/look/AppearanceMenu.d.ts.map +1 -0
  29. package/dist/react/look/AppearanceMenu.js +39 -0
  30. package/dist/react/look/AppearanceMenu.js.map +1 -0
  31. package/dist/react/look/AppearancePanel.d.ts +18 -0
  32. package/dist/react/look/AppearancePanel.d.ts.map +1 -0
  33. package/dist/react/look/AppearancePanel.js +20 -0
  34. package/dist/react/look/AppearancePanel.js.map +1 -0
  35. package/dist/react/look/LookPicker.d.ts +6 -1
  36. package/dist/react/look/LookPicker.d.ts.map +1 -1
  37. package/dist/react/look/LookPicker.js +2 -2
  38. package/dist/react/look/LookPicker.js.map +1 -1
  39. package/dist/react/media/denseGallery.css +2 -0
  40. package/dist/styles-areas/core.css +40 -39
  41. package/dist/styles-utilities.css +10 -8
  42. package/dist/testing/appleDevices.d.ts +68 -0
  43. package/dist/testing/appleDevices.d.ts.map +1 -0
  44. package/dist/testing/appleDevices.js +65 -0
  45. package/dist/testing/appleDevices.js.map +1 -0
  46. package/dist/testing/appleWalk.d.ts +72 -0
  47. package/dist/testing/appleWalk.d.ts.map +1 -0
  48. package/dist/testing/appleWalk.js +209 -0
  49. package/dist/testing/appleWalk.js.map +1 -0
  50. package/dist/testing/designAudit.d.ts +1 -1
  51. package/dist/testing/designAudit.d.ts.map +1 -1
  52. package/dist/testing/designAudit.js +12 -0
  53. package/dist/testing/designAudit.js.map +1 -1
  54. package/dist/testing/designBaseline.d.ts +14 -0
  55. package/dist/testing/designBaseline.d.ts.map +1 -1
  56. package/dist/testing/designBaseline.js +26 -28
  57. package/dist/testing/designBaseline.js.map +1 -1
  58. package/dist/testing/index.d.ts +6 -0
  59. package/dist/testing/index.d.ts.map +1 -1
  60. package/dist/testing/index.js +9 -0
  61. package/dist/testing/index.js.map +1 -1
  62. package/dist/testing/lookPrePaint.d.ts +19 -0
  63. package/dist/testing/lookPrePaint.d.ts.map +1 -0
  64. package/dist/testing/lookPrePaint.js +56 -0
  65. package/dist/testing/lookPrePaint.js.map +1 -0
  66. package/dist/testing/phoneRules.d.ts +98 -0
  67. package/dist/testing/phoneRules.d.ts.map +1 -0
  68. package/dist/testing/phoneRules.js +282 -0
  69. package/dist/testing/phoneRules.js.map +1 -0
  70. package/dist/testing/safariDriver.d.ts +103 -0
  71. package/dist/testing/safariDriver.d.ts.map +1 -0
  72. package/dist/testing/safariDriver.js +288 -0
  73. package/dist/testing/safariDriver.js.map +1 -0
  74. package/dist/testing/safariRules.d.ts +57 -0
  75. package/dist/testing/safariRules.d.ts.map +1 -0
  76. package/dist/testing/safariRules.js +191 -0
  77. package/dist/testing/safariRules.js.map +1 -0
  78. package/package.json +2 -1
  79. package/src/fonts/inter-OFL.txt +93 -0
  80. package/src/fonts/inter-latin-wght-normal.woff2 +0 -0
  81. package/src/looks.css +41 -0
  82. package/src/looks.spec.ts +59 -1
  83. package/src/react/components/ActionBar.spec.tsx +49 -1
  84. package/src/react/components/ActionBar.tsx +10 -2
  85. package/src/react/components/DataTable/DataTable.tsx +6 -1
  86. package/src/react/components/Grid.tsx +4 -2
  87. package/src/react/components/ScrollArea.tsx +1 -1
  88. package/src/react/components/SecretInput.spec.tsx +14 -0
  89. package/src/react/components/SecretInput.tsx +13 -1
  90. package/src/react/components/SegmentedControl.spec.tsx +1 -1
  91. package/src/react/components/SegmentedControl.tsx +5 -1
  92. package/src/react/components/tooltipWarm.spec.tsx +15 -0
  93. package/src/react/hooks/useKeyboardOpen.ts +29 -0
  94. package/src/react/index.ts +2 -0
  95. package/src/react/look/AppearanceMenu.spec.tsx +90 -0
  96. package/src/react/look/AppearanceMenu.tsx +64 -0
  97. package/src/react/look/AppearancePanel.tsx +54 -0
  98. package/src/react/look/LookPicker.tsx +14 -2
  99. package/src/react/media/denseGallery.css +2 -0
  100. package/src/styles-areas/core.css +40 -39
  101. package/src/styles-utilities.css +10 -8
  102. package/src/testing/appleDevices.spec.ts +36 -0
  103. package/src/testing/appleDevices.ts +97 -0
  104. package/src/testing/appleWalk.ts +232 -0
  105. package/src/testing/designAudit.ts +12 -0
  106. package/src/testing/designBaseline.spec.ts +9 -0
  107. package/src/testing/designBaseline.ts +26 -34
  108. package/src/testing/index.ts +18 -0
  109. package/src/testing/lookPrePaint.spec.ts +43 -0
  110. package/src/testing/lookPrePaint.ts +60 -0
  111. package/src/testing/phoneRules.spec.ts +75 -0
  112. package/src/testing/phoneRules.ts +332 -0
  113. package/src/testing/safariDriver.ts +331 -0
  114. package/src/testing/safariRules.spec.ts +92 -0
  115. 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
+ });
@@ -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>
@@ -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
+ });
@@ -1,4 +1,4 @@
1
- import { type CSSProperties, type FocusEvent, type KeyboardEvent, type ReactNode, useState } from 'react';
1
+ import { type CSSProperties, type FocusEvent, type KeyboardEvent, type ReactNode, useRef, useState } from 'react';
2
2
  import { useCopyToClipboard } from '../hooks/useCopyToClipboard.js';
3
3
  import { CONTROL_HEIGHT_CLASS, type ControlSize, declareFieldWidth } from '../lib/field.js';
4
4
  import { resolveClass, resolveStyle, type StyleableProps } from '../styling.js';
@@ -166,6 +166,7 @@ export const SecretInput = ({
166
166
  unstyled,
167
167
  size = 'md',
168
168
  }: SecretInputProps) => {
169
+ const inputRef = useRef<HTMLInputElement>(null);
169
170
  const [internalShown, setInternalShown] = useState(false);
170
171
  const { copied: internalCopied, copy } = useCopyToClipboard();
171
172
  // `onCopy` bypasses `useCopyToClipboard`, so its "copied ✓" tick has to be kept
@@ -199,8 +200,19 @@ export const SecretInput = ({
199
200
  unstyled,
200
201
  )}
201
202
  style={resolveStyle({}, styles?.root ?? style, unstyled)}
203
+ // The frame IS the field to a finger: its 44px on a coarse pointer (`TAP_TARGET_HEIGHT`) is
204
+ // the input's reach only if a tap on the frame's own border or padding lands in the input —
205
+ // the Apple pass read vault's master-password field as 42px (its frame less the border) on
206
+ // every touchscreen, 2026-10-06 (LAY-13). `data-cb-field-control` says so to the audit.
207
+ data-cb-field-control=''
208
+ onPointerDown={(event) => {
209
+ if (event.target !== event.currentTarget) return;
210
+ event.preventDefault();
211
+ inputRef.current?.focus();
212
+ }}
202
213
  >
203
214
  <input
215
+ ref={inputRef}
204
216
  id={id}
205
217
  name={name}
206
218
  // guardrails-ignore no-raw-inputs: this IS the SecretInput Field-family
@@ -77,7 +77,7 @@ describe('equal segments', () => {
77
77
  const { container, rerender } = render(<SegmentedControl options={powers} value='z²' onChange={() => {}} />);
78
78
  const list = () => container.querySelector('[role="tablist"]');
79
79
  expect(list()?.hasAttribute('data-cb-segments-equal')).toBe(true);
80
- expect(list()?.className).toContain('auto-cols-fr');
80
+ expect(list()?.className).toContain('auto-cols-[minmax(min-content,1fr)]');
81
81
  rerender(<SegmentedControl options={powers} value='z²' onChange={() => {}} equal={false} />);
82
82
  expect(list()?.hasAttribute('data-cb-segments-equal')).toBe(false);
83
83
  expect(list()?.className).toContain('inline-flex');
@@ -90,7 +90,11 @@ export function SegmentedControl<T extends string>({
90
90
  'max-w-full items-center gap-0.5 overflow-x-auto rounded-md bg-muted p-1',
91
91
  // Equal: one grid track per segment, every track 1fr of the widest — so z² and z⁸
92
92
  // are the same size and the strip does not bounce (FRM-7).
93
- uniform ? 'inline-grid auto-cols-fr grid-flow-col' : 'inline-flex',
93
+ // `minmax(min-content, 1fr)`, not `minmax(0, 1fr)`: on a touchscreen each segment is at
94
+ // least 44px (`TAP_TARGET`), and a track allowed to shrink to 0 let seven powers overlap
95
+ // each other on an iPad (patterns, the Apple pass, 2026-10-06 — LAY-9). Now the strip
96
+ // scrolls sideways, as its `overflow-x-auto` always meant it to.
97
+ uniform ? 'inline-grid auto-cols-[minmax(min-content,1fr)] grid-flow-col' : 'inline-flex',
94
98
  className,
95
99
  )}
96
100
  >
@@ -0,0 +1,15 @@
1
+ import { expect, test } from 'bun:test';
2
+ import { render } from '@testing-library/react';
3
+ import { IconButton } from './IconButton.js';
4
+
5
+ // The test preload (happydom.ts) warms `TooltipImpl`, so the FIRST tooltip any spec mounts is
6
+ // already the real one and its trigger node is never swapped under a held reference. If the
7
+ // warm-up goes, the very first commit below renders the stand-in, which has no Radix state.
8
+ test('a tooltip is the real implementation from its first commit under bun test', () => {
9
+ const { getByRole } = render(
10
+ <IconButton label='Files'>
11
+ <svg />
12
+ </IconButton>,
13
+ );
14
+ expect(getByRole('button', { name: 'Files' }).getAttribute('data-state')).not.toBeNull();
15
+ });
@@ -0,0 +1,29 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ /**
4
+ * Whether the on-screen keyboard is up: the layout viewport is taller than the visible one by
5
+ * more than `threshold` px (`useKeyboardInset`'s arithmetic, as a boolean). iOS's URL bar
6
+ * collapsing moves `innerHeight` with it, so it never reads as a keyboard; a real keyboard is
7
+ * 260–420px. `false` where `visualViewport` is absent, and on the first render.
8
+ *
9
+ * What it is for (forge task apple-device-testing, 2026-10-06): a bar that rides above the
10
+ * keyboard sits IN the strip the keyboard leaves, over the line being typed on — the Apple pass
11
+ * read family's three-row editor footer over the first field (LAY-15). While a person types,
12
+ * `ActionBar` steps aside and comes back when the keyboard goes.
13
+ */
14
+ export function useKeyboardOpen(threshold = 120): boolean {
15
+ const [open, setOpen] = useState(false);
16
+ useEffect(() => {
17
+ const vv = typeof window !== 'undefined' ? window.visualViewport : null;
18
+ if (!vv) return;
19
+ const update = () => setOpen(window.innerHeight - vv.height - vv.offsetTop > threshold);
20
+ update();
21
+ vv.addEventListener('resize', update);
22
+ vv.addEventListener('scroll', update);
23
+ return () => {
24
+ vv.removeEventListener('resize', update);
25
+ vv.removeEventListener('scroll', update);
26
+ };
27
+ }, [threshold]);
28
+ return open;
29
+ }
@@ -39,6 +39,7 @@ export {
39
39
  } from 'cursedbelt-core/color-scheme';
40
40
  export { ThemeToggle, type ThemeToggleProps } from './colorScheme/ThemeToggle.js';
41
41
  export { type UseColorScheme, useColorScheme } from './colorScheme/useColorScheme.js';
42
+ export { AppearanceMenu, type AppearanceMenuProps } from './look/AppearanceMenu.js';
42
43
  export { LookPicker, type LookPickerProps } from './look/LookPicker.js';
43
44
  export {
44
45
  applyLookToDocument,
@@ -794,6 +795,7 @@ export { useEscapeKey } from './hooks/useEscapeKey.js';
794
795
  export { type UseOutsidePressOptions, useOutsidePress } from './hooks/useOutsidePress.js';
795
796
  export { useHasHover } from './hooks/useHasHover.js';
796
797
  export { useKeyboardInset } from './hooks/useKeyboardInset.js';
798
+ export { useKeyboardOpen } from './hooks/useKeyboardOpen.js';
797
799
  export { useKeyDown } from './hooks/useKeyDown.js';
798
800
  export { type UseKeySequenceOptions, useKeySequence } from './hooks/useKeySequence.js';
799
801
  export {
@@ -0,0 +1,90 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+ import { cleanup, fireEvent, render } from '@testing-library/react';
3
+ import { readFileSync } from 'node:fs';
4
+ import { colorSchemeStore, initColorScheme } from 'cursedbelt-core/color-scheme';
5
+ import { AppearanceMenu } from './AppearanceMenu.js';
6
+ import { initLook, LOOK_STORAGE_KEY, LOOKS, lookClass, resetLook } from './lookStore.js';
7
+
8
+ afterEach(() => {
9
+ cleanup();
10
+ resetLook();
11
+ localStorage.removeItem(LOOK_STORAGE_KEY);
12
+ for (const look of LOOKS) document.documentElement.classList.remove(lookClass(look.id));
13
+ colorSchemeStore.setMode('system');
14
+ });
15
+
16
+ describe('AppearanceMenu', () => {
17
+ test('is one named button until pressed, then the look cards and the scheme in one panel', async () => {
18
+ initLook({ fallback: 'obsidian' });
19
+ initColorScheme();
20
+ const { getByRole, queryAllByRole, getAllByRole, findByRole } = render(<AppearanceMenu />);
21
+ expect(queryAllByRole('radio')).toHaveLength(0);
22
+
23
+ fireEvent.click(getByRole('button', { name: 'Appearance' }));
24
+ // The panel is a lazy chunk: the first press fetches it and it opens as it lands.
25
+ expect(await findByRole('dialog', { name: 'Appearance' })).not.toBeNull();
26
+ // The looks are radios (LookPicker); the scheme is SegmentedControl's tabs beside them.
27
+ const radios = getAllByRole('radio').map((r) => r.textContent ?? '');
28
+ for (const look of LOOKS) expect(radios.some((t) => t.includes(look.label))).toBe(true);
29
+ expect(getByRole('radio', { name: /Obsidian/ }).getAttribute('aria-checked')).toBe('true');
30
+ expect(getByRole('tablist', { name: 'Light or dark' })).not.toBeNull();
31
+ });
32
+
33
+ test('a look picked in the panel is worn and kept; a scheme picked there is applied', async () => {
34
+ initLook({ fallback: 'obsidian' });
35
+ initColorScheme();
36
+ const { getByRole, findByRole } = render(<AppearanceMenu />);
37
+ fireEvent.click(getByRole('button', { name: 'Appearance' }));
38
+
39
+ fireEvent.click(await findByRole('radio', { name: /Dracula/ }));
40
+ expect(document.documentElement.classList.contains('cb-dracula')).toBe(true);
41
+ expect(document.documentElement.classList.contains('cb-obsidian')).toBe(false);
42
+ expect(localStorage.getItem(LOOK_STORAGE_KEY)).toBe('dracula');
43
+
44
+ // Radix Tabs selects on the press, not the click.
45
+ fireEvent.mouseDown(getByRole('tab', { name: 'Dark' }));
46
+ expect(colorSchemeStore.getMode()).toBe('dark');
47
+ expect(document.documentElement.classList.contains('dark')).toBe(true);
48
+ });
49
+
50
+ test('`looks` narrows what the panel offers', async () => {
51
+ const { getByRole, queryByRole, findByRole } = render(<AppearanceMenu looks={['obsidian', 'aurora']} />);
52
+ fireEvent.click(getByRole('button', { name: 'Appearance' }));
53
+ expect(await findByRole('radio', { name: /Aurora/ })).not.toBeNull();
54
+ expect(queryByRole('radio', { name: /Nord/ })).toBeNull();
55
+ });
56
+
57
+ test('the panel closes on a second press and reopens on a third', async () => {
58
+ const { getByRole, findByRole, queryByRole } = render(<AppearanceMenu />);
59
+ fireEvent.click(getByRole('button', { name: 'Appearance' }));
60
+ await findByRole('dialog', { name: 'Appearance' });
61
+ fireEvent.click(getByRole('button', { name: 'Appearance' }));
62
+ expect(queryByRole('dialog')).toBeNull();
63
+ fireEvent.click(getByRole('button', { name: 'Appearance' }));
64
+ expect(await findByRole('dialog', { name: 'Appearance' })).not.toBeNull();
65
+ });
66
+ });
67
+
68
+ // 🔴 The panel's Popover, RadioGroup and Tabs cost every app ~21 kB gzip of ENTRY chunk when this
69
+ // file imported them (arch, 2026-10-07: 157,950 B with the menu vs 136,443 B with ThemeToggle).
70
+ // They may reach the button only through the dynamic import of ./AppearancePanel.
71
+ const STATIC_IMPORT = /^import\s[^;]*?from\s+'([^']+)'/gms;
72
+ function staticImportsOf(source: string): string[] {
73
+ return [...source.matchAll(STATIC_IMPORT)].filter((m) => !m[0].startsWith('import type')).map((m) => m[1] ?? '');
74
+ }
75
+ const PANEL_ONLY = ['./AppearancePanel.js', '../components/Popover.js', '../components/SegmentedControl.js', './LookPicker.js'];
76
+
77
+ describe('AppearanceMenu stays out of the entry chunk', () => {
78
+ test('its module imports none of the panel statically', () => {
79
+ const source = readFileSync(new URL('./AppearanceMenu.tsx', import.meta.url), 'utf8');
80
+ const imported = staticImportsOf(source);
81
+ expect(imported).toContain('../components/IconButton.js');
82
+ for (const heavy of PANEL_ONLY) expect(imported).not.toContain(heavy);
83
+ expect(source).toContain("import('./AppearancePanel.js')");
84
+ });
85
+
86
+ test('🔴 the failure path: a static import of the panel is caught', () => {
87
+ const regressed = "import { Popover } from '../components/Popover.js';\nimport { IconButton } from '../components/IconButton.js';";
88
+ expect(PANEL_ONLY.some((heavy) => staticImportsOf(regressed).includes(heavy))).toBe(true);
89
+ });
90
+ });