headreel 1.0.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.
@@ -0,0 +1,12 @@
1
+ /** A failure fetching GitHub data. The run fails and the existing banner stays. */
2
+ export class DataError extends Error {
3
+ code;
4
+ /** When `code` is `rate_limited`: time at which a retry may succeed. */
5
+ retryAt;
6
+ constructor(code, message, retryAt) {
7
+ super(message);
8
+ this.name = 'DataError';
9
+ this.code = code;
10
+ this.retryAt = retryAt;
11
+ }
12
+ }
@@ -0,0 +1,18 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { z } from 'zod';
3
+ /**
4
+ * Loads offline data for `--fixture`. The file holds the style's normalized
5
+ * data (not a raw API response) plus the profile, validated against `schema`.
6
+ */
7
+ export async function loadFixture(path, schema) {
8
+ const raw = JSON.parse(await readFile(path, 'utf8'));
9
+ const fixtureSchema = z.object({
10
+ profile: z.object({ login: z.string().min(1), name: z.string().min(1) }),
11
+ data: schema,
12
+ });
13
+ const parsed = fixtureSchema.safeParse(raw);
14
+ if (!parsed.success) {
15
+ throw new Error(`Invalid fixture ${path}:\n${z.prettifyError(parsed.error)}`);
16
+ }
17
+ return parsed.data;
18
+ }
@@ -0,0 +1,65 @@
1
+ import { DataError } from './errors.js';
2
+ const ENDPOINT = 'https://api.github.com/graphql';
3
+ /**
4
+ * Creates a GitHub GraphQL client. Does not retry: a failed run keeps the
5
+ * existing banner, and the next scheduled run tries again.
6
+ */
7
+ export function createGraphQLClient({ token, fetch: fetchFn = fetch, }) {
8
+ return async (query, variables = {}) => {
9
+ let res;
10
+ try {
11
+ res = await fetchFn(ENDPOINT, {
12
+ method: 'POST',
13
+ headers: {
14
+ authorization: `bearer ${token}`,
15
+ 'content-type': 'application/json',
16
+ 'user-agent': 'headreel',
17
+ },
18
+ body: JSON.stringify({ query, variables }),
19
+ });
20
+ }
21
+ catch (err) {
22
+ throw new DataError('api', `GitHub API request failed: ${err.message}`);
23
+ }
24
+ const rateLimit = rateLimitError(res);
25
+ if (rateLimit)
26
+ throw rateLimit;
27
+ if (res.status === 401) {
28
+ throw new DataError('auth', 'GitHub rejected the token (401). Check that it is valid.');
29
+ }
30
+ if (!res.ok) {
31
+ throw new DataError('api', `GitHub API returned ${res.status} ${res.statusText}`);
32
+ }
33
+ const body = (await res.json());
34
+ const first = body.errors?.[0];
35
+ if (first) {
36
+ if (first.type === 'NOT_FOUND')
37
+ throw new DataError('not_found', first.message);
38
+ if (first.type === 'RATE_LIMITED')
39
+ throw new DataError('rate_limited', first.message);
40
+ throw new DataError('api', `GitHub API error: ${first.message}`);
41
+ }
42
+ if (!body.data)
43
+ throw new DataError('api', 'GitHub API returned no data');
44
+ return body.data;
45
+ };
46
+ }
47
+ /** Primary limit: remaining is 0. Secondary limit: 403/429, possibly with retry-after. */
48
+ function rateLimitError(res) {
49
+ const remaining = res.headers.get('x-ratelimit-remaining');
50
+ const reset = res.headers.get('x-ratelimit-reset');
51
+ const retryAfter = res.headers.get('retry-after');
52
+ if (retryAfter !== null && (res.status === 403 || res.status === 429)) {
53
+ const at = new Date(Date.now() + Number(retryAfter) * 1000);
54
+ return new DataError('rate_limited', `GitHub secondary rate limit hit; retry after ${retryAfter}s`, at);
55
+ }
56
+ if (remaining === '0') {
57
+ const at = reset !== null ? new Date(Number(reset) * 1000) : undefined;
58
+ const when = at ? ` until ${at.toISOString()}` : '';
59
+ return new DataError('rate_limited', `GitHub rate limit exhausted${when}`, at);
60
+ }
61
+ if (res.status === 429) {
62
+ return new DataError('rate_limited', 'GitHub rate limit hit (429)');
63
+ }
64
+ return undefined;
65
+ }
@@ -0,0 +1,13 @@
1
+ const QUERY = /* GraphQL */ `
2
+ query Profile($login: String!) {
3
+ user(login: $login) {
4
+ login
5
+ name
6
+ }
7
+ }
8
+ `;
9
+ export async function fetchProfile(client, login) {
10
+ const { user } = await client(QUERY, { login });
11
+ const name = user.name?.trim();
12
+ return { login: user.login, name: name ? name : user.login };
13
+ }
@@ -0,0 +1,26 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { DataError } from './errors.js';
3
+ /**
4
+ * Resolves a GitHub token: --token, then GITHUB_TOKEN, then `gh auth token`.
5
+ * GitHub's GraphQL API requires authentication, so no token is an error.
6
+ */
7
+ export function resolveToken({ flag, env = process.env, ghToken = readGhToken, } = {}) {
8
+ const token = flag?.trim() || env.GITHUB_TOKEN?.trim() || ghToken();
9
+ if (!token) {
10
+ throw new DataError('auth', 'No GitHub token found. Pass --token, set GITHUB_TOKEN, or log in with `gh auth login`.');
11
+ }
12
+ return token;
13
+ }
14
+ function readGhToken() {
15
+ try {
16
+ const out = execFileSync('gh', ['auth', 'token'], {
17
+ encoding: 'utf8',
18
+ stdio: ['ignore', 'pipe', 'ignore'],
19
+ timeout: 5000,
20
+ }).trim();
21
+ return out || undefined;
22
+ }
23
+ catch {
24
+ return undefined;
25
+ }
26
+ }
@@ -0,0 +1,92 @@
1
+ import * as gifencModule from 'gifenc';
2
+ // Node loads gifenc's CommonJS build (functions live on the default export);
3
+ // ESM-aware tools such as vitest load its ESM build (named exports).
4
+ const gifenc = typeof gifencModule.quantize === 'function'
5
+ ? gifencModule
6
+ : gifencModule.default;
7
+ const { GIFEncoder, quantize, applyPalette } = gifenc;
8
+ /** Number of frames sampled to build the shared palette. */
9
+ const PALETTE_SAMPLES = 6;
10
+ /** 255 real colors; the last global palette slot is the transparent index. */
11
+ const COLORS = 255;
12
+ const TRANSPARENT = COLORS;
13
+ /** Peak-to-peak dither amplitude, per channel. About one palette step. */
14
+ const DITHER_SPREAD = 14;
15
+ /** GIF disposal: leave the frame in place, so transparent pixels show the previous one. */
16
+ const DISPOSE_KEEP = 1;
17
+ // 8x8 Bayer matrix, normalized to -0.5..0.5.
18
+ const BAYER = (() => {
19
+ const m = [
20
+ [0, 32, 8, 40, 2, 34, 10, 42],
21
+ [48, 16, 56, 24, 50, 18, 58, 26],
22
+ [12, 44, 4, 36, 14, 46, 6, 38],
23
+ [60, 28, 52, 20, 62, 30, 54, 22],
24
+ [3, 35, 11, 43, 1, 33, 9, 41],
25
+ [51, 19, 59, 27, 49, 17, 57, 25],
26
+ [15, 47, 7, 39, 13, 45, 5, 37],
27
+ [63, 31, 55, 23, 61, 29, 53, 21],
28
+ ];
29
+ return m.flat().map((v) => (v + 0.5) / 64 - 0.5);
30
+ })();
31
+ /**
32
+ * Ordered dithering. The pattern depends only on pixel position, so a pixel
33
+ * that does not change between frames dithers identically, which keeps the
34
+ * frame deltas small and avoids shimmer.
35
+ */
36
+ function dither(rgba, width) {
37
+ const out = new Uint8ClampedArray(rgba.length);
38
+ for (let i = 0, p = 0; i < rgba.length; i += 4, p++) {
39
+ const x = p % width;
40
+ const y = (p - x) / width;
41
+ const offset = BAYER[(y & 7) * 8 + (x & 7)] * DITHER_SPREAD;
42
+ out[i] = rgba[i] + offset;
43
+ out[i + 1] = rgba[i + 1] + offset;
44
+ out[i + 2] = rgba[i + 2] + offset;
45
+ out[i + 3] = 255;
46
+ }
47
+ return out;
48
+ }
49
+ function buildPalette(frames, frameBytes) {
50
+ const step = Math.max(1, Math.floor(frames.length / PALETTE_SAMPLES));
51
+ const picks = frames.filter((_, i) => i % step === 0).slice(0, PALETTE_SAMPLES);
52
+ const sample = new Uint8ClampedArray(frameBytes * picks.length);
53
+ picks.forEach((data, i) => sample.set(data, i * frameBytes));
54
+ return quantize(sample, COLORS);
55
+ }
56
+ /**
57
+ * Encodes RGBA frames into a looping GIF: one global palette, ordered
58
+ * dithering, and delta frames where unchanged pixels are transparent.
59
+ */
60
+ export function encodeGif(frames, spec) {
61
+ const { width, height } = spec;
62
+ const palette = buildPalette(frames, width * height * 4);
63
+ const globalPalette = [...palette];
64
+ while (globalPalette.length < TRANSPARENT)
65
+ globalPalette.push([0, 0, 0]);
66
+ globalPalette.push([0, 0, 0]);
67
+ const gif = GIFEncoder();
68
+ const delay = Math.round(1000 / spec.fps);
69
+ let previous;
70
+ for (const [i, data] of frames.entries()) {
71
+ const index = applyPalette(dither(data, width), palette);
72
+ let pixels = index;
73
+ if (previous) {
74
+ pixels = new Uint8Array(index);
75
+ for (let p = 0; p < pixels.length; p++) {
76
+ if (index[p] === previous[p])
77
+ pixels[p] = TRANSPARENT;
78
+ }
79
+ }
80
+ gif.writeFrame(pixels, width, height, {
81
+ ...(i === 0 ? { palette: globalPalette } : {}),
82
+ delay,
83
+ repeat: 0,
84
+ transparent: i > 0,
85
+ transparentIndex: TRANSPARENT,
86
+ dispose: DISPOSE_KEEP,
87
+ });
88
+ previous = index;
89
+ }
90
+ gif.finish();
91
+ return gif.bytes();
92
+ }
@@ -0,0 +1,22 @@
1
+ import { GlobalFonts } from '@napi-rs/canvas';
2
+ import { fileURLToPath } from 'node:url';
3
+ /** Bundled fonts (OFL). Only these are used, so output never depends on system fonts. */
4
+ const FONT_FILES = [
5
+ 'SpaceGrotesk-Medium.ttf',
6
+ 'SpaceGrotesk-Bold.ttf',
7
+ 'JetBrainsMono-Regular.ttf',
8
+ ];
9
+ // Resolves from both src/core (tsx, vitest) and dist/core (published build).
10
+ const FONT_DIR = new URL('../../assets/fonts/', import.meta.url);
11
+ let registered = false;
12
+ export function registerFonts() {
13
+ if (registered)
14
+ return;
15
+ for (const file of FONT_FILES) {
16
+ const path = fileURLToPath(new URL(file, FONT_DIR));
17
+ if (!GlobalFonts.registerFromPath(path)) {
18
+ throw new Error(`Failed to register font ${path}`);
19
+ }
20
+ }
21
+ registered = true;
22
+ }
@@ -0,0 +1,22 @@
1
+ import { z } from 'zod';
2
+ import { encodeGif } from './encode/gif.js';
3
+ import { registerFonts } from './fonts.js';
4
+ import { createRng, hashSeed } from './prng.js';
5
+ import { renderFrames } from './render/render.js';
6
+ import { CANVAS } from './canvas.js';
7
+ /** Validates raw options against the style's schema. Runs before any API call. */
8
+ export function parseOptions(style, raw = {}) {
9
+ const parsed = style.options.safeParse(raw);
10
+ if (!parsed.success) {
11
+ throw new Error(`Invalid options for ${style.id}:\n${z.prettifyError(parsed.error)}`);
12
+ }
13
+ return parsed.data;
14
+ }
15
+ export async function renderBanner(style, input) {
16
+ const options = parseOptions(style, input.options);
17
+ registerFonts();
18
+ const rng = createRng(hashSeed(`${input.login}:${style.id}`));
19
+ const sketch = style.createSketch({ data: input.data, options, identity: input.identity, rng });
20
+ const frames = await renderFrames(sketch, { ...CANVAS, frames: style.frames });
21
+ return encodeGif(frames, { ...CANVAS, fps: style.fps });
22
+ }
@@ -0,0 +1,19 @@
1
+ /** FNV-1a 32-bit hash of a string. Used to derive seeds from login + style. */
2
+ export function hashSeed(input) {
3
+ let h = 0x811c9dc5;
4
+ for (let i = 0; i < input.length; i++) {
5
+ h ^= input.charCodeAt(i);
6
+ h = Math.imul(h, 0x01000193);
7
+ }
8
+ return h >>> 0;
9
+ }
10
+ /** mulberry32: small, fast, deterministic PRNG returning floats in [0, 1). */
11
+ export function createRng(seed) {
12
+ let s = seed | 0;
13
+ return () => {
14
+ s = (s + 0x6d2b79f5) | 0;
15
+ let t = Math.imul(s ^ (s >>> 15), 1 | s);
16
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
17
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
18
+ };
19
+ }
@@ -0,0 +1,66 @@
1
+ import { createCanvas, DOMMatrix, ImageData, Path2D } from '@napi-rs/canvas';
2
+ import { JSDOM } from 'jsdom';
3
+ const DOM_GLOBALS = [
4
+ 'window',
5
+ 'document',
6
+ 'navigator',
7
+ 'HTMLElement',
8
+ 'HTMLCanvasElement',
9
+ 'Image',
10
+ 'Event',
11
+ 'MouseEvent',
12
+ 'getComputedStyle',
13
+ 'screen',
14
+ 'devicePixelRatio',
15
+ // p5 passes an AbortSignal to addEventListener; jsdom rejects Node's own.
16
+ 'AbortController',
17
+ 'AbortSignal',
18
+ ];
19
+ let installed = false;
20
+ /**
21
+ * Installs a jsdom window on globalThis with canvas elements backed by
22
+ * @napi-rs/canvas. p5 2.x draws shapes through Path2D, which node-canvas lacks.
23
+ * Idempotent; must run before importing p5.
24
+ */
25
+ export function installDom() {
26
+ if (installed)
27
+ return;
28
+ const dom = new JSDOM('<!doctype html><html><body></body></html>', { pretendToBeVisual: true });
29
+ const win = dom.window;
30
+ for (const key of DOM_GLOBALS) {
31
+ if (win[key] !== undefined) {
32
+ Object.defineProperty(globalThis, key, {
33
+ value: win[key],
34
+ configurable: true,
35
+ writable: true,
36
+ });
37
+ }
38
+ }
39
+ const backing = new WeakMap();
40
+ dom.window.HTMLCanvasElement.prototype.getContext = function (type) {
41
+ if (type !== '2d')
42
+ return null;
43
+ let canvas = backing.get(this);
44
+ if (!canvas || canvas.width !== this.width || canvas.height !== this.height) {
45
+ canvas = createCanvas(this.width, this.height);
46
+ backing.set(this, canvas);
47
+ }
48
+ return canvas.getContext('2d');
49
+ };
50
+ // p5 draws offscreen layers (createGraphics) by passing their jsdom canvas
51
+ // element; Skia only accepts its own canvases, so swap in the backing one.
52
+ const ctxProto = Object.getPrototypeOf(createCanvas(1, 1).getContext('2d'));
53
+ const drawImage = ctxProto.drawImage;
54
+ ctxProto.drawImage = function (image, ...args) {
55
+ const source = backing.get(image) ?? image;
56
+ drawImage.call(this, source, ...args);
57
+ };
58
+ Object.assign(globalThis, {
59
+ Path2D,
60
+ ImageData,
61
+ DOMMatrix,
62
+ requestAnimationFrame: () => 0,
63
+ cancelAnimationFrame: () => { },
64
+ });
65
+ installed = true;
66
+ }
@@ -0,0 +1,46 @@
1
+ import { installDom } from './dom.js';
2
+ /** Renders every frame of a sketch headlessly and returns RGBA buffers. */
3
+ export async function renderFrames(sketch, spec) {
4
+ installDom();
5
+ const { default: P5 } = (await import('p5/node'));
6
+ let frame = 0;
7
+ let capturing = false;
8
+ const instance = new P5((p) => {
9
+ p.setup = async () => {
10
+ p.pixelDensity(1);
11
+ p.createCanvas(spec.width, spec.height);
12
+ p.noLoop();
13
+ await sketch.setup?.(p);
14
+ };
15
+ // p5 runs one draw after setup even under noLoop(); skip it.
16
+ p.draw = () => {
17
+ if (capturing)
18
+ sketch.draw(p, frame, frame / spec.frames);
19
+ };
20
+ });
21
+ await untilIdle(instance);
22
+ capturing = true;
23
+ const ctx = instance.drawingContext;
24
+ const out = [];
25
+ for (frame = 0; frame < spec.frames; frame++) {
26
+ // redraw() is async in p5 2.x; not awaiting it captures blank frames.
27
+ await instance.redraw();
28
+ out.push(ctx.getImageData(0, 0, spec.width, spec.height).data);
29
+ }
30
+ instance.remove();
31
+ return out;
32
+ }
33
+ const SETUP_TIMEOUT_MS = 30_000;
34
+ /**
35
+ * p5's redraw() silently does nothing until setup has finished and while a
36
+ * draw is in progress. Wait for both before driving frames.
37
+ */
38
+ async function untilIdle(instance) {
39
+ const state = instance;
40
+ const deadline = Date.now() + SETUP_TIMEOUT_MS;
41
+ while (!state._setupDone || state._inUserDraw) {
42
+ if (Date.now() > deadline)
43
+ throw new Error('p5 sketch setup timed out');
44
+ await new Promise((r) => setImmediate(r));
45
+ }
46
+ }
@@ -0,0 +1,123 @@
1
+ export const LAYOUT = {
2
+ width: 1280,
3
+ height: 400,
4
+ /** Week column width. */
5
+ cell: 18,
6
+ /** Street between buildings. */
7
+ gap: 4,
8
+ /** Oblique offset per weekday row. */
9
+ depthX: 6,
10
+ depthY: 7,
11
+ /** Tallest tower, px. */
12
+ maxH: 150,
13
+ /** Ground line of the front row. */
14
+ baseY: 352,
15
+ rightMargin: 28,
16
+ arcs: 4,
17
+ arcTowers: 24,
18
+ arcMinWeeks: 8,
19
+ stars: 180,
20
+ grain: 2600,
21
+ };
22
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
23
+ /** Front-left ground corner of the whole city. */
24
+ export function cityOrigin(weeks) {
25
+ const span = weeks * LAYOUT.cell + 7 * LAYOUT.depthX;
26
+ return { x: LAYOUT.width - span - LAYOUT.rightMargin, y: LAYOUT.baseY };
27
+ }
28
+ /** Screen position of the front-left ground corner for (week, weekday). */
29
+ export function tileAt(weeks, w, d) {
30
+ const o = cityOrigin(weeks);
31
+ const depth = 6 - d;
32
+ return { x: o.x + w * LAYOUT.cell + depth * LAYOUT.depthX, y: o.y - depth * LAYOUT.depthY };
33
+ }
34
+ export function buildCity(data, rng, beacons) {
35
+ const between = (min, max) => min + rng() * (max - min);
36
+ const weeks = data.weeks.length;
37
+ const maxCount = Math.max(0, ...data.weeks.flat().map((d) => d.count));
38
+ const buildings = [];
39
+ data.weeks.forEach((week, w) => {
40
+ for (const day of week) {
41
+ const t = maxCount > 0 ? Math.sqrt(day.count / maxCount) : 0;
42
+ const h = day.count === 0 ? 0 : 6 + t * (LAYOUT.maxH - 6);
43
+ const b = {
44
+ w,
45
+ d: day.weekday,
46
+ count: day.count,
47
+ t,
48
+ h,
49
+ ...tileAt(weeks, w, day.weekday),
50
+ bw: LAYOUT.cell - LAYOUT.gap,
51
+ windows: [],
52
+ beacon: false,
53
+ beaconOffset: rng(),
54
+ };
55
+ if (h > 14) {
56
+ const rows = Math.floor((h - 8) / 6);
57
+ for (let r = 0; r < rows; r++) {
58
+ for (let k = 0; k < 2; k++) {
59
+ b.windows.push({
60
+ x: 3 + k * 6,
61
+ y: 6 + r * 6,
62
+ lit: rng() < 0.25 + t * 0.5,
63
+ cycles: rng() < 0.12 ? Math.floor(between(1, 4)) : 0,
64
+ off: rng(),
65
+ });
66
+ }
67
+ }
68
+ }
69
+ buildings.push(b);
70
+ }
71
+ });
72
+ // Stable ranking: count, then chronological order, so ties never depend on sort internals.
73
+ const ranked = buildings
74
+ .filter((b) => b.count > 0)
75
+ .sort((a, b) => b.count - a.count || a.w - b.w || a.d - b.d);
76
+ for (const b of ranked.slice(0, beacons))
77
+ b.beacon = true;
78
+ // Back rows first, then left to right, so front towers occlude correctly.
79
+ buildings.sort((a, b) => a.d - b.d || a.w - b.w);
80
+ const stars = Array.from({ length: LAYOUT.stars }, () => ({
81
+ x: rng() * LAYOUT.width,
82
+ y: Math.pow(rng(), 1.6) * 250,
83
+ s: rng() < 0.08 ? 2 : 1,
84
+ a: between(40, 160),
85
+ cycles: Math.floor(between(1, 4)),
86
+ off: rng(),
87
+ }));
88
+ const arcs = [];
89
+ const towers = ranked.slice(0, LAYOUT.arcTowers);
90
+ if (towers.length >= 2) {
91
+ const pick = () => towers[Math.floor(rng() * towers.length)];
92
+ for (let i = 0; i < LAYOUT.arcs; i++) {
93
+ const a = pick();
94
+ let b = pick();
95
+ for (let tries = 0; Math.abs(a.w - b.w) < LAYOUT.arcMinWeeks && tries < 20; tries++)
96
+ b = pick();
97
+ if (a === b)
98
+ continue;
99
+ arcs.push({ a, b, off: i / LAYOUT.arcs, lift: between(40, 80) });
100
+ }
101
+ }
102
+ return { weeks, total: data.total, maxCount, buildings, stars, arcs, months: monthLabels(data) };
103
+ }
104
+ /** A label at the first week of each month; drops a partial leading month that would collide. */
105
+ export function monthLabels(data) {
106
+ const months = [];
107
+ let last = -1;
108
+ data.weeks.forEach((week, w) => {
109
+ const first = week[0];
110
+ if (!first)
111
+ return;
112
+ const date = new Date(`${first.date}T00:00:00Z`);
113
+ const m = date.getUTCMonth();
114
+ if (m !== last) {
115
+ if (w > 0 || date.getUTCDate() <= 7)
116
+ months.push({ w, label: MONTHS[m] });
117
+ last = m;
118
+ }
119
+ });
120
+ if (months[0]?.w === 0 && months[1] && months[1].w < 3)
121
+ months.shift();
122
+ return months;
123
+ }
@@ -0,0 +1,14 @@
1
+ import { contributionsSchema, fetchContributions, } from '../../core/data/contributions.js';
2
+ import { buildCity } from './city.js';
3
+ import { options } from './options.js';
4
+ import { createCitySketch } from './sketch.js';
5
+ export const contributionCity = {
6
+ id: 'contribution-city',
7
+ fps: 25,
8
+ frames: 150,
9
+ data: { schema: contributionsSchema, fetch: fetchContributions },
10
+ options,
11
+ createSketch({ data, options, identity, rng }) {
12
+ return createCitySketch(buildCity(data, rng, options.beacons), identity, rng);
13
+ },
14
+ };
@@ -0,0 +1,7 @@
1
+ import { z } from 'zod';
2
+ export const options = z
3
+ .object({
4
+ /** Number of busiest days marked with a beacon. */
5
+ beacons: z.coerce.number().int().min(0).max(10).default(8),
6
+ })
7
+ .strict();