@avi2dg/checks 0.58.0 → 0.59.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/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  Every release of `@avi2dg/checks`, newest first, written by the release from its conventional commits.
4
4
 
5
+ ## 0.59.0
6
+
7
+ Released 2026-10-31.
8
+
9
+ ### Features
10
+
11
+ - **browser:** add shared screen size presets and starter declaration [#20](https://github.com/avi2d/checks/pull/20)
12
+
13
+ ## 0.58.1
14
+
15
+ Released 2026-10-29.
16
+
17
+ ### Fixes
18
+
19
+ - **browser:** judge where the pointer lands instead of timing the hover [#19](https://github.com/avi2d/checks/pull/19)
20
+
5
21
  ## 0.58.0
6
22
 
7
23
  Released 2026-10-29.
package/docs/design.md CHANGED
@@ -84,9 +84,10 @@ Nothing would then say which gate a helper serves.
84
84
  A mutation runner's default scope covers `src/`, so the kit's own Stryker run mutates its source with no `mutate` list.
85
85
  The testing directory is named `testing` rather than `tests`, because a `src/tests/` beside the root `tests/` would read as a second suite.
86
86
 
87
- Five `exports` keys name a path the file does not sit at, because consumers resolve them by that name.
87
+ Six `exports` keys name a path the file does not sit at, because consumers resolve them by that name.
88
88
  `@avi2dg/checks/scripts/test-skips.ts`, `@avi2dg/checks/scripts/comment-matchers.ts` and `@avi2dg/checks/scripts/prose-matchers.ts` point at their files under `src/`.
89
89
  `@avi2dg/checks/browser-hooks.ts` points at `src/quality/browser/hooks.ts`, whose types a product's browser hooks import.
90
+ `@avi2dg/checks/browser-checks.json` points at `src/quality/browser/browser-checks.json`, the starter a product copies.
90
91
  `@avi2dg/checks/templates/*` points at `dist/templates/`.
91
92
  A path read without the resolver, such as `node_modules/@avi2dg/checks/scripts/test-skips.ts`, does not exist.
92
93
 
@@ -121,12 +121,12 @@ It reads `browser-checks.json` in the directory it runs in:
121
121
  { "path": "/ru/", "locale": "ru" }
122
122
  ],
123
123
  "viewports": [
124
- { "name": "phone", "width": 375, "height": 812, "touch": true },
125
- { "name": "desktop", "width": 1280, "height": 800 }
124
+ { "preset": "anchor" },
125
+ { "name": "small laptop", "width": 1280, "height": 720, "device": "desktop" }
126
126
  ],
127
127
  "states": [
128
128
  { "name": "default" },
129
- { "name": "enlarged text", "textPx": 32 },
129
+ { "preset": "enlarged-text" },
130
130
  { "name": "reduced motion", "reducedMotion": "reduce" }
131
131
  ],
132
132
  "targets": [
@@ -143,8 +143,8 @@ It reads `browser-checks.json` in the directory it runs in:
143
143
  | --- | --- |
144
144
  | `site` | the directory of the built site, which the runner serves on a local port, an `index.html` answering for its directory |
145
145
  | `routes` | each page to visit, as a `path` from the site root and the `locale` it renders |
146
- | `viewports` | each window to visit in, with a `name`, a `width` and a `height` in pixels, and `touch` for a phone that has touch and a mobile viewport |
147
- | `states` | each condition to visit in, with a `name`, `textPx` for the browser's default text size, and `reducedMotion` as `reduce` or `no-preference` |
146
+ | `viewports` | each window to visit in, with a `name`, a `width` and a `height` in pixels, `touch` for a phone that has touch and a mobile viewport, and `device` as `phone`, `tablet` or `desktop`, or a `preset` that stands for a set of screen sizes |
147
+ | `states` | each condition to visit in, with a `name`, `textPx` for the browser's default text size, and `reducedMotion` as `reduce` or `no-preference`, or the `enlarged-text` preset |
148
148
  | `targets` | each named control or text the checks judge, with a CSS `selector`, the `routes` it is on when not every route, and `focusable` when Tab must reach it |
149
149
  | `checks` | the built-in checks to run, from `layout`, `keyboard`, `motion`, `axe`, `nesting` and `assets` |
150
150
  | `hooks` | the module of product hooks, as a path from the directory the runner runs in |
@@ -153,6 +153,88 @@ It reads `browser-checks.json` in the directory it runs in:
153
153
  A declaration that names a route, viewport, state, target or check twice is refused.
154
154
  So are a `motion` check with no state whose `reducedMotion` is `reduce`, a `keyboard` check with no `focusable` target, and a target or a hook that names an undeclared route.
155
155
 
156
+ ### Screen sizes
157
+
158
+ A viewport `{ "preset": "anchor" }` stands for the anchor set of screen sizes, and `{ "preset": "full" }` for the full set.
159
+ The full set holds the anchors and eleven more sizes.
160
+ A product runs the anchor set on every pull request and the full set at night.
161
+ Each size in a set is a viewport named for its device and its size, with `device` set, and each phone also sets `touch`.
162
+ A preset sits beside a product's own viewports.
163
+ A name both declare is refused as declared twice, and so is a size that two listed presets both hold.
164
+
165
+ <!-- generated screen-sizes: bun run build writes it from SCREEN_SIZES in src/quality/browser/screen-sizes.ts and scripts/doc-blocks.ts -->
166
+
167
+ | Device | Width | Height | Name | Sets |
168
+ | --- | --- | --- | --- | --- |
169
+ | phone | 360 | 640 | `phone-360x640` | `anchor` and `full` |
170
+ | phone | 375 | 667 | `phone-375x667` | `anchor` and `full` |
171
+ | phone | 393 | 852 | `phone-393x852` | `anchor` and `full` |
172
+ | phone | 412 | 915 | `phone-412x915` | `anchor` and `full` |
173
+ | tablet | 820 | 1180 | `tablet-820x1180` | `anchor` and `full` |
174
+ | desktop | 1366 | 768 | `desktop-1366x768` | `anchor` and `full` |
175
+ | desktop | 1440 | 900 | `desktop-1440x900` | `anchor` and `full` |
176
+ | desktop | 1920 | 1080 | `desktop-1920x1080` | `anchor` and `full` |
177
+ | phone | 320 | 568 | `phone-320x568` | `full` |
178
+ | phone | 360 | 540 | `phone-360x540` | `full` |
179
+ | phone | 375 | 567 | `phone-375x567` | `full` |
180
+ | phone | 375 | 812 | `phone-375x812` | `full` |
181
+ | phone | 390 | 844 | `phone-390x844` | `full` |
182
+ | phone | 430 | 932 | `phone-430x932` | `full` |
183
+ | tablet | 768 | 1024 | `tablet-768x1024` | `full` |
184
+ | tablet | 1024 | 1366 | `tablet-1024x1366` | `full` |
185
+ | desktop | 1280 | 800 | `desktop-1280x800` | `full` |
186
+ | desktop | 1536 | 864 | `desktop-1536x864` | `full` |
187
+ | desktop | 2560 | 1440 | `desktop-2560x1440` | `full` |
188
+
189
+ <!-- end generated screen-sizes -->
190
+
191
+ The state `{ "preset": "enlarged-text" }` stands for a state named `enlarged text` whose `textPx` is 32.
192
+ The sizes were checked on 2026-10-31 against four sources.
193
+ They are StatCounter's screen resolution statistics, the point sizes in Apple's Human Interface Guidelines, Material Design's device metrics and the Chrome UX Report.
194
+ They are due for a recheck against those sources once a year.
195
+
196
+ ### Start from the shipped declaration
197
+
198
+ The kit ships a starter declaration, which a product copies and then edits to its own routes and targets:
199
+
200
+ ```sh
201
+ cp node_modules/@avi2dg/checks/src/quality/browser/browser-checks.json browser-checks.json
202
+ ```
203
+
204
+ A host resolves it as `@avi2dg/checks/browser-checks.json`.
205
+
206
+ <!-- generated starter: bun run build writes it from src/quality/browser/browser-checks.json and scripts/doc-blocks.ts -->
207
+
208
+ ```json
209
+ {
210
+ "site": "dist",
211
+ "routes": [
212
+ { "path": "/", "locale": "en" }
213
+ ],
214
+ "viewports": [
215
+ { "preset": "anchor" }
216
+ ],
217
+ "states": [
218
+ { "name": "default" },
219
+ { "preset": "enlarged-text" },
220
+ { "name": "reduced motion", "reducedMotion": "reduce" }
221
+ ],
222
+ "targets": [
223
+ { "name": "headline", "selector": "main h1" },
224
+ { "name": "link", "selector": "a[href]", "focusable": true }
225
+ ],
226
+ "checks": ["layout", "keyboard", "motion", "axe", "nesting", "assets"]
227
+ }
228
+ ```
229
+
230
+ <!-- end generated starter -->
231
+
232
+ For the night run, a product keeps a second declaration in a directory of its own.
233
+ Its `site` is a path from that directory, such as `../dist`, and its viewports are `{ "preset": "full" }`.
234
+ It runs `checks-browser <directory>` on that directory.
235
+
236
+ ### Chrome and the peers
237
+
156
238
  It drives the Chrome the machine has, which `CHROME_PATH` names, or Chrome's standard install when it is unset.
157
239
  The product installs the optional peers the runner loads, at the versions the kit pins:
158
240
 
@@ -183,15 +265,15 @@ It reads the declaration in the directory it runs in, or in the directory it is
183
265
  ## Sample output
184
266
 
185
267
  ```
186
- browser: targets at /ru/ (ru), phone 375x812 touch, enlarged text scanned language switch 1, email contact 1, headline 1
187
- browser: layout at /ru/ (ru), phone 375x812 touch, enlarged text scanned language switch 1, email contact 1, headline 1
188
- browser: keyboard at /ru/ (ru), phone 375x812 touch, enlarged text scanned language switch 1, email contact 1
189
- browser: axe at /ru/ (ru), phone 375x812 touch, enlarged text scanned 11 rule(s)
268
+ browser: targets at /ru/ (ru), phone-375x667 375x667 touch, enlarged text scanned language switch 1, email contact 1, headline 1
269
+ browser: layout at /ru/ (ru), phone-375x667 375x667 touch, enlarged text scanned language switch 1, email contact 1, headline 1
270
+ browser: keyboard at /ru/ (ru), phone-375x667 375x667 touch, enlarged text scanned language switch 1, email contact 1
271
+ browser: axe at /ru/ (ru), phone-375x667 375x667 touch, enlarged text scanned 11 rule(s)
190
272
  browser: 1 contrast check(s) axe cannot measure, reported as unverified:
191
- axe at /ru/ (ru), phone 375x812 touch, enlarged text: color-contrast unverified: header > p: Element's background color could not be determined due to a background image
273
+ axe at /ru/ (ru), phone-375x667 375x667 touch, enlarged text: color-contrast unverified: header > p: Element's background color could not be determined due to a background image
192
274
  browser: 2 failure(s) in 4 check run(s) over 1 route(s), 1 viewport(s), 1 state(s) and 3 target(s):
193
- layout at /ru/ (ru), phone 375x812 touch, enlarged text: email contact "Написать письмо владельцу" clips its own text
194
- axe at /ru/ (ru), phone 375x812 touch, enlarged text: nested-interactive (serious): button: Interactive controls must not be nested
275
+ layout at /ru/ (ru), phone-375x667 375x667 touch, enlarged text: email contact "Написать письмо владельцу" clips its own text
276
+ axe at /ru/ (ru), phone-375x667 375x667 touch, enlarged text: nested-interactive (serious): button: Interactive controls must not be nested
195
277
  ```
196
278
 
197
279
  ## When it runs
@@ -21,7 +21,7 @@ Every path is relative to the installed package, `node_modules/@avi2dg/checks/`.
21
21
  | `commitlint.config.ts` | the shared commitlint config |
22
22
  | `dependency-cruiser.config.js` | the kit's dependency-cruiser rules as a base a `.dependency-cruiser.cjs` extends by path, which the build writes |
23
23
  | `knip-base.json` | the Knip `include` setting, for a configuration that spreads it |
24
- | `src/` | every bin, which a package script calls by its `checks-` name, the modules the bins import, and the Effect language service severities under `src/quality/presets/` |
24
+ | `src/` | every bin, which a package script calls by its `checks-` name, the modules the bins import, the Effect language service severities under `src/quality/presets/`, and the starter `browser-checks.json` under `src/quality/browser/` |
25
25
  | `dist/` | the compiled oxlint plugins, the config builders `@avi2dg/checks/oxlint`, `@avi2dg/checks/knip`, `@avi2dg/checks/dependency-cruiser` and `@avi2dg/checks/stylelint` resolve to, and the doc templates, one template per kind of doc file |
26
26
  | `oxlintrc.json` | the oxlint `base` as a config a `.oxlintrc.json` extends by path, which the build writes |
27
27
  | `stryker.preset.js` | the Stryker mutation-testing preset, which refuses a full run outside CI |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.58.0",
3
+ "version": "0.59.0",
4
4
  "description": "Deterministic checks shared across a set of TypeScript repositories",
5
5
  "author": {
6
6
  "name": "avi2d",
@@ -141,6 +141,7 @@
141
141
  "./templates/*": "./dist/templates/*",
142
142
  "./tsconfig.effect.json": "./tsconfig.effect.json",
143
143
  "./browser-hooks.ts": "./src/quality/browser/hooks.ts",
144
+ "./browser-checks.json": "./src/quality/browser/browser-checks.json",
144
145
  "./oxlint": {
145
146
  "types": "./dist/presets/oxlint.d.ts",
146
147
  "default": "./dist/presets/oxlint.js"
@@ -0,0 +1,19 @@
1
+ {
2
+ "site": "dist",
3
+ "routes": [
4
+ { "path": "/", "locale": "en" }
5
+ ],
6
+ "viewports": [
7
+ { "preset": "anchor" }
8
+ ],
9
+ "states": [
10
+ { "name": "default" },
11
+ { "preset": "enlarged-text" },
12
+ { "name": "reduced motion", "reducedMotion": "reduce" }
13
+ ],
14
+ "targets": [
15
+ { "name": "headline", "selector": "main h1" },
16
+ { "name": "link", "selector": "a[href]", "focusable": true }
17
+ ],
18
+ "checks": ["layout", "keyboard", "motion", "axe", "nesting", "assets"]
19
+ }
@@ -1,5 +1,6 @@
1
- import { Effect, FileSystem, Path, Schema } from "effect";
1
+ import { Array as Arr, Effect, FileSystem, Path, Schema } from "effect";
2
2
  import { Usage } from "../../core/main.ts";
3
+ import { DEVICES, ENLARGED_TEXT_PX, SCREEN_SIZE_SETS, SCREEN_SIZES, screenSizeName, type ScreenSize } from "./screen-sizes.ts";
3
4
 
4
5
  const DECLARATION = "browser-checks.json";
5
6
 
@@ -15,7 +16,15 @@ const Route = Schema.Struct({
15
16
  locale: Name,
16
17
  });
17
18
 
18
- const Viewport = Schema.Struct({ name: Name, width: Pixels, height: Pixels, touch: Schema.optionalKey(Schema.Boolean) });
19
+ const Viewport = Schema.Struct({
20
+ name: Name,
21
+ width: Pixels,
22
+ height: Pixels,
23
+ touch: Schema.optionalKey(Schema.Boolean),
24
+ device: Schema.optionalKey(Schema.Literals(DEVICES)),
25
+ });
26
+
27
+ const ScreenSizeSet = Schema.Struct({ preset: Schema.Literals(SCREEN_SIZE_SETS) });
19
28
 
20
29
  const State = Schema.Struct({
21
30
  name: Name,
@@ -23,6 +32,10 @@ const State = Schema.Struct({
23
32
  reducedMotion: Schema.optionalKey(Schema.Literals(["reduce", "no-preference"])),
24
33
  });
25
34
 
35
+ const STATE_PRESETS = ["enlarged-text"] as const;
36
+
37
+ const StatePreset = Schema.Struct({ preset: Schema.Literals(STATE_PRESETS) });
38
+
26
39
  const Target = Schema.Struct({
27
40
  name: Name,
28
41
  selector: Name,
@@ -40,6 +53,12 @@ const Declaration = Schema.Struct({
40
53
  hooks: Schema.optionalKey(Name),
41
54
  });
42
55
 
56
+ const Listed = Schema.Struct({
57
+ ...Declaration.fields,
58
+ viewports: Schema.NonEmptyArray(Schema.Union([Viewport, ScreenSizeSet])),
59
+ states: Schema.NonEmptyArray(Schema.Union([State, StatePreset])),
60
+ });
61
+
43
62
  export type Route = typeof Route.Type;
44
63
  export type Viewport = typeof Viewport.Type;
45
64
  export type State = typeof State.Type;
@@ -52,7 +71,26 @@ export type Visit = {
52
71
  readonly state: State;
53
72
  };
54
73
 
55
- const decodeDeclaration = Schema.decodeUnknownEffect(Schema.fromJsonString(Declaration));
74
+ export type Listed = typeof Listed.Type;
75
+
76
+ const decodeListed = Schema.decodeUnknownEffect(Schema.fromJsonString(Listed));
77
+
78
+ function viewportOf(size: ScreenSize): Viewport {
79
+ const { device, width, height } = size;
80
+ return { name: screenSizeName(size), width, height, device, ...(device === "phone" ? { touch: true } : {}) };
81
+ }
82
+
83
+ const STATES: Readonly<Record<(typeof STATE_PRESETS)[number], State>> = {
84
+ "enlarged-text": { name: "enlarged text", textPx: ENLARGED_TEXT_PX },
85
+ };
86
+
87
+ export function expanded(listed: Listed): Declaration {
88
+ return {
89
+ ...listed,
90
+ viewports: Arr.flatMap(listed.viewports, (entry) => ("preset" in entry ? Arr.map(SCREEN_SIZES[entry.preset], viewportOf) : [entry])),
91
+ states: Arr.map(listed.states, (entry) => ("preset" in entry ? STATES[entry.preset] : entry)),
92
+ };
93
+ }
56
94
 
57
95
  function repeated(names: readonly string[]): readonly string[] {
58
96
  return [...new Set(names.filter((name, index) => names.indexOf(name) !== index))];
@@ -85,9 +123,10 @@ export const readDeclaration = Effect.fn("readDeclaration")(function* (root: str
85
123
  if (!(yield* fs.exists(file))) {
86
124
  return yield* new Usage({ message: `${DECLARATION} is missing, and a product opts in to the browser checks by declaring its pages there` });
87
125
  }
88
- const declaration = yield* decodeDeclaration(yield* fs.readFileString(file)).pipe(
126
+ const listed = yield* decodeListed(yield* fs.readFileString(file)).pipe(
89
127
  Effect.mapError(({ message }) => new Usage({ message: `${DECLARATION} does not decode: ${message}` })),
90
128
  );
129
+ const declaration = expanded(listed);
91
130
  const found = inconsistencies(declaration);
92
131
  if (found.length > 0) return yield* new Usage({ message: `${DECLARATION}: ${found.join("; ")}` });
93
132
  return declaration;
@@ -1,10 +1,63 @@
1
1
  import { Effect, Schema } from "effect";
2
2
  import type { BrowserContext, Page } from "playwright-core";
3
+ import type { Rect } from "./dom.ts";
3
4
  import { attempt, BrowserFailure } from "./page.ts";
4
5
 
5
6
  const CONTROLS = "a[href], button, summary, input, select, textarea";
6
7
 
7
- const HOVER_TIMEOUT_MS = 2_000;
8
+ const Landing = Schema.NullOr(Schema.Struct({ x: Schema.Finite, y: Schema.Finite }));
9
+
10
+ type ScrollAlignment = "nearest" | "center" | "start" | "end";
11
+
12
+ type PointerTarget = {
13
+ readonly ownerDocument: { readonly documentElement: { readonly clientWidth: number; readonly clientHeight: number } };
14
+ readonly scrollIntoView: (options: { readonly behavior: "instant"; readonly block: ScrollAlignment; readonly inline: ScrollAlignment }) => void;
15
+ readonly getClientRects: () => Iterable<Rect>;
16
+ readonly getRootNode: () => { readonly elementFromPoint: (x: number, y: number) => unknown };
17
+ };
18
+
19
+ // Playwright's hover retries until a timeout to give up on a control the pointer cannot reach, and a busy host outlasts any timeout on a control it can reach.
20
+ // The page runs this from its source text, so it must not reach anything outside its own body.
21
+ function pointerLanding(control: PointerTarget): typeof Landing.Type {
22
+ const reachesControl = (hit: unknown): boolean => {
23
+ let node: unknown = hit;
24
+ while (node !== null && node !== undefined) {
25
+ if (node === control) return true;
26
+ const candidate = node as { readonly assignedSlot?: unknown; readonly parentNode?: unknown; readonly host?: unknown };
27
+ if (candidate.assignedSlot !== undefined && candidate.assignedSlot !== null) {
28
+ node = candidate.assignedSlot;
29
+ continue;
30
+ }
31
+ if (candidate.parentNode !== undefined && candidate.parentNode !== null) {
32
+ node = candidate.parentNode;
33
+ continue;
34
+ }
35
+ if (candidate.host !== undefined && candidate.host !== null) {
36
+ node = candidate.host;
37
+ continue;
38
+ }
39
+ return false;
40
+ }
41
+ return false;
42
+ };
43
+ const alignments: readonly ScrollAlignment[] = ["nearest", "center", "start", "end"];
44
+ for (const alignment of alignments) {
45
+ control.scrollIntoView({ behavior: "instant", block: alignment, inline: alignment });
46
+ const { clientWidth, clientHeight } = control.ownerDocument.documentElement;
47
+ for (const rect of control.getClientRects()) {
48
+ const left = Math.max(rect.left, 0);
49
+ const right = Math.min(rect.right, clientWidth);
50
+ const top = Math.max(rect.top, 0);
51
+ const bottom = Math.min(rect.bottom, clientHeight);
52
+ if (right - left < 1 || bottom - top < 1) continue;
53
+ const x = (left + right) / 2;
54
+ const y = (top + bottom) / 2;
55
+ const hit = control.getRootNode().elementFromPoint(x, y);
56
+ if (hit !== null && reachesControl(hit)) return { x, y };
57
+ }
58
+ }
59
+ return null;
60
+ }
8
61
 
9
62
  // A motion reset for reduced motion shortens animations to near zero rather than removing them, so one that ends within a frame never moves.
10
63
  const FRAME_MS = 1000 / 60;
@@ -33,6 +86,8 @@ const MOTION_RECORDER = `(() => {
33
86
 
34
87
  const decodeStarted = Schema.decodeUnknownEffect(Schema.Array(Schema.String));
35
88
 
89
+ const decodeLanding = Schema.decodeUnknownEffect(Landing);
90
+
36
91
  export function recordMotion(context: BrowserContext): Promise<unknown> {
37
92
  return context.addInitScript(MOTION_RECORDER);
38
93
  }
@@ -67,11 +122,12 @@ export const judgeStillness = Effect.fn("judgeStillness")(function* (page: Page)
67
122
  const control = controls.nth(index);
68
123
  if (!(yield* attempt(`cannot see control ${index + 1}`, () => control.isVisible()))) continue;
69
124
  judged += 1;
70
- const hovered = yield* attempt(`cannot hover control ${index + 1}`, () => control.hover({ timeout: HOVER_TIMEOUT_MS })).pipe(
71
- Effect.as(true),
72
- Effect.catchTag("BrowserFailure", () => Effect.succeed(false)),
125
+ const landing: unknown = yield* attempt(`cannot find where the pointer lands on control ${index + 1}`, () => control.evaluate(pointerLanding));
126
+ const point = yield* decodeLanding(landing).pipe(
127
+ Effect.mapError(() => new BrowserFailure({ message: `the pointer landing on control ${index + 1} gave ${JSON.stringify(landing)}` })),
73
128
  );
74
- if (hovered) {
129
+ if (point !== null) {
130
+ yield* attempt(`cannot hover control ${index + 1}`, () => page.mouse.move(point.x, point.y));
75
131
  yield* animatedDuring(`on hovering control ${index + 1}`);
76
132
  yield* attempt("cannot move the pointer away", () => page.mouse.move(0, 0));
77
133
  yield* animatedDuring(`on leaving control ${index + 1}`);
@@ -0,0 +1,48 @@
1
+ import type { NonEmptyReadonlyArray } from "effect/Array";
2
+
3
+ export const DEVICES = ["phone", "tablet", "desktop"] as const;
4
+
5
+ type Device = (typeof DEVICES)[number];
6
+
7
+ export type ScreenSize = { readonly device: Device; readonly width: number; readonly height: number };
8
+
9
+ const phone = (width: number, height: number): ScreenSize => ({ device: "phone", width, height });
10
+ const tablet = (width: number, height: number): ScreenSize => ({ device: "tablet", width, height });
11
+ const desktop = (width: number, height: number): ScreenSize => ({ device: "desktop", width, height });
12
+
13
+ const ANCHOR = [
14
+ phone(360, 640),
15
+ phone(375, 667),
16
+ phone(393, 852),
17
+ phone(412, 915),
18
+ tablet(820, 1180),
19
+ desktop(1366, 768),
20
+ desktop(1440, 900),
21
+ desktop(1920, 1080),
22
+ ] as const;
23
+
24
+ const NIGHTLY = [
25
+ phone(320, 568),
26
+ phone(360, 540),
27
+ phone(375, 567),
28
+ phone(375, 812),
29
+ phone(390, 844),
30
+ phone(430, 932),
31
+ tablet(768, 1024),
32
+ tablet(1024, 1366),
33
+ desktop(1280, 800),
34
+ desktop(1536, 864),
35
+ desktop(2560, 1440),
36
+ ] as const;
37
+
38
+ export const SCREEN_SIZE_SETS = ["anchor", "full"] as const;
39
+
40
+ export type ScreenSizeSet = (typeof SCREEN_SIZE_SETS)[number];
41
+
42
+ export const SCREEN_SIZES: Readonly<Record<ScreenSizeSet, NonEmptyReadonlyArray<ScreenSize>>> = { anchor: ANCHOR, full: [...ANCHOR, ...NIGHTLY] };
43
+
44
+ export function screenSizeName({ device, width, height }: ScreenSize): string {
45
+ return `${device}-${width}x${height}`;
46
+ }
47
+
48
+ export const ENLARGED_TEXT_PX = 32;