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.
- package/dist/looks.css +41 -0
- package/dist/react/components/ActionBar.d.ts.map +1 -1
- package/dist/react/components/ActionBar.js +9 -1
- package/dist/react/components/ActionBar.js.map +1 -1
- package/dist/react/components/DataTable/DataTable.d.ts.map +1 -1
- package/dist/react/components/DataTable/DataTable.js +3 -3
- package/dist/react/components/DataTable/DataTable.js.map +1 -1
- package/dist/react/components/Grid.d.ts.map +1 -1
- package/dist/react/components/Grid.js +4 -2
- package/dist/react/components/Grid.js.map +1 -1
- package/dist/react/components/ScrollArea.d.ts +1 -1
- package/dist/react/components/ScrollArea.js +1 -1
- package/dist/react/components/SecretInput.d.ts.map +1 -1
- package/dist/react/components/SecretInput.js +8 -2
- package/dist/react/components/SecretInput.js.map +1 -1
- package/dist/react/components/SegmentedControl.d.ts.map +1 -1
- package/dist/react/components/SegmentedControl.js +5 -1
- package/dist/react/components/SegmentedControl.js.map +1 -1
- package/dist/react/hooks/useKeyboardOpen.d.ts +13 -0
- package/dist/react/hooks/useKeyboardOpen.d.ts.map +1 -0
- package/dist/react/hooks/useKeyboardOpen.js +30 -0
- package/dist/react/hooks/useKeyboardOpen.js.map +1 -0
- package/dist/react/index.d.ts +2 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +2 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/look/AppearanceMenu.d.ts +29 -0
- package/dist/react/look/AppearanceMenu.d.ts.map +1 -0
- package/dist/react/look/AppearanceMenu.js +39 -0
- package/dist/react/look/AppearanceMenu.js.map +1 -0
- package/dist/react/look/AppearancePanel.d.ts +18 -0
- package/dist/react/look/AppearancePanel.d.ts.map +1 -0
- package/dist/react/look/AppearancePanel.js +20 -0
- package/dist/react/look/AppearancePanel.js.map +1 -0
- package/dist/react/look/LookPicker.d.ts +6 -1
- package/dist/react/look/LookPicker.d.ts.map +1 -1
- package/dist/react/look/LookPicker.js +2 -2
- package/dist/react/look/LookPicker.js.map +1 -1
- package/dist/react/media/denseGallery.css +2 -0
- package/dist/styles-areas/core.css +40 -39
- package/dist/styles-utilities.css +10 -8
- package/dist/testing/appleDevices.d.ts +68 -0
- package/dist/testing/appleDevices.d.ts.map +1 -0
- package/dist/testing/appleDevices.js +65 -0
- package/dist/testing/appleDevices.js.map +1 -0
- package/dist/testing/appleWalk.d.ts +72 -0
- package/dist/testing/appleWalk.d.ts.map +1 -0
- package/dist/testing/appleWalk.js +209 -0
- package/dist/testing/appleWalk.js.map +1 -0
- package/dist/testing/designAudit.d.ts +1 -1
- package/dist/testing/designAudit.d.ts.map +1 -1
- package/dist/testing/designAudit.js +12 -0
- package/dist/testing/designAudit.js.map +1 -1
- package/dist/testing/designBaseline.d.ts +14 -0
- package/dist/testing/designBaseline.d.ts.map +1 -1
- package/dist/testing/designBaseline.js +26 -28
- package/dist/testing/designBaseline.js.map +1 -1
- package/dist/testing/index.d.ts +6 -0
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +9 -0
- package/dist/testing/index.js.map +1 -1
- package/dist/testing/lookPrePaint.d.ts +19 -0
- package/dist/testing/lookPrePaint.d.ts.map +1 -0
- package/dist/testing/lookPrePaint.js +56 -0
- package/dist/testing/lookPrePaint.js.map +1 -0
- package/dist/testing/phoneRules.d.ts +98 -0
- package/dist/testing/phoneRules.d.ts.map +1 -0
- package/dist/testing/phoneRules.js +282 -0
- package/dist/testing/phoneRules.js.map +1 -0
- package/dist/testing/safariDriver.d.ts +103 -0
- package/dist/testing/safariDriver.d.ts.map +1 -0
- package/dist/testing/safariDriver.js +288 -0
- package/dist/testing/safariDriver.js.map +1 -0
- package/dist/testing/safariRules.d.ts +57 -0
- package/dist/testing/safariRules.d.ts.map +1 -0
- package/dist/testing/safariRules.js +191 -0
- package/dist/testing/safariRules.js.map +1 -0
- package/package.json +2 -1
- package/src/fonts/inter-OFL.txt +93 -0
- package/src/fonts/inter-latin-wght-normal.woff2 +0 -0
- package/src/looks.css +41 -0
- package/src/looks.spec.ts +59 -1
- package/src/react/components/ActionBar.spec.tsx +49 -1
- package/src/react/components/ActionBar.tsx +10 -2
- package/src/react/components/DataTable/DataTable.tsx +6 -1
- package/src/react/components/Grid.tsx +4 -2
- package/src/react/components/ScrollArea.tsx +1 -1
- package/src/react/components/SecretInput.spec.tsx +14 -0
- package/src/react/components/SecretInput.tsx +13 -1
- package/src/react/components/SegmentedControl.spec.tsx +1 -1
- package/src/react/components/SegmentedControl.tsx +5 -1
- package/src/react/components/tooltipWarm.spec.tsx +15 -0
- package/src/react/hooks/useKeyboardOpen.ts +29 -0
- package/src/react/index.ts +2 -0
- package/src/react/look/AppearanceMenu.spec.tsx +90 -0
- package/src/react/look/AppearanceMenu.tsx +64 -0
- package/src/react/look/AppearancePanel.tsx +54 -0
- package/src/react/look/LookPicker.tsx +14 -2
- package/src/react/media/denseGallery.css +2 -0
- package/src/styles-areas/core.css +40 -39
- package/src/styles-utilities.css +10 -8
- package/src/testing/appleDevices.spec.ts +36 -0
- package/src/testing/appleDevices.ts +97 -0
- package/src/testing/appleWalk.ts +232 -0
- package/src/testing/designAudit.ts +12 -0
- package/src/testing/designBaseline.spec.ts +9 -0
- package/src/testing/designBaseline.ts +26 -34
- package/src/testing/index.ts +18 -0
- package/src/testing/lookPrePaint.spec.ts +43 -0
- package/src/testing/lookPrePaint.ts +60 -0
- package/src/testing/phoneRules.spec.ts +75 -0
- package/src/testing/phoneRules.ts +332 -0
- package/src/testing/safariDriver.ts +331 -0
- package/src/testing/safariRules.spec.ts +92 -0
- 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.
|
|
Binary file
|
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
|
-
|
|
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
|
-
|
|
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 `
|
|
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-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/react/index.ts
CHANGED
|
@@ -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
|
+
});
|