@wasm-gaming/engine-specs 0.2.7 → 0.3.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.
Files changed (48) hide show
  1. package/README.md +63 -54
  2. package/demo/app.html +227 -0
  3. package/demo/canvas-renderer.js +153 -0
  4. package/demo/components/asset-picker.html +137 -0
  5. package/demo/components/call-log.html +33 -0
  6. package/demo/components/engine-header.html +78 -0
  7. package/demo/components/lifecycle-bar.html +69 -0
  8. package/demo/components/property-table.html +119 -0
  9. package/demo/demo.css +26 -130
  10. package/demo/demo.sdk.js +405 -203
  11. package/demo/index.html +7 -56
  12. package/demo/tsconfig.json +17 -0
  13. package/dist/engine-specs.d.ts +106 -0
  14. package/dist/engine-specs.d.ts.map +1 -0
  15. package/dist/engine-specs.js +3 -0
  16. package/dist/engine-specs.js.map +1 -0
  17. package/package.json +7 -12
  18. package/src/engine-specs.ts +117 -0
  19. package/demo/README.md +0 -155
  20. package/demo/checksums.js +0 -101
  21. package/demo/components/drop-area.html +0 -171
  22. package/demo/components/esc-menu.html +0 -540
  23. package/demo/components/launcher.html +0 -220
  24. package/demo/components/sdk-info.html +0 -54
  25. package/demo/demo.js +0 -622
  26. package/demo/sdk.js +0 -19
  27. package/demo/storage.js +0 -88
  28. package/dist/engine.d.ts +0 -427
  29. package/dist/engine.d.ts.map +0 -1
  30. package/dist/engine.js +0 -83
  31. package/dist/engine.js.map +0 -1
  32. package/dist/index.d.ts +0 -13
  33. package/dist/index.d.ts.map +0 -1
  34. package/dist/index.js +0 -12
  35. package/dist/index.js.map +0 -1
  36. package/dist/manifest.d.ts +0 -69
  37. package/dist/manifest.d.ts.map +0 -1
  38. package/dist/manifest.js +0 -8
  39. package/dist/manifest.js.map +0 -1
  40. package/dist/validate.d.ts +0 -10
  41. package/dist/validate.d.ts.map +0 -1
  42. package/dist/validate.js +0 -79
  43. package/dist/validate.js.map +0 -1
  44. package/schema/engine-manifest.schema.json +0 -74
  45. package/src/engine.ts +0 -516
  46. package/src/index.ts +0 -55
  47. package/src/manifest.ts +0 -82
  48. package/src/validate.ts +0 -92
package/README.md CHANGED
@@ -1,84 +1,93 @@
1
1
  # @wasm-gaming/engine-specs
2
2
 
3
- The **engine contract** for the wasm-gaming ecosystem: the single shared interface
4
- that every WASM game/emulator engine and the host app conform to. Define it once
5
- here, depend on it everywhere, and engines can't drift.
3
+ The **engine contract** for the wasm-gaming ecosystem: one shared JS API that every
4
+ WASM game/emulator engine implements and every host drives. Define it once here,
5
+ depend on it everywhere, and engines can't drift.
6
6
 
7
- TypeScript is the source of truth ([src/](src/)); a JSON Schema
8
- ([schema/engine-manifest.schema.json](schema/engine-manifest.schema.json)) mirrors
9
- the manifest for non-TS consumers; a Zod-based validator lets any CI fail on
10
- drift.
7
+ TypeScript types and nothing else — [src/engine-specs.ts](src/engine-specs.ts) is the
8
+ whole package. No runtime dependencies, because twelve engines depend on this.
11
9
 
12
- ## Documentation
10
+ ## The shape
13
11
 
14
- **[API reference](https://wasm-gaming.github.io/engine-specs/api-docs/)** — every type,
15
- generated from the source. It also carries the [contract
16
- map](https://wasm-gaming.github.io/engine-specs/api-docs/documents/Contract_map.html)
17
- (how the types fit together) and the [usage
18
- guide](https://wasm-gaming.github.io/engine-specs/api-docs/documents/Usage.html)
19
- (host-side wiring, from validating a manifest to forwarding input).
12
+ An engine exports a class. Its **identity is static**, so a launcher can list engines
13
+ without constructing one:
20
14
 
21
- The [demo](https://wasm-gaming.github.io/engine-specs/) runs a dummy engine through the
22
- same contract.
15
+ ```ts
16
+ import type { EngineSDKConstructor, EnginePayloads } from '@wasm-gaming/engine-specs';
23
17
 
24
- ## Two layers
18
+ interface Rsdkv4Payloads extends EnginePayloads {
19
+ config: { region: 'us' | 'eu' | 'jp' };
20
+ options: { player: 'sonic' | 'tails'; slot: number };
21
+ assets: { pack: Uint8Array };
22
+ storage: { namespace: string };
23
+ }
25
24
 
26
- **Layer A — declarative ([src/manifest.ts](src/manifest.ts))**
27
- `EngineManifest` is what an engine declares about itself before anything boots.
28
- Everything that differs between engines lives here as data.
25
+ // The one line that belongs in an engine's CI: does the class satisfy the contract?
26
+ const _contract: EngineSDKConstructor<Rsdkv4Payloads> = Rsdkv4SDK;
27
+ ```
29
28
 
30
- **Layer B — imperative ([src/engine.ts](src/engine.ts))**
31
- Every engine package default-exports an `EngineSDK` = `{ manifest, load }`.
32
- `load(config)` boots the engine and returns an `EngineInstance` the host drives —
33
- identically for every engine.
29
+ A host configures by chaining, and gets the live engine back:
34
30
 
35
- ## Usage
31
+ ```js
32
+ const play = await new Rsdkv4SDK({ config: { region: 'eu' } })
33
+ .assets({ pack })
34
+ .mount(document.querySelector('#stage'))
35
+ .start({ player: 'tails', slot: 0 });
36
36
 
37
- An engine implements the contract:
37
+ await play.restart({ slot: 3 });
38
+ await play.destroy();
39
+ ```
38
40
 
39
- ```ts
40
- import type { EngineSDK, EngineManifest } from '@wasm-gaming/engine-specs';
41
+ ## Read and write are different shapes
41
42
 
42
- export const manifest: EngineManifest = { /* ... */ };
43
- export async function load(config) { /* ... */ }
43
+ `config`, `options`, `assets` and `storage` are each one method with two overloads.
44
+ Called with a patch it writes and keeps the chain; called with nothing it reads:
44
45
 
45
- // Compile-time conformance:
46
- const _sdk: EngineSDK = { manifest, load };
46
+ ```js
47
+ sdk.config({ region: 'eu' }) // writes, chains
48
+ await sdk.config() // reads
49
+ // → { region: { value: 'eu', default: 'us', enum: [...], description: '…', live: false } }
47
50
  ```
48
51
 
49
- CI (or the host) validates a manifest at runtime:
52
+ The getter returns a **descriptor per key** — enough to paint a settings screen
53
+ without asking the engine anything, and without the host knowing a single key name.
54
+ `live` says whether a property can be changed on a running engine or needs a
55
+ `restart()`. The descriptor shape is never accepted on write: that asymmetry is what
56
+ keeps a write unambiguous the day a value is itself an object.
50
57
 
51
- ```ts
52
- import { validateManifest, assertManifest } from '@wasm-gaming/engine-specs';
58
+ ## Closing the types
53
59
 
54
- const { valid, errors } = validateManifest(json);
55
- // or: assertManifest(json) // throws with a readable error list
60
+ One type parameter carries all four payloads, so there is no order to get wrong:
61
+
62
+ ```ts
63
+ type Sdk = EngineSDK<Rsdkv4Payloads>;
56
64
  ```
57
65
 
66
+ Leave it off and the payloads stay open (`Record<string, unknown>`), which compiles
67
+ but gives up the checking that is the point.
68
+
58
69
  ## Build
59
70
 
60
71
  ```bash
61
72
  npm install
62
- make build # tsc → dist/ (.js + .d.ts)
63
- make typecheck # no emit
64
- make test # build + node test runner
65
- make docs # typedoc → site/api-docs
66
- make site # the Pages site: demo at the root, reference under /api-docs/
67
- make preview # serve site/ on :8020
68
- make publish-dry-run
69
- make publish
73
+ make build # tsc → dist/ (cleans first)
74
+ make typecheck # the contract
75
+ make typecheck-tests # the compile-time conformance suite
76
+ make typecheck-demo # the demo engine, held to the same contract
77
+ make test # all of the above + the package integrity tests
78
+ make docs # typedoc → site/
79
+ make site # reference at /, demo at /demo/
80
+ make preview # serve site/ on :8020 (PORT= to change it)
70
81
  ```
71
82
 
72
- ## Consuming
73
-
74
- Depend on it by version (published npm) or by git ref. Engines list it as a
75
- (dev/peer) dependency and import only `type`s at compile time — the SDK itself
76
- carries no runtime dependency on this package.
77
-
78
- ## Versioning
83
+ ## Conformance
79
84
 
80
- `CONTRACT_VERSION` tracks the shape of the interfaces. Breaking changes bump the
81
- major; engines declare which contract version they target via their own SemVer.
85
+ Two engines implement the contract in this repo, and both are type-checked in CI:
86
+ [tests/engine.conformance.ts](tests/engine.conformance.ts), the reference engine, and
87
+ [demo/demo.sdk.js](demo/demo.sdk.js), which drives the [demo
88
+ page](https://wasm-gaming.github.io/engine-specs/demo/). The negative cases live in
89
+ [tests/types.test-d.ts](tests/types.test-d.ts), guarded by `@ts-expect-error` — which
90
+ fails if a rejection ever stops happening.
82
91
 
83
92
  ## License
84
93
 
package/demo/app.html ADDED
@@ -0,0 +1,227 @@
1
+ <!--
2
+ The demo, as one root component.
3
+
4
+ It drives the engine only through the contract, and every panel is painted from
5
+ something the contract handed back:
6
+
7
+ - the header is the class's static identity, read before any engine exists;
8
+ - the pickers get their file filter from each asset descriptor's `allowedTypes`;
9
+ - the tables are `config()`, `options()`, `assets()` and `storage()` rendered as
10
+ they arrive — one component, and not one key name written on this side.
11
+
12
+ State lives in the `let`s below: reassigning one repaints what depends on it, so
13
+ there is no mount bookkeeping here.
14
+ -->
15
+
16
+ <EngineHeader :name="name" :id="id" :version="version" :description="description" />
17
+
18
+ <div class="layout">
19
+ <aside>
20
+ <section>
21
+ <h2>assets</h2>
22
+ <AssetPicker :assets="assets" @select="onSelect" @remove="onRemove" />
23
+ </section>
24
+
25
+ <section>
26
+ <h2>lifecycle</h2>
27
+ <LifecycleBar :running="running" @start="onStart" @restart="onRestart"
28
+ @pause="onPause" @resume="onResume" @destroy="onDestroy" />
29
+ </section>
30
+
31
+ <section>
32
+ <h2>calls</h2>
33
+ <CallLog :entries="calls" />
34
+ </section>
35
+ </aside>
36
+
37
+ <div>
38
+ <!-- The engine is handed this element and appends its own canvas to it. -->
39
+ <div id="stage"></div>
40
+ <PropertyTable :tables="tables" />
41
+ </div>
42
+ </div>
43
+
44
+ <script :setup="{}">
45
+ const EngineHeader = await import('./components/engine-header.html');
46
+ const AssetPicker = await import('./components/asset-picker.html');
47
+ const PropertyTable = await import('./components/property-table.html');
48
+ const LifecycleBar = await import('./components/lifecycle-bar.html');
49
+ const CallLog = await import('./components/call-log.html');
50
+
51
+ const { DemoEngineSDK } = await import('./demo.sdk.js');
52
+
53
+ // Identity, off the class — no engine constructed to read it.
54
+ const name = DemoEngineSDK.name;
55
+ const id = DemoEngineSDK.id;
56
+ const version = DemoEngineSDK.version;
57
+ const description = DemoEngineSDK.description;
58
+ document.title = `${name} — engine-specs demo`;
59
+
60
+ let sdk;
61
+ let play;
62
+ let running = false;
63
+ let assets = [];
64
+ let tables = [];
65
+ let calls = [];
66
+
67
+ /** File names by asset key, for the pickers. The bytes live in the engine. */
68
+ const picked = {};
69
+
70
+ const say = (line) => {
71
+ calls = [line, ...calls].slice(0, 40);
72
+ };
73
+
74
+ const show = (value) => {
75
+ if (value === undefined) return 'unset';
76
+ if (value instanceof Uint8Array) return `${value.byteLength} bytes`;
77
+ return String(value);
78
+ };
79
+
80
+ /** One getter's return value, flattened for the table component. */
81
+ const toRows = (properties) =>
82
+ Object.entries(properties).map(([key, property]) => ({
83
+ key,
84
+ value: show(property.value),
85
+ unset: property.value === undefined,
86
+ description: property.description ?? '',
87
+ default: property.default === undefined ? '' : show(property.default),
88
+ enum: property.enum ? property.enum.join(' | ') : '',
89
+ allowedTypes: property.allowedTypes ? property.allowedTypes.join(' ') : '',
90
+ live: Boolean(property.live),
91
+ }));
92
+
93
+ /** Reads the live engine — or the factory, when there is not one yet. */
94
+ const refresh = async () => {
95
+ const source = play ?? (sdk ??= new DemoEngineSDK());
96
+ const [config, options, assetProps, storage] = await Promise.all([
97
+ source.config(),
98
+ source.options(),
99
+ source.assets(),
100
+ source.storage(),
101
+ ]);
102
+
103
+ assets = Object.entries(assetProps).map(([key, property]) => ({
104
+ key,
105
+ description: property.description ?? '',
106
+ accept: (property.allowedTypes ?? []).join(','),
107
+ accepts: property.allowedTypes ? `accepts ${property.allowedTypes.join(' ')}` : 'any file',
108
+ fileName: picked[key] ?? '',
109
+ }));
110
+
111
+ tables = [
112
+ { title: 'config', rows: toRows(config) },
113
+ { title: 'options', rows: toRows(options) },
114
+ { title: 'assets', rows: toRows(assetProps) },
115
+ { title: 'storage', rows: toRows(storage) },
116
+ ];
117
+
118
+ running = Boolean(play);
119
+ };
120
+
121
+ const onSelect = async ({ key, file }) => {
122
+ const bytes = new Uint8Array(await file.arrayBuffer());
123
+ picked[key] = file.name;
124
+ sdk ??= new DemoEngineSDK();
125
+ say(`sdk.assets({ ${key}: Uint8Array(${bytes.byteLength}) })`);
126
+ sdk.assets({ [key]: bytes });
127
+ if (key === 'rom') {
128
+ say(`sdk.options({ fileName: '${file.name}', byteLength: ${file.size} })`);
129
+ sdk.options({ fileName: file.name, byteLength: file.size });
130
+ }
131
+ await refresh();
132
+ };
133
+
134
+ const onRemove = async ({ key }) => {
135
+ delete picked[key];
136
+ say(`sdk.assets({ ${key}: undefined })`);
137
+ sdk?.assets({ [key]: undefined });
138
+ await refresh();
139
+ };
140
+
141
+ const onStart = async () => {
142
+ sdk ??= new DemoEngineSDK();
143
+ say('play = await sdk.mount(stage).start()');
144
+ // The whole chain in one expression, exactly as the contract advertises it.
145
+ play = await sdk.mount(document.querySelector('#stage')).start();
146
+ await refresh();
147
+ };
148
+
149
+ const onRestart = async () => {
150
+ say('await play.restart()');
151
+ await play.restart();
152
+ await refresh();
153
+ };
154
+
155
+ // `pause` and `resume` are this engine's own members, not the contract's.
156
+ const onPause = async () => {
157
+ say('play.pause() // engine-specific, not in the contract');
158
+ play.pause();
159
+ await refresh();
160
+ };
161
+
162
+ const onResume = async () => {
163
+ say('play.resume() // engine-specific, not in the contract');
164
+ play.resume();
165
+ await refresh();
166
+ };
167
+
168
+ const onDestroy = async () => {
169
+ say('await play.destroy()');
170
+ await play.destroy();
171
+ play = undefined;
172
+ sdk = undefined;
173
+ for (const key of Object.keys(picked)) delete picked[key];
174
+ await refresh();
175
+ };
176
+
177
+ say('ready — identity read from the class, no engine constructed yet');
178
+ await refresh();
179
+ </script>
180
+
181
+ <style>
182
+ .layout {
183
+ display: grid;
184
+ grid-template-columns: minmax(280px, 340px) 1fr;
185
+ gap: 1.5rem;
186
+ padding: 1.5rem;
187
+ align-items: start;
188
+ }
189
+
190
+ @media (max-width: 900px) {
191
+ .layout {
192
+ grid-template-columns: 1fr;
193
+ }
194
+ }
195
+
196
+ aside section {
197
+ background: var(--panel, #fff);
198
+ border: 1px solid var(--border, #e2e8f0);
199
+ border-radius: 4px;
200
+ padding: 1rem;
201
+ margin-bottom: 1rem;
202
+ }
203
+
204
+ aside h2 {
205
+ margin: 0 0 0.75rem;
206
+ font-size: 0.78rem;
207
+ font-family: var(--mono, monospace);
208
+ text-transform: uppercase;
209
+ letter-spacing: 0.08em;
210
+ color: var(--muted, #64748b);
211
+ }
212
+
213
+ #stage {
214
+ height: 380px;
215
+ border: 1px solid var(--border, #e2e8f0);
216
+ border-radius: 4px;
217
+ overflow: hidden;
218
+ background: #060910;
219
+ margin-bottom: 1rem;
220
+ }
221
+
222
+ #stage canvas {
223
+ display: block;
224
+ width: 100%;
225
+ height: 100%;
226
+ }
227
+ </style>
@@ -0,0 +1,153 @@
1
+ /**
2
+ * The demo's picture, and nothing else.
3
+ *
4
+ * Isolated from the SDK on purpose: it knows about a canvas and a frame callback,
5
+ * not about manifests, assets or the engine contract. Whatever shape the demo SDK
6
+ * takes next, this keeps working untouched — it is the part that was never about
7
+ * the contract.
8
+ *
9
+ * @param {HTMLCanvasElement} canvasEl
10
+ * @param {{ onFrame?: (fps: number) => void }} [options]
11
+ */
12
+ export function createCanvasRenderer(canvasEl, { onFrame } = {}) {
13
+ const ctx = canvasEl.getContext("2d");
14
+ if (!ctx) {
15
+ throw new Error("Could not create 2D canvas context.");
16
+ }
17
+
18
+ let raf = 0;
19
+ let running = false;
20
+ let lastFrame = performance.now();
21
+
22
+ const fontSize = 30;
23
+ /** @typedef {{ y: number, speed: number, opacity: number, length: number, chars: string[] }} Drop */
24
+ /** @type {Drop[]} */
25
+ let drops = [];
26
+
27
+ const fitCanvasToWindow = () => {
28
+ const dpr = window.devicePixelRatio || 1;
29
+ const rect = canvasEl.getBoundingClientRect();
30
+ canvasEl.width = Math.max(1, Math.floor(rect.width * dpr));
31
+ canvasEl.height = Math.max(1, Math.floor(rect.height * dpr));
32
+ };
33
+
34
+ /** @param {number} time */
35
+ const render = (time) => {
36
+ if (!running) {
37
+ return;
38
+ }
39
+
40
+ const width = canvasEl.width;
41
+ const height = canvasEl.height;
42
+ const dt = Math.min((time - lastFrame) / 1000, 0.1);
43
+ const fps = Math.max(1, 1000 / Math.max(1, time - lastFrame));
44
+ lastFrame = time;
45
+
46
+ const expectedColumns = Math.floor(width / fontSize);
47
+ const maxRows = Math.floor(height / fontSize) + 30;
48
+
49
+ if (drops.length !== expectedColumns) {
50
+ drops = [];
51
+ for (let x = 0; x < expectedColumns; x++) {
52
+ drops[x] = {
53
+ y: Math.random() * maxRows,
54
+ speed: Math.random() * 1.5 + 0.5,
55
+ opacity: Math.random() * 0.7 + 0.3,
56
+ length: Math.floor(Math.random() * 20 + 5),
57
+ chars: Array.from({ length: maxRows }, () => String.fromCharCode(0x30A0 + Math.random() * 96)),
58
+ };
59
+ }
60
+ }
61
+
62
+ // Dark background inside the canvas to contrast with the light UI components
63
+ ctx.fillStyle = "#060910ff";
64
+ ctx.fillRect(0, 0, width, height);
65
+
66
+ ctx.font = "bold " + fontSize + "px 'Hiragino Sans', 'Hiragino Kaku Gothic ProN', 'Noto Sans CJK JP', sans-serif";
67
+ ctx.textAlign = "center";
68
+
69
+ for (const [x, drop] of drops.entries()) {
70
+ if (Math.random() > 0.4) {
71
+ let randIdx = Math.floor(Math.random() * drop.chars.length);
72
+ drop.chars[randIdx] = String.fromCharCode(0x30A0 + Math.random() * 96);
73
+ }
74
+
75
+ drop.y += drop.speed * (dt * 12);
76
+
77
+ let startY = Math.floor(drop.y);
78
+
79
+ for (let i = 0; i < drop.length; i++) {
80
+ let charY = startY - i;
81
+ if (charY < 0 || charY >= maxRows) continue;
82
+
83
+ const text = drop.chars[charY];
84
+ if (text === undefined) continue;
85
+ let alpha = drop.opacity * (1 - (i / drop.length));
86
+
87
+ if (i === 0) {
88
+ ctx.fillStyle = `rgba(220, 255, 255, ${drop.opacity})`;
89
+ } else {
90
+ const hue = ((time / 15) + (x * 10)) % 360;
91
+ ctx.fillStyle = `hsla(${hue}, 80%, 65%, ${alpha})`;
92
+ }
93
+
94
+ ctx.fillText(text, x * fontSize + fontSize / 2, charY * fontSize);
95
+ }
96
+
97
+ if ((drop.y - drop.length) * fontSize > height) {
98
+ drop.y = 0;
99
+ drop.speed = Math.random() * 1.5 + 0.5;
100
+ drop.opacity = Math.random() * 0.7 + 0.3;
101
+ drop.length = Math.floor(Math.random() * 20 + 5);
102
+ }
103
+ }
104
+
105
+ ctx.fillStyle = "rgba(255,255,255,0.85)";
106
+ ctx.fillRect(width - 210, 20, 180, 64);
107
+ ctx.fillStyle = "#0f172a";
108
+ ctx.font = "600 36px 'Outfit', system-ui, sans-serif";
109
+ ctx.textAlign = "left";
110
+ ctx.fillText(`FPS ${fps.toFixed(0)}`, width - 182, 65);
111
+
112
+ onFrame?.(fps);
113
+ raf = requestAnimationFrame(render);
114
+ };
115
+
116
+ fitCanvasToWindow();
117
+ window.addEventListener("resize", fitCanvasToWindow);
118
+
119
+ return {
120
+ get running() {
121
+ return running;
122
+ },
123
+ start() {
124
+ if (running) {
125
+ return;
126
+ }
127
+ running = true;
128
+ lastFrame = performance.now();
129
+ raf = requestAnimationFrame(render);
130
+ },
131
+ pause() {
132
+ running = false;
133
+ cancelAnimationFrame(raf);
134
+ },
135
+ resume() {
136
+ if (running) {
137
+ return;
138
+ }
139
+ running = true;
140
+ lastFrame = performance.now();
141
+ raf = requestAnimationFrame(render);
142
+ },
143
+ reset() {
144
+ lastFrame = performance.now();
145
+ drops = [];
146
+ },
147
+ destroy() {
148
+ running = false;
149
+ cancelAnimationFrame(raf);
150
+ window.removeEventListener("resize", fitCanvasToWindow);
151
+ },
152
+ };
153
+ }
@@ -0,0 +1,137 @@
1
+ <!--
2
+ The engine's assets, each driven by its own descriptor.
3
+
4
+ Everything shown comes from what `assets()` returned: the hint is the descriptor's
5
+ `description`, and the file dialog's filter is its `allowedTypes`. The page
6
+ hardcodes nothing about any asset — add a third one to the engine and it appears
7
+ here on its own.
8
+ -->
9
+ <div class="pickers">
10
+ <section class="drop" :each="(asset, index) in assets"
11
+ :class.selected="asset.fileName"
12
+ @dragover.prevent="dragging = asset.key"
13
+ @dragleave="dragging = null"
14
+ @drop.prevent="onDrop($event, asset)"
15
+ :class.active="dragging === asset.key">
16
+ <strong>{{ asset.key }}</strong>
17
+ <p class="hint">{{ asset.description }}</p>
18
+ <p :class.file-selected="asset.fileName">{{ asset.fileName || asset.accepts }}</p>
19
+ <div class="picker-actions">
20
+ <label class="picker">
21
+ {{ asset.fileName ? 'Change' : 'Pick file' }}
22
+ <input type="file" :accept="asset.accept" @change="onPick($event, asset)" />
23
+ </label>
24
+ <button type="button" :if="asset.fileName" class="remove-btn" @click.stop="$emit('remove', asset)">Remove</button>
25
+ </div>
26
+ </section>
27
+ </div>
28
+
29
+ <script :setup="{
30
+ assets = [],
31
+ }">
32
+ let dragging = null;
33
+
34
+ const extractFile = (arg) => {
35
+ if (!arg) return null;
36
+ if (arg instanceof File) return arg;
37
+ if (arg.detail instanceof File) return arg.detail;
38
+ if (arg.target?.files?.item(0)) return arg.target.files.item(0);
39
+ if (arg.dataTransfer?.files?.item(0)) return arg.dataTransfer.files.item(0);
40
+ return null;
41
+ };
42
+
43
+ const onDrop = (event, asset) => {
44
+ dragging = null;
45
+ const file = extractFile(event);
46
+ if (file) $emit('select', { key: asset.key, file });
47
+ };
48
+
49
+ const onPick = (event, asset) => {
50
+ const file = extractFile(event);
51
+ if (file) $emit('select', { key: asset.key, file });
52
+ };
53
+ </script>
54
+
55
+ <style>
56
+ .pickers {
57
+ display: grid;
58
+ gap: 0.6rem;
59
+ }
60
+
61
+ .drop {
62
+ border: 1px dashed var(--border, #e2e8f0);
63
+ border-radius: 4px;
64
+ padding: 1rem;
65
+ text-align: center;
66
+ transition: border-color 200ms ease, background 200ms ease;
67
+ }
68
+
69
+ .drop.active {
70
+ border-color: var(--accent, #ff007f);
71
+ background: rgb(255 0 127 / 0.06);
72
+ }
73
+
74
+ .drop.selected {
75
+ border-style: solid;
76
+ border-color: var(--accent, #ff007f);
77
+ }
78
+
79
+ .drop strong {
80
+ display: block;
81
+ font-family: var(--mono, monospace);
82
+ font-size: 0.9rem;
83
+ font-weight: 600;
84
+ color: var(--text, #0f172a);
85
+ }
86
+
87
+ .drop p {
88
+ margin: 0.35rem 0 0;
89
+ color: var(--muted, #64748b);
90
+ font-size: 0.78rem;
91
+ word-break: break-all;
92
+ }
93
+
94
+ .drop p.hint {
95
+ font-size: 0.8rem;
96
+ }
97
+
98
+ .drop p.file-selected {
99
+ color: var(--accent, #ff007f);
100
+ font-family: var(--mono, monospace);
101
+ font-weight: 600;
102
+ }
103
+
104
+ .picker-actions {
105
+ display: inline-flex;
106
+ align-items: center;
107
+ gap: 0.4rem;
108
+ margin-top: 0.75rem;
109
+ }
110
+
111
+ .picker,
112
+ .remove-btn {
113
+ display: inline-flex;
114
+ font-family: var(--mono, monospace);
115
+ font-size: 0.75rem;
116
+ padding: 0.35rem 0.7rem;
117
+ border: 1px solid var(--border, #e2e8f0);
118
+ border-radius: 3px;
119
+ background: transparent;
120
+ color: var(--text, #0f172a);
121
+ cursor: pointer;
122
+ }
123
+
124
+ .picker:hover {
125
+ border-color: var(--accent, #ff007f);
126
+ color: var(--accent, #ff007f);
127
+ }
128
+
129
+ .picker input {
130
+ display: none;
131
+ }
132
+
133
+ .remove-btn:hover {
134
+ border-color: #ef4444;
135
+ color: #ef4444;
136
+ }
137
+ </style>