busybar-kit 0.2.1 → 1.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 viirtualp1
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,148 +1,148 @@
1
- # busybar-kit
2
-
3
- > [!IMPORTANT]
4
- > **Unofficial community project.** Built and maintained by [@viirtualp1](https://github.com/viirtualp1), **not** an official Flipper Devices / BUSY product, and not affiliated with, endorsed by, or supported by them. "BUSY Bar" remains their trademark. For the real hardware and official apps, visit **[busy.app](https://busy.app/)**.
5
-
6
- Shared building blocks for [BUSY Bar](https://busy.bar) apps. Everything here is
7
- app-agnostic: screen geometry, the palette neutrals, formatters, ticker text,
8
- the Bar's error taxonomy, the connection half of a config, and an offline PNG
9
- renderer that draws the same elements the device does.
10
-
11
- It is the common half of [busybar-dota](https://github.com/viirtualp1/busybar-dota),
12
- [busybar-mydota](https://github.com/viirtualp1/busybar-mydota) and
13
- [busybar-livesplit](https://github.com/viirtualp1/busybar-livesplit).
14
-
15
- ```bash
16
- npm install busybar-kit
17
- ```
18
-
19
- `@busy-app/busy-lib` is a peer dependency — the app owns that version.
20
-
21
- ## What is in it
22
-
23
- | Import | What it gives you |
24
- | ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
25
- | `busybar-kit/device` | `FRONT` / `BACK` screen sizes, `FONT_WIDTH`, `textWidth`, `clipToWidth`, `fittingChars`, `rowY`, `backRowGrid` |
26
- | `busybar-kit/colors` | `BASE_COLORS` — neutrals, chrome, and the transparent value that hides an element |
27
- | `busybar-kit/format` | `formatClock`, `formatGold`, `formatCountdown`, `formatStartTime`, `formatKda`, `formatPercent`, … |
28
- | `busybar-kit/elements` | `AnyElement`, `band()`, `fillWidth()` |
29
- | `busybar-kit/ticker` | `paginate` / `pageAt` / `scrollAt` / `tickerLine`, and `EventTicker` |
30
- | `busybar-kit/errors` | `BarApiError`, `toBarError`, `isLowPriority`, `isClientError`, `isForbidden` |
31
- | `busybar-kit/config` | `loadEnvFile`, `envReader`, `loadBarConfig` |
32
- | `busybar-kit/image` | `ditherToPanel` — a picture, in the back panel's 16 greys |
33
- | `busybar-kit/preview` | `renderFront` / `renderBack` / `Bitmap` — device-accurate PNGs with no hardware |
34
- | `busybar-kit/tsconfig.json`, `busybar-kit/eslint`, `busybar-kit/prettier` | the shared toolchain |
35
- | `busybar-fix-esm` (bin) | rewrites relative imports in `dist/` to carry `.js`, for Node ESM |
36
-
37
- ## Geometry is hardware, not layout
38
-
39
- `FRONT` (72×16) and `BACK` (160×80, with its header rows and 12px grid) are
40
- facts about the device. An app spreads its own named slots on top:
41
-
42
- ```ts
43
- import { FRONT as DEVICE, BACK as DEVICE_BACK } from 'busybar-kit/device';
44
-
45
- export const FRONT = {
46
- ...DEVICE,
47
- topY: 0,
48
- bottomY: 11,
49
- clockWidth: 24,
50
- } as const;
51
- ```
52
-
53
- An app whose header is a different height lays out its own row grid, and reads
54
- rows off that instead of the default:
55
-
56
- ```ts
57
- import { backRowGrid, rowY as gridRowY } from 'busybar-kit/device';
58
-
59
- export const BACK = { ...DEVICE_BACK, ...backRowGrid(16), nameX: 8 } as const;
60
-
61
- export const rowY = (index: number) => gridRowY(index, BACK);
62
- ```
63
-
64
- `clipToWidth` takes the truncation mark as a fourth argument (`'.'` by
65
- default, `'..'` where names are cut often enough to want the emphasis).
66
-
67
- Colours work the same way — `BASE_COLORS` holds what every app needs, and the
68
- app adds its own semantics:
69
-
70
- ```ts
71
- import { BASE_COLORS } from 'busybar-kit/colors';
72
-
73
- export const COLORS = {
74
- ...BASE_COLORS,
75
- radiant: '#3FBF5FFF',
76
- dire: '#E14B3AFF',
77
- } as const;
78
- ```
79
-
80
- ## Config
81
-
82
- `loadBarConfig` resolves which Bar to talk to and over which transport, and
83
- warns about credentials that transport will ignore. That is all it covers —
84
- render cadence and request timeouts are the app's own tempo (a speedrun timer
85
- wants 60ms frames where a match ticker is happy at 200), so they stay in the
86
- app's config with `DEFAULTS` / `LIMITS` as the values worth starting from.
87
-
88
- It hands back the same env reader, so an app's own settings collect their
89
- warnings into one list:
90
-
91
- ```ts
92
- import { loadBarConfig, loadEnvFile } from 'busybar-kit/config';
93
-
94
- loadEnvFile();
95
-
96
- export function loadConfig(env = process.env) {
97
- const warnings: string[] = [];
98
- const { bar, env: read } = loadBarConfig(env, warnings);
99
-
100
- return {
101
- warnings,
102
- config: {
103
- ...bar,
104
- gsiPort: read.number('GSI_PORT', 3080, { min: 1024, max: 65_535 }),
105
- },
106
- };
107
- }
108
- ```
109
-
110
- ## Pictures
111
-
112
- The back panel has 16 greys and quantises whatever you upload to them, so a
113
- photo sent as-is comes back in bands. `ditherToPanel` does the quantising here,
114
- with error diffusion, which leaves every pixel already sitting on a level:
115
-
116
- ```ts
117
- import { ditherToPanel } from 'busybar-kit/image';
118
-
119
- const cover = ditherToPanel(decodedRgba); // 80x80 Bitmap, panel-ready
120
- await bar.AssetsUpload({ application_name: 'app', file: 'art.png', data: cover.toPng() });
121
- ```
122
-
123
- It takes pixels, not files. Which image decoder to pay for is the app's
124
- choice — the kit stays dependency-free.
125
-
126
- `Bitmap.fromRgba` wraps pixels from anywhere, and `bitmap.blit(other, x, y)`
127
- draws one into another: the raster below cannot follow an image element's path,
128
- so a preview composites the picture itself.
129
-
130
- ## Preview
131
-
132
- `renderFront` / `renderBack` rasterise the very element list you send to the
133
- device — a 3×5 pixel font, the same anchoring rules, and the back panel
134
- flattened to the 16 greys the real OLED shows. Useful for screenshots in a
135
- README and for asserting a layout in tests without a Bar on the desk.
136
-
137
- ```ts
138
- import { renderFront } from 'busybar-kit/preview';
139
-
140
- writeFileSync('front.png', renderFront(frontElements(frame)).scale(8).toPng());
141
- ```
142
-
143
- ## Scripts
144
-
145
- ```bash
146
- npm run check # lint + typecheck + test
147
- npm run build # tsc + .js extension fixup
148
- ```
1
+ # busybar-kit
2
+
3
+ > [!IMPORTANT]
4
+ > **Unofficial community project.** Built and maintained by [@viirtualp1](https://github.com/viirtualp1), **not** an official Flipper Devices / BUSY product, and not affiliated with, endorsed by, or supported by them. "BUSY Bar" remains their trademark. For the real hardware and official apps, visit **[busy.app](https://busy.app/)**.
5
+
6
+ Shared building blocks for [BUSY Bar](https://busy.bar) apps. Everything here is
7
+ app-agnostic: screen geometry, the palette neutrals, formatters, ticker text,
8
+ the Bar's error taxonomy, the connection half of a config, and an offline PNG
9
+ renderer that draws the same elements the device does.
10
+
11
+ It is the common half of [busybar-dota](https://github.com/viirtualp1/busybar-dota),
12
+ [busybar-mydota](https://github.com/viirtualp1/busybar-mydota) and
13
+ [busybar-livesplit](https://github.com/viirtualp1/busybar-livesplit).
14
+
15
+ ```bash
16
+ npm install busybar-kit
17
+ ```
18
+
19
+ `@busy-app/busy-lib` is a peer dependency — the app owns that version.
20
+
21
+ ## What is in it
22
+
23
+ | Import | What it gives you |
24
+ | ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
25
+ | `busybar-kit/device` | `FRONT` / `BACK` screen sizes, `FONT_WIDTH`, `textWidth`, `clipToWidth`, `fittingChars`, `rowY`, `backRowGrid` |
26
+ | `busybar-kit/colors` | `BASE_COLORS` — neutrals, chrome, and the transparent value that hides an element |
27
+ | `busybar-kit/format` | `formatClock`, `formatGold`, `formatCountdown`, `formatStartTime`, `formatKda`, `formatPercent`, … |
28
+ | `busybar-kit/elements` | `AnyElement`, `band()`, `fillWidth()` |
29
+ | `busybar-kit/ticker` | `paginate` / `pageAt` / `scrollAt` / `tickerLine`, and `EventTicker` |
30
+ | `busybar-kit/errors` | `BarApiError`, `toBarError`, `isLowPriority`, `isClientError`, `isForbidden` |
31
+ | `busybar-kit/config` | `loadEnvFile`, `envReader`, `loadBarConfig` |
32
+ | `busybar-kit/image` | `ditherToPanel` — a picture, in the back panel's 16 greys |
33
+ | `busybar-kit/preview` | `renderFront` / `renderBack` / `Bitmap` — device-accurate PNGs with no hardware |
34
+ | `busybar-kit/tsconfig.json`, `busybar-kit/eslint`, `busybar-kit/prettier` | the shared toolchain |
35
+ | `busybar-fix-esm` (bin) | rewrites relative imports in `dist/` to carry `.js`, for Node ESM |
36
+
37
+ ## Geometry is hardware, not layout
38
+
39
+ `FRONT` (72×16) and `BACK` (160×80, with its header rows and 12px grid) are
40
+ facts about the device. An app spreads its own named slots on top:
41
+
42
+ ```ts
43
+ import { FRONT as DEVICE, BACK as DEVICE_BACK } from 'busybar-kit/device';
44
+
45
+ export const FRONT = {
46
+ ...DEVICE,
47
+ topY: 0,
48
+ bottomY: 11,
49
+ clockWidth: 24,
50
+ } as const;
51
+ ```
52
+
53
+ An app whose header is a different height lays out its own row grid, and reads
54
+ rows off that instead of the default:
55
+
56
+ ```ts
57
+ import { backRowGrid, rowY as gridRowY } from 'busybar-kit/device';
58
+
59
+ export const BACK = { ...DEVICE_BACK, ...backRowGrid(16), nameX: 8 } as const;
60
+
61
+ export const rowY = (index: number) => gridRowY(index, BACK);
62
+ ```
63
+
64
+ `clipToWidth` takes the truncation mark as a fourth argument (`'.'` by
65
+ default, `'..'` where names are cut often enough to want the emphasis).
66
+
67
+ Colours work the same way — `BASE_COLORS` holds what every app needs, and the
68
+ app adds its own semantics:
69
+
70
+ ```ts
71
+ import { BASE_COLORS } from 'busybar-kit/colors';
72
+
73
+ export const COLORS = {
74
+ ...BASE_COLORS,
75
+ radiant: '#3FBF5FFF',
76
+ dire: '#E14B3AFF',
77
+ } as const;
78
+ ```
79
+
80
+ ## Config
81
+
82
+ `loadBarConfig` resolves which Bar to talk to and over which transport, and
83
+ warns about credentials that transport will ignore. That is all it covers —
84
+ render cadence and request timeouts are the app's own tempo (a speedrun timer
85
+ wants 60ms frames where a match ticker is happy at 200), so they stay in the
86
+ app's config with `DEFAULTS` / `LIMITS` as the values worth starting from.
87
+
88
+ It hands back the same env reader, so an app's own settings collect their
89
+ warnings into one list:
90
+
91
+ ```ts
92
+ import { loadBarConfig, loadEnvFile } from 'busybar-kit/config';
93
+
94
+ loadEnvFile();
95
+
96
+ export function loadConfig(env = process.env) {
97
+ const warnings: string[] = [];
98
+ const { bar, env: read } = loadBarConfig(env, warnings);
99
+
100
+ return {
101
+ warnings,
102
+ config: {
103
+ ...bar,
104
+ gsiPort: read.number('GSI_PORT', 3080, { min: 1024, max: 65_535 }),
105
+ },
106
+ };
107
+ }
108
+ ```
109
+
110
+ ## Pictures
111
+
112
+ The back panel has 16 greys and quantises whatever you upload to them, so a
113
+ photo sent as-is comes back in bands. `ditherToPanel` does the quantising here,
114
+ with error diffusion, which leaves every pixel already sitting on a level:
115
+
116
+ ```ts
117
+ import { ditherToPanel } from 'busybar-kit/image';
118
+
119
+ const cover = ditherToPanel(decodedRgba); // 80x80 Bitmap, panel-ready
120
+ await bar.AssetsUpload({ application_name: 'app', file: 'art.png', data: cover.toPng() });
121
+ ```
122
+
123
+ It takes pixels, not files. Which image decoder to pay for is the app's
124
+ choice — the kit stays dependency-free.
125
+
126
+ `Bitmap.fromRgba` wraps pixels from anywhere, and `bitmap.blit(other, x, y)`
127
+ draws one into another: the raster below cannot follow an image element's path,
128
+ so a preview composites the picture itself.
129
+
130
+ ## Preview
131
+
132
+ `renderFront` / `renderBack` rasterise the very element list you send to the
133
+ device — a 3×5 pixel font, the same anchoring rules, and the back panel
134
+ flattened to the 16 greys the real OLED shows. Useful for screenshots in a
135
+ README and for asserting a layout in tests without a Bar on the desk.
136
+
137
+ ```ts
138
+ import { renderFront } from 'busybar-kit/preview';
139
+
140
+ writeFileSync('front.png', renderFront(frontElements(frame)).scale(8).toPng());
141
+ ```
142
+
143
+ ## Scripts
144
+
145
+ ```bash
146
+ npm run check # lint + typecheck + test
147
+ npm run build # tsc + .js extension fixup
148
+ ```
@@ -0,0 +1,81 @@
1
+ /**
2
+ * How an app describes its own settings, so something else can ask about them.
3
+ *
4
+ * The point is that the app owns the description. `busybar-flights` knows that
5
+ * a route reads `SVO-JFK` and that a departure is a local time without a zone;
6
+ * an editor that had to know those things for every app would need editing
7
+ * every time a new app appeared. With a spec, installing a package is enough.
8
+ *
9
+ * Specs are modules rather than JSON because the useful parts — how one entry
10
+ * reads in a picker, whether a value is acceptable — are functions. An app
11
+ * points at its own with a `busybar.config` field in package.json:
12
+ *
13
+ * "busybar": { "config": "./dist/config-spec.js" }
14
+ */
15
+ export type FieldType = 'text' | 'secret' | 'number' | 'boolean' | 'select' | 'datetime' | 'path';
16
+ export type FieldOption = {
17
+ value: string;
18
+ label: string;
19
+ hint?: string;
20
+ };
21
+ export type ConfigField = {
22
+ /** The env variable, or the key inside a record. */
23
+ key: string;
24
+ label: string;
25
+ type: FieldType;
26
+ /** One line under the question. */
27
+ hint?: string;
28
+ placeholder?: string;
29
+ /** Empty is not an answer. */
30
+ required?: boolean;
31
+ /** Kept behind "more settings": tuning nobody changes on a normal day. */
32
+ advanced?: boolean;
33
+ /** For `select`. */
34
+ options?: readonly FieldOption[];
35
+ /** What the app itself would do with the setting left out. */
36
+ fallback?: string;
37
+ /** A message when the answer will not do, or nothing when it will. */
38
+ validate?: (value: string) => string | undefined;
39
+ };
40
+ /** A file of records you add to, edit and remove — flights, fixtures. */
41
+ export type ListSection = {
42
+ kind: 'list';
43
+ /** Relative to the app's own config directory. */
44
+ file: string;
45
+ title: string;
46
+ /**
47
+ * The key holding the array, when the file is an object wrapped around one —
48
+ * dota's `schedule.json` keeps its matches under `matches`, beside settings
49
+ * that apply to the whole file. Left out, the file is the array itself.
50
+ */
51
+ at?: string;
52
+ /** Settings that live beside the array in the same file, not in `.env`. */
53
+ header?: ConfigField[];
54
+ /** What one entry looks like in the picker. */
55
+ summary: (entry: Record<string, string>) => string;
56
+ fields: ConfigField[];
57
+ /** Shown instead of the picker when the file has nothing in it yet. */
58
+ empty?: string;
59
+ };
60
+ /** Plain settings, one key to a line. */
61
+ export type EnvSection = {
62
+ kind: 'env';
63
+ file: string;
64
+ title: string;
65
+ fields: ConfigField[];
66
+ };
67
+ export type ConfigSection = ListSection | EnvSection;
68
+ export type AppConfigSpec = {
69
+ /** The `application_name` the app draws with — the join with everything else. */
70
+ name: string;
71
+ /** One line about what the app is, for the app picker. */
72
+ summary?: string;
73
+ sections: ConfigSection[];
74
+ };
75
+ /** Identity, for the types. Specs are data; this only saves the annotation. */
76
+ export declare function defineConfigSpec(spec: AppConfigSpec): AppConfigSpec;
77
+ export declare function required(label: string): (value: string) => string | undefined;
78
+ export declare function integerIn(min: number, max: number): (value: string) => string | undefined;
79
+ export declare function matching(pattern: RegExp, message: string): (value: string) => string | undefined;
80
+ export declare function localDateTime(value: string): "YYYY-MM-DD HH:MM" | "that is not a real date" | undefined;
81
+ //# sourceMappingURL=config-spec.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config-spec.d.ts","sourceRoot":"","sources":["../src/config-spec.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,MAAM,MAAM,SAAS,GACnB,MAAM,GAAG,QAAQ,GAAG,QAAQ,GAAG,SAAS,GAAG,QAAQ,GAAG,UAAU,GAAG,MAAM,CAAC;AAE5E,MAAM,MAAM,WAAW,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAE1E,MAAM,MAAM,WAAW,GAAG;IACxB,oDAAoD;IACpD,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,SAAS,CAAC;IAChB,mCAAmC;IACnC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,8BAA8B;IAC9B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,0EAA0E;IAC1E,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,oBAAoB;IACpB,OAAO,CAAC,EAAE,SAAS,WAAW,EAAE,CAAC;IACjC,8DAA8D;IAC9D,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,sEAAsE;IACtE,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAAC;CAClD,CAAC;AAEF,yEAAyE;AACzE,MAAM,MAAM,WAAW,GAAG;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,kDAAkD;IAClD,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,2EAA2E;IAC3E,MAAM,CAAC,EAAE,WAAW,EAAE,CAAC;IACvB,+CAA+C;IAC/C,OAAO,EAAE,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,MAAM,CAAC;IACnD,MAAM,EAAE,WAAW,EAAE,CAAC;IACtB,uEAAuE;IACvE,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,yCAAyC;AACzC,MAAM,MAAM,UAAU,GAAG;IACvB,IAAI,EAAE,KAAK,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,WAAW,EAAE,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG,WAAW,GAAG,UAAU,CAAC;AAErD,MAAM,MAAM,aAAa,GAAG;IAC1B,iFAAiF;IACjF,IAAI,EAAE,MAAM,CAAC;IACb,0DAA0D;IAC1D,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,aAAa,EAAE,CAAC;CAC3B,CAAC;AAEF,+EAA+E;AAC/E,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,aAAa,GAAG,aAAa,CAEnE;AAID,wBAAgB,QAAQ,CAAC,KAAK,EAAE,MAAM,IAC5B,OAAO,MAAM,wBACtB;AAED,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,IACxC,OAAO,MAAM,wBAWtB;AAED,wBAAgB,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,IAC/C,OAAO,MAAM,wBAEtB;AAQD,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,8DAW1C"}
@@ -0,0 +1,54 @@
1
+ /**
2
+ * How an app describes its own settings, so something else can ask about them.
3
+ *
4
+ * The point is that the app owns the description. `busybar-flights` knows that
5
+ * a route reads `SVO-JFK` and that a departure is a local time without a zone;
6
+ * an editor that had to know those things for every app would need editing
7
+ * every time a new app appeared. With a spec, installing a package is enough.
8
+ *
9
+ * Specs are modules rather than JSON because the useful parts — how one entry
10
+ * reads in a picker, whether a value is acceptable — are functions. An app
11
+ * points at its own with a `busybar.config` field in package.json:
12
+ *
13
+ * "busybar": { "config": "./dist/config-spec.js" }
14
+ */
15
+ /** Identity, for the types. Specs are data; this only saves the annotation. */
16
+ export function defineConfigSpec(spec) {
17
+ return spec;
18
+ }
19
+ // --- Validators the apps kept writing for themselves -------------------------
20
+ export function required(label) {
21
+ return (value) => (value.trim() ? undefined : `${label} is needed`);
22
+ }
23
+ export function integerIn(min, max) {
24
+ return (value) => {
25
+ if (!value.trim()) {
26
+ return undefined;
27
+ }
28
+ const parsed = Number(value);
29
+ if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
30
+ return 'a whole number, please';
31
+ }
32
+ return parsed < min || parsed > max ? `between ${min} and ${max}` : undefined;
33
+ };
34
+ }
35
+ export function matching(pattern, message) {
36
+ return (value) => !value.trim() || pattern.test(value.trim()) ? undefined : message;
37
+ }
38
+ /**
39
+ * `2026-09-08 19:30` — a local time at the airport, with no zone on it, which
40
+ * is how departure boards and boarding passes are written.
41
+ */
42
+ const LOCAL_DATETIME = /^\d{4}-\d{2}-\d{2}[T ]\d{1,2}:\d{2}$/;
43
+ export function localDateTime(value) {
44
+ if (!value.trim()) {
45
+ return undefined;
46
+ }
47
+ if (!LOCAL_DATETIME.test(value.trim())) {
48
+ return 'YYYY-MM-DD HH:MM';
49
+ }
50
+ return Number.isNaN(Date.parse(value.trim().replace(' ', 'T')))
51
+ ? 'that is not a real date'
52
+ : undefined;
53
+ }
54
+ //# sourceMappingURL=config-spec.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config-spec.js","sourceRoot":"","sources":["../src/config-spec.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAkEH,+EAA+E;AAC/E,MAAM,UAAU,gBAAgB,CAAC,IAAmB;IAClD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,gFAAgF;AAEhF,MAAM,UAAU,QAAQ,CAAC,KAAa;IACpC,OAAO,CAAC,KAAa,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,GAAG,KAAK,YAAY,CAAC,CAAC;AAC9E,CAAC;AAED,MAAM,UAAU,SAAS,CAAC,GAAW,EAAE,GAAW;IAChD,OAAO,CAAC,KAAa,EAAE,EAAE;QACvB,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,EAAE,CAAC;YAClB,OAAO,SAAS,CAAC;QACnB,CAAC;QACD,MAAM,MAAM,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC;QAC7B,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,MAAM,CAAC,EAAE,CAAC;YAC1D,OAAO,wBAAwB,CAAC;QAClC,CAAC;QAED,OAAO,MAAM,GAAG,GAAG,IAAI,MAAM,GAAG,GAAG,CAAC,CAAC,CAAC,WAAW,GAAG,QAAQ,GAAG,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC;IAChF,CAAC,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,QAAQ,CAAC,OAAe,EAAE,OAAe;IACvD,OAAO,CAAC,KAAa,EAAE,EAAE,CACvB,CAAC,KAAK,CAAC,IAAI,EAAE,IAAI,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,OAAO,CAAC;AACtE,CAAC;AAED;;;GAGG;AACH,MAAM,cAAc,GAAG,sCAAsC,CAAC;AAE9D,MAAM,UAAU,aAAa,CAAC,KAAa;IACzC,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,EAAE,CAAC;QAClB,OAAO,SAAS,CAAC;IACnB,CAAC;IACD,IAAI,CAAC,cAAc,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,EAAE,CAAC;QACvC,OAAO,kBAAkB,CAAC;IAC5B,CAAC;IAED,OAAO,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,OAAO,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,CAAC;QAC7D,CAAC,CAAC,yBAAyB;QAC3B,CAAC,CAAC,SAAS,CAAC;AAChB,CAAC"}
package/dist/index.d.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  export * from './colors.js';
2
2
  export * from './config/index.js';
3
+ export * from './config-spec.js';
3
4
  export * from './device.js';
4
5
  export * from './elements.js';
5
6
  export * from './errors.js';
6
7
  export * from './format.js';
7
8
  export * from './image/index.js';
8
9
  export * from './preview/index.js';
10
+ export * from './profile.js';
9
11
  export * from './ticker/index.js';
10
12
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC;AACzB,cAAc,gBAAgB,CAAC;AAC/B,cAAc,UAAU,CAAC;AACzB,cAAc,YAAY,CAAC;AAC3B,cAAc,UAAU,CAAC;AACzB,cAAc,UAAU,CAAC;AACzB,cAAc,eAAe,CAAC;AAC9B,cAAc,iBAAiB,CAAC;AAChC,cAAc,gBAAgB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC;AACzB,cAAc,gBAAgB,CAAC;AAC/B,cAAc,eAAe,CAAC;AAC9B,cAAc,UAAU,CAAC;AACzB,cAAc,YAAY,CAAC;AAC3B,cAAc,UAAU,CAAC;AACzB,cAAc,UAAU,CAAC;AACzB,cAAc,eAAe,CAAC;AAC9B,cAAc,iBAAiB,CAAC;AAChC,cAAc,WAAW,CAAC;AAC1B,cAAc,gBAAgB,CAAC"}
package/dist/index.js CHANGED
@@ -1,10 +1,12 @@
1
1
  export * from './colors.js';
2
2
  export * from './config/index.js';
3
+ export * from './config-spec.js';
3
4
  export * from './device.js';
4
5
  export * from './elements.js';
5
6
  export * from './errors.js';
6
7
  export * from './format.js';
7
8
  export * from './image/index.js';
8
9
  export * from './preview/index.js';
10
+ export * from './profile.js';
9
11
  export * from './ticker/index.js';
10
12
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC;AACzB,cAAc,gBAAgB,CAAC;AAC/B,cAAc,UAAU,CAAC;AACzB,cAAc,YAAY,CAAC;AAC3B,cAAc,UAAU,CAAC;AACzB,cAAc,UAAU,CAAC;AACzB,cAAc,eAAe,CAAC;AAC9B,cAAc,iBAAiB,CAAC;AAChC,cAAc,gBAAgB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC;AACzB,cAAc,gBAAgB,CAAC;AAC/B,cAAc,eAAe,CAAC;AAC9B,cAAc,UAAU,CAAC;AACzB,cAAc,YAAY,CAAC;AAC3B,cAAc,UAAU,CAAC;AACzB,cAAc,UAAU,CAAC;AACzB,cAAc,eAAe,CAAC;AAC9B,cAAc,iBAAiB,CAAC;AAChC,cAAc,WAAW,CAAC;AAC1B,cAAc,gBAAgB,CAAC"}
@@ -0,0 +1,38 @@
1
+ /**
2
+ * A profile is one directory that holds a whole BUSY Bar setup: the manifest,
3
+ * the apps themselves as installed packages, and a folder per app for its own
4
+ * `.env` and data files.
5
+ *
6
+ * ```
7
+ * ~/.busybar/
8
+ * wm.config.json
9
+ * node_modules/ the app packages, production dependencies only
10
+ * mydota/.env
11
+ * dota/.env dota/schedule.json
12
+ * ```
13
+ *
14
+ * Every app already reads its `.env` from its working directory, so pointing
15
+ * each one at its own folder here means a packaged app finds its settings in
16
+ * exactly the place a checked-out one always did — no change inside the apps.
17
+ */
18
+ export type ProfileResolver = {
19
+ /** The directory itself. */
20
+ dir: string;
21
+ /** Where an app keeps its `.env` and data files. */
22
+ cwdFor: (name: string) => string;
23
+ /** The bin an app package installed into this profile, if it is there. */
24
+ binFor: (name: string) => string | undefined;
25
+ };
26
+ export type ProfileOptions = {
27
+ /** Injected by the tests, which have no profile on disk. */
28
+ exists?: (path: string) => boolean;
29
+ platform?: NodeJS.Platform;
30
+ };
31
+ export declare function profileAt(dir: string, options?: ProfileOptions): ProfileResolver;
32
+ /**
33
+ * Makes sure every app has somewhere to keep its `.env`. Creating the folder
34
+ * up front is what lets a fresh profile be filled in by hand without having to
35
+ * guess the layout first.
36
+ */
37
+ export declare function ensureAppDirs(profile: ProfileResolver, names: readonly string[]): string[];
38
+ //# sourceMappingURL=profile.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"profile.d.ts","sourceRoot":"","sources":["../src/profile.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,MAAM,eAAe,GAAG;IAC5B,4BAA4B;IAC5B,GAAG,EAAE,MAAM,CAAC;IACZ,oDAAoD;IACpD,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;IACjC,0EAA0E;IAC1E,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAAC;CAC9C,CAAC;AAEF,MAAM,MAAM,cAAc,GAAG;IAC3B,4DAA4D;IAC5D,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC;IACnC,QAAQ,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAC;CAC5B,CAAC;AAEF,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,GAAE,cAAmB,GAAG,eAAe,CAqBpF;AAWD;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,eAAe,EAAE,KAAK,EAAE,SAAS,MAAM,EAAE,YAW/E"}
@@ -0,0 +1,47 @@
1
+ import { existsSync, mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ export function profileAt(dir, options = {}) {
4
+ const exists = options.exists ?? existsSync;
5
+ const platform = options.platform ?? process.platform;
6
+ // npm writes a `.cmd` shim on Windows; the supervisor already knows to run
7
+ // those through a shell, which is why the extension is kept here.
8
+ const suffix = platform === 'win32' ? '.cmd' : '';
9
+ return {
10
+ dir,
11
+ cwdFor: (name) => join(dir, name),
12
+ binFor: (name) => {
13
+ for (const candidate of binNames(name)) {
14
+ const bin = join(dir, 'node_modules', '.bin', `${candidate}${suffix}`);
15
+ if (exists(bin)) {
16
+ return bin;
17
+ }
18
+ }
19
+ return undefined;
20
+ },
21
+ };
22
+ }
23
+ /**
24
+ * An app's manifest name is its `application_name` — `mydota`, `flights` — but
25
+ * it ships as `busybar-mydota` and installs a bin by that name. Trying both is
26
+ * what lets a manifest entry be nothing more than the name on its own draws.
27
+ */
28
+ function binNames(name) {
29
+ return name.startsWith('busybar-') ? [name] : [name, `busybar-${name}`];
30
+ }
31
+ /**
32
+ * Makes sure every app has somewhere to keep its `.env`. Creating the folder
33
+ * up front is what lets a fresh profile be filled in by hand without having to
34
+ * guess the layout first.
35
+ */
36
+ export function ensureAppDirs(profile, names) {
37
+ const created = [];
38
+ for (const name of names) {
39
+ const dir = profile.cwdFor(name);
40
+ if (!existsSync(dir)) {
41
+ mkdirSync(dir, { recursive: true });
42
+ created.push(dir);
43
+ }
44
+ }
45
+ return created;
46
+ }
47
+ //# sourceMappingURL=profile.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"profile.js","sourceRoot":"","sources":["../src/profile.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AAChD,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAkCjC,MAAM,UAAU,SAAS,CAAC,GAAW,EAAE,UAA0B,EAAE;IACjE,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,IAAI,UAAU,CAAC;IAC5C,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,IAAI,OAAO,CAAC,QAAQ,CAAC;IACtD,2EAA2E;IAC3E,kEAAkE;IAClE,MAAM,MAAM,GAAG,QAAQ,KAAK,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC;IAElD,OAAO;QACL,GAAG;QACH,MAAM,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,EAAE,IAAI,CAAC;QACjC,MAAM,EAAE,CAAC,IAAI,EAAE,EAAE;YACf,KAAK,MAAM,SAAS,IAAI,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC;gBACvC,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,cAAc,EAAE,MAAM,EAAE,GAAG,SAAS,GAAG,MAAM,EAAE,CAAC,CAAC;gBACvE,IAAI,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC;oBAChB,OAAO,GAAG,CAAC;gBACb,CAAC;YACH,CAAC;YAED,OAAO,SAAS,CAAC;QACnB,CAAC;KACF,CAAC;AACJ,CAAC;AAED;;;;GAIG;AACH,SAAS,QAAQ,CAAC,IAAY;IAC5B,OAAO,IAAI,CAAC,UAAU,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,WAAW,IAAI,EAAE,CAAC,CAAC;AAC1E,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,aAAa,CAAC,OAAwB,EAAE,KAAwB;IAC9E,MAAM,OAAO,GAAa,EAAE,CAAC;IAC7B,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,MAAM,GAAG,GAAG,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;QACjC,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;YACrB,SAAS,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;YACpC,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QACpB,CAAC;IACH,CAAC;IAED,OAAO,OAAO,CAAC;AACjB,CAAC"}
package/package.json CHANGED
@@ -1,9 +1,13 @@
1
1
  {
2
2
  "name": "busybar-kit",
3
- "version": "0.2.1",
3
+ "version": "1.1.0",
4
4
  "description": "Shared building blocks for BUSY Bar apps: device geometry, colours, formatters, ticker text, error taxonomy and an offline PNG preview renderer",
5
5
  "author": "viirtualp1",
6
6
  "license": "MIT",
7
+ "bugs": {
8
+ "url": "https://github.com/viirtualp1/busybar-kit/issues"
9
+ },
10
+ "homepage": "https://github.com/viirtualp1/busybar-kit#readme",
7
11
  "type": "module",
8
12
  "repository": {
9
13
  "type": "git",
@@ -15,12 +19,14 @@
15
19
  ".": "./dist/index.js",
16
20
  "./colors": "./dist/colors.js",
17
21
  "./config": "./dist/config/index.js",
22
+ "./config-spec": "./dist/config-spec.js",
18
23
  "./device": "./dist/device.js",
19
24
  "./elements": "./dist/elements.js",
20
25
  "./errors": "./dist/errors.js",
21
26
  "./format": "./dist/format.js",
22
27
  "./image": "./dist/image/index.js",
23
28
  "./preview": "./dist/preview/index.js",
29
+ "./profile": "./dist/profile.js",
24
30
  "./ticker": "./dist/ticker/index.js",
25
31
  "./tsconfig.json": "./configs/tsconfig.base.json",
26
32
  "./eslint": "./configs/eslint.config.js",
@@ -34,7 +40,8 @@
34
40
  "dist",
35
41
  "configs",
36
42
  "scripts",
37
- "README.md"
43
+ "README.md",
44
+ "LICENSE"
38
45
  ],
39
46
  "keywords": [
40
47
  "busy-bar",
@@ -1,33 +1,33 @@
1
- #!/usr/bin/env node
2
- import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
3
- import { isAbsolute, join, resolve } from 'node:path';
4
-
5
- // Node's ESM loader wants a real file name; tsc leaves extensionless specifiers.
6
- const argument = process.argv[2] ?? 'dist';
7
- const dist = isAbsolute(argument) ? argument : resolve(process.cwd(), argument);
8
- const EXTENSIONS = /\.(?:js|json|mjs|cjs|node)$/;
9
-
10
- function walk(dir) {
11
- for (const name of readdirSync(dir)) {
12
- const path = join(dir, name);
13
- if (statSync(path).isDirectory()) {
14
- walk(path);
15
- continue;
16
- }
17
- if (!/\.(?:js|d\.ts)$/.test(name)) {
18
- continue;
19
- }
20
-
21
- const source = readFileSync(path, 'utf8');
22
- const next = source.replace(
23
- /(from\s+|import\s*\()(['"])(\.[^'"]+)\2/g,
24
- (match, prefix, quote, spec) =>
25
- EXTENSIONS.test(spec) ? match : `${prefix}${quote}${spec}.js${quote}`,
26
- );
27
- if (next !== source) {
28
- writeFileSync(path, next);
29
- }
30
- }
31
- }
32
-
33
- walk(dist);
1
+ #!/usr/bin/env node
2
+ import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
3
+ import { isAbsolute, join, resolve } from 'node:path';
4
+
5
+ // Node's ESM loader wants a real file name; tsc leaves extensionless specifiers.
6
+ const argument = process.argv[2] ?? 'dist';
7
+ const dist = isAbsolute(argument) ? argument : resolve(process.cwd(), argument);
8
+ const EXTENSIONS = /\.(?:js|json|mjs|cjs|node)$/;
9
+
10
+ function walk(dir) {
11
+ for (const name of readdirSync(dir)) {
12
+ const path = join(dir, name);
13
+ if (statSync(path).isDirectory()) {
14
+ walk(path);
15
+ continue;
16
+ }
17
+ if (!/\.(?:js|d\.ts)$/.test(name)) {
18
+ continue;
19
+ }
20
+
21
+ const source = readFileSync(path, 'utf8');
22
+ const next = source.replace(
23
+ /(from\s+|import\s*\()(['"])(\.[^'"]+)\2/g,
24
+ (match, prefix, quote, spec) =>
25
+ EXTENSIONS.test(spec) ? match : `${prefix}${quote}${spec}.js${quote}`,
26
+ );
27
+ if (next !== source) {
28
+ writeFileSync(path, next);
29
+ }
30
+ }
31
+ }
32
+
33
+ walk(dist);