@protoyard/core 0.0.0-stage → 1.1.1

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 Dailybot, Inc.
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,3 +1,62 @@
1
- # Temporary Holding Version
1
+ # @protoyard/core
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Framework-agnostic contracts for Protoyard: zod schemas, TypeScript
4
+ interfaces, and the small deterministic primitives every other package builds
5
+ on. Its only runtime dependency is `zod`; Faker is imported for types only.
6
+ Nothing here imports Svelte, Dexie or Node APIs.
7
+
8
+ ## Modules
9
+
10
+ | File | What it defines |
11
+ | --- | --- |
12
+ | `manifest.ts` | `PrototypeManifestSchema` for `protoyard.json` (id, slug, kind, platform, `preview` (viewport, `shell`, `device`, `orientation`, `chrome` with fictional-only origins, optional `layout`), storage namespace + schemaVersion, build flags, scenario ids), `ShellSchema`, `resolveShell` (`auto` → `phone` for mobile, else `none`), the optional marketplace `catalog` block (`CatalogSchema`, `CATALOG_CATEGORIES`), `parseManifest`, `MANIFEST_FILENAME` |
13
+ | `slug.ts` | `checkSlug` / `isValidSlug` / `slugify`, reserved slugs. This is the path-safety gate for the CLI. |
14
+ | `prototype.ts` | `definePrototype` and `PrototypeDefinition` (manifest, `DatabaseDefinition` versions/migrations, scenarios, `generate`, optional context-free `generateWorld(scenario)` used for scenario loads, `generatorVersion`, `policy`, `integrity`, `themes`, `timeline`, `applyEvent`), `WorldSeed`, `GenerateContext`, `RESERVED_TABLES` |
15
+ | `scenario.ts` | `ScenarioSchema` and its independent axes (organization, actor, density, activity, environment, appearance, generator knobs), `defineScenario`, `parseScenarioLink` / `buildScenarioLink` for safe reproducible links (including `?shell=` and `?layout=`) |
16
+ | `manifest.ts` | `PrototypeManifestSchema` for `protoyard.json` (id, slug, kind, platform, `preview` (viewport, `shell`, `device`, `orientation`, `chrome` with fictional-only origins), storage namespace + schemaVersion, build flags including the optional `build.review` (`ReviewBuildSchema`, `resolveReviewBuild`: adapter `local`/`cloudflare`/`http`/`artifact`, safe endpoint, Cloudflare store `kv`/`d1`; ADR-025), scenario ids), `ShellSchema`, `resolveShell` (`auto` → `phone` for mobile, else `none`), the optional marketplace `catalog` block (`CatalogSchema`, `CATALOG_CATEGORIES`), `parseManifest`, `MANIFEST_FILENAME` |
17
+ | `scenario.ts` | `ScenarioSchema` and its independent axes (organization, actor, density, activity, environment, appearance, generator knobs), `defineScenario`, `parseScenarioLink` / `buildScenarioLink` for safe reproducible links (including `?shell=`, and `review` from `?comments=0/1` and `?review=<key>`, `REVIEW_KEY_PATTERN`) |
18
+ | `session.ts` | `Organization`, `Persona` (optional `avatar`: an `av-NNN` library id, an `https://` URL or a root-relative path, checked by `isPersonaAvatar` / `PersonaAvatarSchema`), `Membership`, capability-based `createPermissionPolicy` (wildcards, `-denials`, record rules), `PermissionDeniedError`, `resolvePrincipal` |
19
+ | `clock.ts` | `createSimulationClock` (preview vs committed time), `createTimeHelpers`, `Duration` |
20
+ | `time.ts` | `nextMonotonicIso(lastIso, now)` — ISO timestamps that advance by at least 1 ms so rows sorted by `at` keep insertion order while the simulated clock is paused |
21
+ | `environment.ts` | `createSimulationEnvironment` (network, latency, reproducible failure modes with optional `failureScope`, subscription, flags), `EnvironmentPatchSchema` (partial **without** defaults), `SimulatedServiceError` |
22
+ | `random.ts` | Seeded `createRng` (mulberry32) with `fork`, deterministic `createIdFactory`, `createRuntimeId` for user-created rows |
23
+ | `events.ts` | `DomainEvent` envelope and deterministic `eventId()` |
24
+ | `overrides.ts` | `OverridesSchema`, `resolveEffective` and the value sources (override › preference › scenario › default) |
25
+ | `snapshot.ts` | `SnapshotSchema`, `validateSnapshot` and `parseSnapshotText` (size limit, wrong-prototype rejection, schema-version checks). These never throw on untrusted input. |
26
+ | `devices.ts` | Brand-neutral device catalog (`DEVICES`, `DeviceSpecSchema`: CSS size, corner radius, portrait/landscape safe areas, status bar, home indicator, cutout), `getDevice`, `rotate`, `orient`, `safeAreaOf`, `fitScale`. Data only, no DOM. |
27
+ | `layouts.ts` | App layout contract (ADR-023): `LAYOUT_IDS` / `LayoutIdSchema` (`custom` is the default: the prototype renders its own), `LayoutSettingsSchema` for `preview.layout` (`{ id, supported?, options? }`), the `LAYOUTS` catalog (label, platforms, `minWidth`, `fallback`), `getLayout`, `layoutChoices`, `layoutForWidth`. Data only, no DOM. |
28
+ | `theme.ts` | `ThemeSchema`, the token palette, the optional `style` profile (`ThemeStyleSchema`, `DEFAULT_THEME_STYLE`, `resolveThemeStyle`: typography, shape, elevation, surface, motion, focus ring), `defineTheme`, `tokenToCssVar` (`--tx-*`) |
29
+ | `validation.ts` | `ValidationError` (`issues`: field path → first message, `form` for root issues) and `parseInput(schema, input)` for service inputs; `runService` in `@protoyard/renderer-svelte` leaves these errors to the form |
30
+ | `review.ts` | Subpath `@protoyard/core/review` only (not in the barrel, so builds without review pay nothing): `ReviewCommentSchema` (`formatVersion` 1, plain-text body 1–2,000 characters, self-declared author 1–60 characters, wall-clock ISO timestamps), `ReviewAnchorSchema`, `ReviewContextSchema`, `ReviewThreadSchema`, `groupReviewThreads`, `safeParseReviewComment`, `REVIEW_LIMITS`. Comments are untrusted text: control and bidi-override characters are rejected, and nothing here renders markup. |
31
+ | `runtime.ts` | `PrototypeRuntime` and its controllers (scenarios, session, overrides, theme, snapshots, data inspector), implemented by `@protoyard/runtime` |
32
+
33
+ ## Example
34
+
35
+ ```ts
36
+ import { createPermissionPolicy, createRng, createSimulationClock } from '@protoyard/core';
37
+
38
+ const rng = createRng(42);
39
+ rng.int(1, 6); // same value on every machine for seed 42
40
+
41
+ const clock = createSimulationClock({ referenceTime: '2026-10-16T10:00:00Z' });
42
+ clock.preview({ days: 7 }); // UI-only; data is untouched
43
+ clock.clearPreview();
44
+
45
+ const policy = createPermissionPolicy({
46
+ roles: { admin: ['*', '-billing:manage'], viewer: ['deals:read'] },
47
+ });
48
+ ```
49
+
50
+ ## Rules for changes
51
+
52
+ - Additive changes only, unless every consumer is updated in the same change.
53
+ Contracts are shared by all packages and prototypes.
54
+ - Erasable TypeScript only (`erasableSyntaxOnly`): no parameter properties,
55
+ enums or namespaces. Vite loads configs through Node's type stripping.
56
+ - Tests live in `tests/core.test.ts` (one file since PLAN_009). Run them with
57
+ `pnpm vitest run packages/core` and type-check with
58
+ `pnpm --filter @protoyard/core typecheck`.
59
+
60
+ ## Package output
61
+
62
+ The manifest keeps TypeScript source exports for workspace development. `pnpm build:packages --filter engine` creates the ESM and declaration files under `dist/` that the packed manifest exposes.
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The simulation clock. Prototype code reads "now" from here, never from
3
+ * `Date.now()`, so scenarios render the same world on every machine.
4
+ *
5
+ * Two distinct operations (docs/SCENARIOS.md → "Time"):
6
+ * - **Preview** (`setTime`, `previewOffset`) only moves the clock the UI sees.
7
+ * No data changes.
8
+ * - **Advance** is a scenario-controller operation that ALSO commits
9
+ * deterministic timeline events up to the new instant (idempotent through
10
+ * event ids + a checkpoint). The clock itself only records the instant.
11
+ */
12
+ export declare const MS_PER_MINUTE = 60000;
13
+ export declare const MS_PER_HOUR = 3600000;
14
+ export declare const MS_PER_DAY = 86400000;
15
+ export interface Duration {
16
+ days?: number;
17
+ hours?: number;
18
+ minutes?: number;
19
+ seconds?: number;
20
+ }
21
+ export declare function durationToMs(duration: Duration): number;
22
+ export interface ClockState {
23
+ /** ISO instant the scenario was generated against. */
24
+ referenceTime: string;
25
+ /** Committed offset from the reference (moves with `advance`). */
26
+ committedOffsetMs: number;
27
+ /** Extra preview-only offset on top of the committed instant. */
28
+ previewOffsetMs: number;
29
+ /** When true, `now()` follows wall-clock time from the effective instant. */
30
+ running: boolean;
31
+ /** Wall-clock ms when `running` was last enabled (internal). */
32
+ runningSince?: number;
33
+ }
34
+ export interface SimulationClock {
35
+ /** The effective simulated instant. */
36
+ now(): Date;
37
+ nowMs(): number;
38
+ /** Scenario reference instant (time zero of the seeded world). */
39
+ reference(): Date;
40
+ /** The committed instant (reference + committed offset), without preview. */
41
+ committed(): Date;
42
+ /** True when a preview offset is active. */
43
+ isPreviewing(): boolean;
44
+ /** Preview-only: shows the UI at `date` without touching data. */
45
+ setTime(date: Date | string): void;
46
+ /** Preview-only: shifts the preview by a duration. */
47
+ preview(duration: Duration): void;
48
+ /** Drops the preview offset; back to the committed instant. */
49
+ clearPreview(): void;
50
+ /** Records a committed advance. Callers commit timeline events first. */
51
+ commitAdvance(duration: Duration): void;
52
+ /** Resets to the scenario reference time (no preview, no committed offset). */
53
+ resetClock(): void;
54
+ /** Helpers relative to the effective instant. */
55
+ daysAgo(days: number): Date;
56
+ daysFromNow(days: number): Date;
57
+ getState(): ClockState;
58
+ setState(state: ClockState): void;
59
+ subscribe(listener: (state: ClockState) => void): () => void;
60
+ }
61
+ export declare function createSimulationClock(initial: Pick<ClockState, 'referenceTime'> & Partial<ClockState>, wallClock?: () => number): SimulationClock;
62
+ /** Time helpers bound to a fixed instant — used by seed generators. */
63
+ export interface TimeHelpers {
64
+ /** The scenario reference instant. */
65
+ now: Date;
66
+ daysAgo(days: number, jitterHours?: number): Date;
67
+ hoursAgo(hours: number): Date;
68
+ daysFromNow(days: number): Date;
69
+ /** Uniformly random instant between two dates (needs an rng). */
70
+ between(from: Date, to: Date, next: () => number): Date;
71
+ iso(date: Date): string;
72
+ }
73
+ export declare function createTimeHelpers(referenceTime: string | Date): TimeHelpers;
package/dist/clock.js ADDED
@@ -0,0 +1,105 @@
1
+ /**
2
+ * The simulation clock. Prototype code reads "now" from here, never from
3
+ * `Date.now()`, so scenarios render the same world on every machine.
4
+ *
5
+ * Two distinct operations (docs/SCENARIOS.md → "Time"):
6
+ * - **Preview** (`setTime`, `previewOffset`) only moves the clock the UI sees.
7
+ * No data changes.
8
+ * - **Advance** is a scenario-controller operation that ALSO commits
9
+ * deterministic timeline events up to the new instant (idempotent through
10
+ * event ids + a checkpoint). The clock itself only records the instant.
11
+ */
12
+ export const MS_PER_MINUTE = 60_000;
13
+ export const MS_PER_HOUR = 3_600_000;
14
+ export const MS_PER_DAY = 86_400_000;
15
+ export function durationToMs(duration) {
16
+ return ((duration.days ?? 0) * MS_PER_DAY +
17
+ (duration.hours ?? 0) * MS_PER_HOUR +
18
+ (duration.minutes ?? 0) * MS_PER_MINUTE +
19
+ (duration.seconds ?? 0) * 1000);
20
+ }
21
+ export function createSimulationClock(initial, wallClock = () => Date.now()) {
22
+ const referenceMs = Date.parse(initial.referenceTime);
23
+ if (Number.isNaN(referenceMs)) {
24
+ throw new RangeError(`Invalid referenceTime: ${initial.referenceTime}`);
25
+ }
26
+ let state = {
27
+ referenceTime: initial.referenceTime,
28
+ committedOffsetMs: initial.committedOffsetMs ?? 0,
29
+ previewOffsetMs: initial.previewOffsetMs ?? 0,
30
+ running: initial.running ?? false,
31
+ ...(initial.runningSince !== undefined ? { runningSince: initial.runningSince } : {}),
32
+ };
33
+ const listeners = new Set();
34
+ const emit = () => {
35
+ for (const listener of listeners)
36
+ listener(state);
37
+ };
38
+ const ref = () => Date.parse(state.referenceTime);
39
+ const committedMs = () => ref() + state.committedOffsetMs;
40
+ const effectiveMs = () => {
41
+ const drift = state.running && state.runningSince !== undefined ? wallClock() - state.runningSince : 0;
42
+ return committedMs() + state.previewOffsetMs + drift;
43
+ };
44
+ return {
45
+ now: () => new Date(effectiveMs()),
46
+ nowMs: effectiveMs,
47
+ reference: () => new Date(ref()),
48
+ committed: () => new Date(committedMs()),
49
+ isPreviewing: () => state.previewOffsetMs !== 0,
50
+ setTime(date) {
51
+ const target = typeof date === 'string' ? Date.parse(date) : date.getTime();
52
+ if (Number.isNaN(target))
53
+ throw new RangeError('setTime(): invalid date');
54
+ state = { ...state, previewOffsetMs: target - committedMs() };
55
+ emit();
56
+ },
57
+ preview(duration) {
58
+ state = { ...state, previewOffsetMs: state.previewOffsetMs + durationToMs(duration) };
59
+ emit();
60
+ },
61
+ clearPreview() {
62
+ state = { ...state, previewOffsetMs: 0 };
63
+ emit();
64
+ },
65
+ commitAdvance(duration) {
66
+ const ms = durationToMs(duration);
67
+ if (ms < 0)
68
+ throw new RangeError('commitAdvance(): time only moves forward');
69
+ state = { ...state, committedOffsetMs: state.committedOffsetMs + ms };
70
+ emit();
71
+ },
72
+ resetClock() {
73
+ state = {
74
+ referenceTime: state.referenceTime,
75
+ committedOffsetMs: 0,
76
+ previewOffsetMs: 0,
77
+ running: false,
78
+ };
79
+ emit();
80
+ },
81
+ daysAgo: (days) => new Date(effectiveMs() - days * MS_PER_DAY),
82
+ daysFromNow: (days) => new Date(effectiveMs() + days * MS_PER_DAY),
83
+ getState: () => ({ ...state }),
84
+ setState(next) {
85
+ state = { ...next };
86
+ emit();
87
+ },
88
+ subscribe(listener) {
89
+ listeners.add(listener);
90
+ return () => listeners.delete(listener);
91
+ },
92
+ };
93
+ }
94
+ export function createTimeHelpers(referenceTime) {
95
+ const ref = typeof referenceTime === 'string' ? new Date(referenceTime) : referenceTime;
96
+ const base = ref.getTime();
97
+ return {
98
+ now: new Date(base),
99
+ daysAgo: (days, jitterHours = 0) => new Date(base - days * MS_PER_DAY - jitterHours * MS_PER_HOUR),
100
+ hoursAgo: (hours) => new Date(base - hours * MS_PER_HOUR),
101
+ daysFromNow: (days) => new Date(base + days * MS_PER_DAY),
102
+ between: (from, to, next) => new Date(from.getTime() + next() * (to.getTime() - from.getTime())),
103
+ iso: (date) => date.toISOString(),
104
+ };
105
+ }
@@ -0,0 +1,138 @@
1
+ import { z } from 'zod';
2
+ import type { ResolvedShell, ViewportPreset } from './manifest.ts';
3
+ /**
4
+ * Brand-neutral device catalog: the single source of truth for device frames
5
+ * (renderer shells), the DevTools device picker and the gallery preview.
6
+ *
7
+ * Entries are data only (no DOM, no framework), so the gallery can reuse them
8
+ * while depending only on `@protoyard/core`. Labels describe a form factor
9
+ * ("Phone (pill cutout)"), never a real device model, vendor or OS mark.
10
+ * Sizes are CSS pixels; insets are the safe-area insets a frame feeds into
11
+ * `--py-safe-*` for the given orientation.
12
+ */
13
+ export declare const DeviceKindSchema: z.ZodEnum<{
14
+ desktop: "desktop";
15
+ tablet: "tablet";
16
+ phone: "phone";
17
+ laptop: "laptop";
18
+ }>;
19
+ export type DeviceKind = z.infer<typeof DeviceKindSchema>;
20
+ export declare const OrientationSchema: z.ZodEnum<{
21
+ portrait: "portrait";
22
+ landscape: "landscape";
23
+ }>;
24
+ export type Orientation = z.infer<typeof OrientationSchema>;
25
+ /** Camera cutout drawn by phone and tablet frames. */
26
+ export declare const CutoutStyleSchema: z.ZodEnum<{
27
+ none: "none";
28
+ pill: "pill";
29
+ "punch-hole": "punch-hole";
30
+ notch: "notch";
31
+ }>;
32
+ export type CutoutStyle = z.infer<typeof CutoutStyleSchema>;
33
+ /**
34
+ * `split`: clock and indicators sit left and right of a central cutout.
35
+ * `standard`: clock left, indicators right, no gap. `none`: no status bar.
36
+ */
37
+ export declare const StatusBarStyleSchema: z.ZodEnum<{
38
+ none: "none";
39
+ split: "split";
40
+ standard: "standard";
41
+ }>;
42
+ export type StatusBarStyle = z.infer<typeof StatusBarStyleSchema>;
43
+ export declare const SafeAreaInsetsSchema: z.ZodObject<{
44
+ top: z.ZodNumber;
45
+ right: z.ZodNumber;
46
+ bottom: z.ZodNumber;
47
+ left: z.ZodNumber;
48
+ }, z.core.$strict>;
49
+ export type SafeAreaInsets = z.infer<typeof SafeAreaInsetsSchema>;
50
+ export declare const DEVICE_IDS: readonly ["phone-pill", "phone-punch-hole", "tablet", "laptop-window", "desktop-window"];
51
+ export declare const DeviceIdSchema: z.ZodEnum<{
52
+ tablet: "tablet";
53
+ "desktop-window": "desktop-window";
54
+ "phone-pill": "phone-pill";
55
+ "phone-punch-hole": "phone-punch-hole";
56
+ "laptop-window": "laptop-window";
57
+ }>;
58
+ export type DeviceId = z.infer<typeof DeviceIdSchema>;
59
+ export declare const DeviceSpecSchema: z.ZodObject<{
60
+ id: z.ZodEnum<{
61
+ tablet: "tablet";
62
+ "desktop-window": "desktop-window";
63
+ "phone-pill": "phone-pill";
64
+ "phone-punch-hole": "phone-punch-hole";
65
+ "laptop-window": "laptop-window";
66
+ }>;
67
+ label: z.ZodString;
68
+ kind: z.ZodEnum<{
69
+ desktop: "desktop";
70
+ tablet: "tablet";
71
+ phone: "phone";
72
+ laptop: "laptop";
73
+ }>;
74
+ orientation: z.ZodEnum<{
75
+ portrait: "portrait";
76
+ landscape: "landscape";
77
+ }>;
78
+ rotatable: z.ZodBoolean;
79
+ width: z.ZodNumber;
80
+ height: z.ZodNumber;
81
+ cornerRadius: z.ZodNumber;
82
+ safeArea: z.ZodObject<{
83
+ portrait: z.ZodObject<{
84
+ top: z.ZodNumber;
85
+ right: z.ZodNumber;
86
+ bottom: z.ZodNumber;
87
+ left: z.ZodNumber;
88
+ }, z.core.$strict>;
89
+ landscape: z.ZodObject<{
90
+ top: z.ZodNumber;
91
+ right: z.ZodNumber;
92
+ bottom: z.ZodNumber;
93
+ left: z.ZodNumber;
94
+ }, z.core.$strict>;
95
+ }, z.core.$strict>;
96
+ statusBar: z.ZodObject<{
97
+ height: z.ZodNumber;
98
+ style: z.ZodEnum<{
99
+ none: "none";
100
+ split: "split";
101
+ standard: "standard";
102
+ }>;
103
+ }, z.core.$strict>;
104
+ homeIndicator: z.ZodObject<{
105
+ height: z.ZodNumber;
106
+ }, z.core.$strict>;
107
+ cutout: z.ZodEnum<{
108
+ none: "none";
109
+ pill: "pill";
110
+ "punch-hole": "punch-hole";
111
+ notch: "notch";
112
+ }>;
113
+ }, z.core.$strict>;
114
+ export type DeviceSpec = z.infer<typeof DeviceSpecSchema>;
115
+ /** Every catalog entry, in picker order. Deeply frozen. */
116
+ export declare const DEVICES: readonly DeviceSpec[];
117
+ /** Catalog entry by id, or `undefined` for unknown (e.g. untrusted) ids. */
118
+ export declare function getDevice(id: string): DeviceSpec | undefined;
119
+ /** Safe-area insets for the spec's current orientation. */
120
+ export declare function safeAreaOf(spec: DeviceSpec): SafeAreaInsets;
121
+ /**
122
+ * Turns a device a quarter turn: swaps `width`/`height` and flips the
123
+ * orientation, so `safeAreaOf()` returns the other orientation's insets.
124
+ * Non-rotatable devices (windows) are returned unchanged. Never mutates.
125
+ */
126
+ export declare function rotate(spec: DeviceSpec): DeviceSpec;
127
+ /** The spec in the requested orientation (unchanged when it cannot rotate). */
128
+ export declare function orient(spec: DeviceSpec, orientation: Orientation): DeviceSpec;
129
+ /** Scale that fits a frame of `width` into `available` pixels; never upscales. */
130
+ export declare function fitScale(available: number, width: number): number;
131
+ /** Whether `deviceId` is a catalog device the shell can frame. */
132
+ export declare function deviceFitsShell(deviceId: string, shell: ResolvedShell): boolean;
133
+ /**
134
+ * The device a shell uses when nothing chose one. The legacy viewport preset
135
+ * picks the phone style (`android` → punch-hole) and maps `none` to the closest
136
+ * form factor. Always returns a device that fits the shell.
137
+ */
138
+ export declare function defaultDeviceFor(shell: ResolvedShell, viewport: ViewportPreset): DeviceId;
@@ -0,0 +1,212 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * Brand-neutral device catalog: the single source of truth for device frames
4
+ * (renderer shells), the DevTools device picker and the gallery preview.
5
+ *
6
+ * Entries are data only (no DOM, no framework), so the gallery can reuse them
7
+ * while depending only on `@protoyard/core`. Labels describe a form factor
8
+ * ("Phone (pill cutout)"), never a real device model, vendor or OS mark.
9
+ * Sizes are CSS pixels; insets are the safe-area insets a frame feeds into
10
+ * `--py-safe-*` for the given orientation.
11
+ */
12
+ export const DeviceKindSchema = z.enum(['phone', 'tablet', 'laptop', 'desktop']);
13
+ export const OrientationSchema = z.enum(['portrait', 'landscape']);
14
+ /** Camera cutout drawn by phone and tablet frames. */
15
+ export const CutoutStyleSchema = z.enum(['none', 'pill', 'punch-hole', 'notch']);
16
+ /**
17
+ * `split`: clock and indicators sit left and right of a central cutout.
18
+ * `standard`: clock left, indicators right, no gap. `none`: no status bar.
19
+ */
20
+ export const StatusBarStyleSchema = z.enum(['split', 'standard', 'none']);
21
+ const InsetSchema = z.number().int().nonnegative();
22
+ export const SafeAreaInsetsSchema = z
23
+ .object({ top: InsetSchema, right: InsetSchema, bottom: InsetSchema, left: InsetSchema })
24
+ .strict();
25
+ export const DEVICE_IDS = [
26
+ 'phone-pill',
27
+ 'phone-punch-hole',
28
+ 'tablet',
29
+ 'laptop-window',
30
+ 'desktop-window',
31
+ ];
32
+ export const DeviceIdSchema = z.enum(DEVICE_IDS);
33
+ export const DeviceSpecSchema = z
34
+ .object({
35
+ id: DeviceIdSchema,
36
+ label: z.string().min(1).max(40),
37
+ kind: DeviceKindSchema,
38
+ /** Orientation `width`/`height` describe; `rotate()` flips it. */
39
+ orientation: OrientationSchema,
40
+ /** Windows (laptop, desktop) do not rotate. */
41
+ rotatable: z.boolean(),
42
+ width: z.number().int().positive(),
43
+ height: z.number().int().positive(),
44
+ cornerRadius: z.number().int().nonnegative(),
45
+ safeArea: z
46
+ .object({ portrait: SafeAreaInsetsSchema, landscape: SafeAreaInsetsSchema })
47
+ .strict(),
48
+ statusBar: z.object({ height: InsetSchema, style: StatusBarStyleSchema }).strict(),
49
+ homeIndicator: z.object({ height: InsetSchema }).strict(),
50
+ cutout: CutoutStyleSchema,
51
+ })
52
+ .strict();
53
+ const NO_INSETS = { top: 0, right: 0, bottom: 0, left: 0 };
54
+ function freeze(spec) {
55
+ Object.freeze(spec.safeArea.portrait);
56
+ Object.freeze(spec.safeArea.landscape);
57
+ Object.freeze(spec.safeArea);
58
+ Object.freeze(spec.statusBar);
59
+ Object.freeze(spec.homeIndicator);
60
+ return Object.freeze(spec);
61
+ }
62
+ /** Every catalog entry, in picker order. Deeply frozen. */
63
+ export const DEVICES = Object.freeze([
64
+ {
65
+ id: 'phone-pill',
66
+ label: 'Phone (pill cutout)',
67
+ kind: 'phone',
68
+ orientation: 'portrait',
69
+ rotatable: true,
70
+ width: 393,
71
+ height: 852,
72
+ cornerRadius: 55,
73
+ safeArea: {
74
+ portrait: { top: 59, right: 0, bottom: 34, left: 0 },
75
+ landscape: { top: 0, right: 59, bottom: 21, left: 59 },
76
+ },
77
+ statusBar: { height: 54, style: 'split' },
78
+ homeIndicator: { height: 34 },
79
+ cutout: 'pill',
80
+ },
81
+ {
82
+ id: 'phone-punch-hole',
83
+ label: 'Phone (punch-hole)',
84
+ kind: 'phone',
85
+ orientation: 'portrait',
86
+ rotatable: true,
87
+ width: 412,
88
+ height: 915,
89
+ cornerRadius: 36,
90
+ safeArea: {
91
+ portrait: { top: 32, right: 0, bottom: 24, left: 0 },
92
+ landscape: { top: 0, right: 0, bottom: 24, left: 32 },
93
+ },
94
+ statusBar: { height: 32, style: 'standard' },
95
+ homeIndicator: { height: 24 },
96
+ cutout: 'punch-hole',
97
+ },
98
+ {
99
+ id: 'tablet',
100
+ label: 'Tablet',
101
+ kind: 'tablet',
102
+ orientation: 'portrait',
103
+ rotatable: true,
104
+ width: 820,
105
+ height: 1180,
106
+ cornerRadius: 18,
107
+ safeArea: {
108
+ portrait: { top: 24, right: 0, bottom: 20, left: 0 },
109
+ landscape: { top: 24, right: 0, bottom: 20, left: 0 },
110
+ },
111
+ statusBar: { height: 24, style: 'standard' },
112
+ homeIndicator: { height: 20 },
113
+ cutout: 'none',
114
+ },
115
+ {
116
+ id: 'laptop-window',
117
+ label: 'Laptop window',
118
+ kind: 'laptop',
119
+ orientation: 'landscape',
120
+ rotatable: false,
121
+ width: 1280,
122
+ height: 800,
123
+ cornerRadius: 10,
124
+ safeArea: { portrait: { ...NO_INSETS }, landscape: { ...NO_INSETS } },
125
+ statusBar: { height: 0, style: 'none' },
126
+ homeIndicator: { height: 0 },
127
+ cutout: 'none',
128
+ },
129
+ {
130
+ id: 'desktop-window',
131
+ label: 'Desktop window',
132
+ kind: 'desktop',
133
+ orientation: 'landscape',
134
+ rotatable: false,
135
+ width: 1440,
136
+ height: 900,
137
+ cornerRadius: 10,
138
+ safeArea: { portrait: { ...NO_INSETS }, landscape: { ...NO_INSETS } },
139
+ statusBar: { height: 0, style: 'none' },
140
+ homeIndicator: { height: 0 },
141
+ cutout: 'none',
142
+ },
143
+ ].map(freeze));
144
+ /** Catalog entry by id, or `undefined` for unknown (e.g. untrusted) ids. */
145
+ export function getDevice(id) {
146
+ return DEVICES.find((device) => device.id === id);
147
+ }
148
+ /** Safe-area insets for the spec's current orientation. */
149
+ export function safeAreaOf(spec) {
150
+ return spec.safeArea[spec.orientation];
151
+ }
152
+ /**
153
+ * Turns a device a quarter turn: swaps `width`/`height` and flips the
154
+ * orientation, so `safeAreaOf()` returns the other orientation's insets.
155
+ * Non-rotatable devices (windows) are returned unchanged. Never mutates.
156
+ */
157
+ export function rotate(spec) {
158
+ if (!spec.rotatable)
159
+ return spec;
160
+ return freeze({
161
+ ...spec,
162
+ orientation: spec.orientation === 'portrait' ? 'landscape' : 'portrait',
163
+ width: spec.height,
164
+ height: spec.width,
165
+ });
166
+ }
167
+ /** The spec in the requested orientation (unchanged when it cannot rotate). */
168
+ export function orient(spec, orientation) {
169
+ return spec.orientation === orientation ? spec : rotate(spec);
170
+ }
171
+ /** Scale that fits a frame of `width` into `available` pixels; never upscales. */
172
+ export function fitScale(available, width) {
173
+ if (!(available > 0) || !(width > 0))
174
+ return 1;
175
+ return Math.min(1, available / width);
176
+ }
177
+ /** Device kinds each shell can frame. `none` renders full-bleed, so any device fits. */
178
+ const SHELL_DEVICE_KINDS = {
179
+ none: ['phone', 'tablet', 'laptop', 'desktop'],
180
+ phone: ['phone'],
181
+ tablet: ['tablet'],
182
+ 'desktop-window': ['laptop', 'desktop'],
183
+ browser: ['laptop', 'desktop'],
184
+ 'widget-host': ['laptop', 'desktop'],
185
+ 'extension-popup': ['laptop', 'desktop'],
186
+ 'side-panel': ['laptop', 'desktop'],
187
+ };
188
+ /** Whether `deviceId` is a catalog device the shell can frame. */
189
+ export function deviceFitsShell(deviceId, shell) {
190
+ const device = getDevice(deviceId);
191
+ return device !== undefined && SHELL_DEVICE_KINDS[shell].includes(device.kind);
192
+ }
193
+ /**
194
+ * The device a shell uses when nothing chose one. The legacy viewport preset
195
+ * picks the phone style (`android` → punch-hole) and maps `none` to the closest
196
+ * form factor. Always returns a device that fits the shell.
197
+ */
198
+ export function defaultDeviceFor(shell, viewport) {
199
+ if (shell === 'phone')
200
+ return viewport === 'android' ? 'phone-punch-hole' : 'phone-pill';
201
+ if (shell === 'tablet')
202
+ return 'tablet';
203
+ if (shell === 'none') {
204
+ if (viewport === 'iphone')
205
+ return 'phone-pill';
206
+ if (viewport === 'android')
207
+ return 'phone-punch-hole';
208
+ if (viewport === 'tablet')
209
+ return 'tablet';
210
+ }
211
+ return 'laptop-window';
212
+ }