@bookklik/pivo-craft 0.6.0 → 0.6.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/README.md CHANGED
@@ -8,6 +8,10 @@ A local-first browser pixel-art and animation editor.
8
8
 
9
9
  ## npm and headless SDK
10
10
 
11
+ For the full visual editor without its suite header or recovery startup, load `?embed=1`. Use `?recovery=off` to keep the header but disable browser recovery. Existing backups are preserved; see [browser embedding options](docs/npm.md#browser-embedding-and-recovery-options).
12
+
13
+ Embed custom UIs or Node jobs with `createHeadlessEditor` from the root or `/headless` entry. It isolates documents and validates synchronous transactions; see [the embedding API](docs/npm.md#embed-with-a-custom-ui-or-no-ui).
14
+
11
15
  ESM library for Node.js 24+, with generated TypeScript declarations. After publication, install with `npm install @bookklik/pivo-craft` and import from `@bookklik/pivo-craft/headless`, `@bookklik/pivo-craft/core` or `@bookklik/pivo-craft/io`. No CLI or browser is required for the documented portable operations.
12
16
 
13
17
  See [SDK usage, supported operations and manual publishing](docs/npm.md). Build with `npm run build:lib`; validate an isolated production install with `npm run check:consumer`. These commands do not run the unit suite.
package/docs/npm.md CHANGED
@@ -42,6 +42,48 @@ Calls do not open windows, start servers, contact remote services or access brow
42
42
 
43
43
  The SDK uses the existing bounded project/PNG/codec validators. Headless calls execute in the caller's thread: Promise-returning APIs do not imply background workers. For untrusted batches, isolate work in your own Node worker/process with memory/time budgets and terminate the worker for cancellation. There is no built-in Node worker/progress adapter. Model capture, browser PWA/recovery, and UI interactions remain editor features, not implicit headless capabilities.
44
44
 
45
+ ## Browser embedding and recovery options
46
+
47
+ These URL options apply to the full browser editor and standalone HTML, not the UI-free SDK:
48
+
49
+ | URL option | Header | Editor workspace | Browser recovery |
50
+ | --- | --- | --- | --- |
51
+ | No option | Visible | Mounted | Enabled |
52
+ | `?recovery=off` | Visible | Mounted | Disabled |
53
+ | `?embed=1` | Hidden | Mounted | Disabled |
54
+
55
+ For example, load `/pivo-craft/?embed=1` directly or as an iframe's `src`. Keep the iframe sized to the space allocated by the host. If the URL already has a query string, append `&embed=1` instead. This is visual embedding, distinct from importing the headless SDK.
56
+
57
+ Embedded mode hides only `.suite-header` and removes its layout space. Document/mode tabs, context toolbars, side panels, canvas, timeline where applicable, and status UI remain mounted. The hidden header is excluded from layout, accessibility navigation, and keyboard focus; its internal DOM is retained so existing editor command wiring stays intact. Native keyboard shortcuts, including Ctrl/Cmd+S for project downloads, continue to work. This option does not add a cross-origin postMessage/host-control bridge.
58
+
59
+ `?recovery=off` disables discovery, legacy migration, recovery prompts, background autosaves, and recovery reads/writes/deletions for that page session. It does not erase existing backups. The status strip shows **Browser recovery disabled**. Remove the option and reload to resume normal recovery. `recovery=false` and `recovery=0` are aliases; `embed=true` and `embed=on` enable embedded mode. Embedded mode always disables recovery, even with `recovery=on` in the same URL.
60
+
61
+ Unsaved-change warnings remain active. PWA updates are blocked while unsaved documents have no recovery copy; download all current projects before updating. Theme, workspace, tutorial preferences, and PWA asset caching are separate features and are not disabled by the recovery flag. This is not a private/incognito or storage-free browsing mode.
62
+
63
+ The headless SDK already performs no browser recovery lookup or automatic persistence and needs neither URL option. To validate the browser modes after `npm run build`, use `npm run test:embed`; it exercises desktop and compact Chromium layouts, hidden-header spacing, native keyboard saving, and absence of recovery storage access. Set `PIVO_BROWSERS=chromium,firefox,webkit` to run the same checks in all three engines.
64
+
65
+ ## Embed with a custom UI or no UI
66
+
67
+ The root and `/headless` entries export `createHeadlessEditor` and the `HeadlessEditor` TypeScript interface. Each instance owns an isolated project; it does not mount the editor, touch the DOM/storage, register service workers, start a server, or render a viewport. Use it in your host application's framework or a Node job, with the existing core operations and IO exporters.
68
+
69
+ ```js
70
+ import { createHeadlessEditor, createProject } from '@bookklik/pivo-craft/headless';
71
+
72
+ const editor = createHeadlessEditor(createProject());
73
+ editor.transact(draft => {
74
+ draft.name = 'Embedded project';
75
+ });
76
+ const project = editor.getProject();
77
+ ```
78
+
79
+ - `getProject()` returns a detached snapshot, including Craft pixel buffers. Mutating it does not edit the instance.
80
+ - `transact(callback)` edits a private draft and validates before atomically adopting a detached copy. Throws, invalid documents, Promise/thenable callbacks, and reentrant mutations leave the previous project intact. Callbacks must be synchronous; await external work before opening a transaction. Late writes to a retained draft cannot change committed state.
81
+ - `replace(project)` validates and detaches a replacement before adopting it. The seed and replacement remain caller-owned.
82
+
83
+ Call your renderer or framework's state setter with `editor.getProject()` after a successful edit. Export that snapshot using this package's IO APIs. Persistence, rendering, subscriptions, undo/redo, and worker scheduling are explicit host responsibilities; the adapter does not silently persist changes or retain an undo history. Existing lower-level history/store APIs remain available separately. For React, create one instance per mounted editor rather than one on every render. For servers, create one instance per document/job, never a shared global editor for multiple tenants.
84
+
85
+ The adapter is headless application logic, not an iframe bridge, a DOM editor widget, an HTTP API, or a CLI flag. Large validation/export calls still run in the calling thread. For untrusted input, use worker/process isolation and external memory/time limits in addition to the built-in format budgets. Embedded browser hosts must bundle the ESM package and its production dependencies; the browser UI and its PWA/recovery code are not needed.
86
+
45
87
  ## Build and artifact validation
46
88
 
47
89
  ```sh
@@ -67,7 +109,7 @@ npm publish --dry-run --access public
67
109
  npm publish --access public
68
110
  ```
69
111
 
70
- Only run the final command when you intend to publish. No workflow publishes automatically. Confirm npm login, package-name ownership and version availability first; registry E404 does not guarantee ownership. Before later releases, choose an unused semver version. prepublishOnly rejects any current npm audit vulnerability; prepack builds/checks the artifact and never runs unit tests. If publishing a previously created tarball rather than the project directory, run the release checks before packing: npm cannot rebuild a tarball for you. Configure provenance/trusted publishing according to your npm account if desired; no credentials are stored here.
112
+ Only run the final command when you intend to publish. No workflow publishes automatically. Confirm npm login, package-name ownership and version availability first; registry E404 does not guarantee ownership. Before later releases, choose an unused semver version. prepublishOnly rejects npm audit vulnerabilities except the narrowly scoped, expiring deepmerge exception documented in the [dependency policy](production-readiness.md#dependency-policy-updated-28-october-2026); prepack builds/checks the artifact and never runs unit tests. If publishing a previously created tarball rather than the project directory, run the release checks before packing: npm cannot rebuild a tarball for you. Configure provenance/trusted publishing according to your npm account if desired; no credentials are stored here.
71
113
 
72
114
  The npm archive intentionally includes lib/, this document, README, LICENSE and the relevant third-party notices. Source, tests, tooling, app dist/ and standalone HTML are excluded. The root package has no dependency on another PiVo package: each can be published independently.
73
115
 
package/lib/build.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bookklik/pivo-craft",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "inputs": [
5
5
  "packages/core/src/util.ts",
6
6
  "packages/core/src/color.ts",
@@ -15,8 +15,9 @@
15
15
  "packages/core/src/color-adjustments.ts",
16
16
  "packages/core/src/index.ts",
17
17
  "sdk/core.ts",
18
- "packages/codecs/src/bytes.ts",
19
18
  "packages/codecs/src/project.ts",
19
+ "sdk/editor.ts",
20
+ "packages/codecs/src/bytes.ts",
20
21
  "packages/codecs/src/native.ts",
21
22
  "packages/codecs/src/zlib.ts",
22
23
  "packages/codecs/src/ase.ts",
@@ -489,6 +489,8 @@ var IMAGES_PREFIX = "images/";
489
489
  var MAX_UNCOMPRESSED = 256 * 1024 * 1024;
490
490
  var MAX_MEMBERS = 32768;
491
491
  function saveNative(p) {
492
+ p = validateProjectShape(structuredClone(p));
493
+ validateProject(p);
492
494
  const imageRefs = [];
493
495
  const images = {};
494
496
  for (const [id, img] of Object.entries(p.images)) {
@@ -507,7 +509,13 @@ function saveNative(p) {
507
509
  [JSON_MEMBER]: new TextEncoder().encode(JSON.stringify(envelope)),
508
510
  ...images
509
511
  };
510
- return zipSync(members, { level: 6 });
512
+ const entries = Object.values(members);
513
+ if (entries.length > MAX_MEMBERS || members[JSON_MEMBER].length > 16 * 1024 * 1024 || entries.reduce((total, data) => total + data.length, 0) > MAX_UNCOMPRESSED) {
514
+ throw new Error("Project exceeds native archive budgets");
515
+ }
516
+ const bytes = zipSync(members, { level: 6 });
517
+ if (bytes.length > 128 * 1024 * 1024) throw new Error("Encoded ZIP exceeds 128MiB");
518
+ return bytes;
511
519
  }
512
520
  function preflightZip(bytes) {
513
521
  if (bytes.length < 22) throw new Error("file too small for ZIP");
@@ -547,6 +555,7 @@ function preflightZip(bytes) {
547
555
  return directory;
548
556
  }
549
557
  function loadNative(bytes) {
558
+ if (!(bytes instanceof Uint8Array)) throw new Error("Native project must be a Uint8Array");
550
559
  const directory = preflightZip(bytes);
551
560
  let files;
552
561
  try {
@@ -584,10 +593,11 @@ function loadNative(bytes) {
584
593
  if (typeof projectRaw !== "object" || projectRaw === null) throw new Error("pvc envelope missing project");
585
594
  const images = {};
586
595
  for (const ref of env.imageRefs) {
596
+ if (!ref || typeof ref !== "object" || Array.isArray(ref)) throw new Error("bad imageRef");
587
597
  const id = typeof ref.id === "string" ? ref.id : "";
588
- if (!id || id === "__proto__") throw new Error("bad imageRef id");
598
+ if (!/^[A-Za-z0-9_.:-]{1,128}$/.test(id) || ["__proto__", "constructor", "prototype"].includes(id) || Object.hasOwn(images, id)) throw new Error("bad or duplicate imageRef id");
589
599
  const member = typeof ref.member === "string" ? ref.member : "";
590
- const data = files[member];
600
+ const data = Object.hasOwn(files, member) && member.startsWith(IMAGES_PREFIX) ? files[member] : void 0;
591
601
  if (!data) throw new Error(`missing image member ${member}`);
592
602
  images[id] = {
593
603
  id,
@@ -600,15 +610,24 @@ function loadNative(bytes) {
600
610
  const tileSetRefs = Array.isArray(env.tileSetRefs) ? env.tileSetRefs : [];
601
611
  if (tileSetRefs.length > 1024) throw new Error("Too many tilesets");
602
612
  const tilesets = tileSetRefs.map((raw) => {
603
- if (!Array.isArray(raw.tiles) || raw.tiles.length > 65536) throw new Error("Invalid tileset");
604
- return { ...raw, tiles: raw.tiles.map((t) => {
605
- const data = files[t.member];
606
- if (!data) throw new Error("Missing tile member");
607
- return { ...t, data };
608
- }) };
613
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("Invalid tileset");
614
+ const tileset = raw;
615
+ if (!Array.isArray(tileset.tiles) || tileset.tiles.length > 65536) throw new Error("Invalid tileset");
616
+ return {
617
+ ...tileset,
618
+ tiles: tileset.tiles.map((rawTile) => {
619
+ if (!rawTile || typeof rawTile !== "object" || Array.isArray(rawTile)) throw new Error("Invalid tile");
620
+ const tile = rawTile;
621
+ const member = typeof tile.member === "string" ? tile.member : "";
622
+ if (!member.startsWith("tiles/") || !Object.hasOwn(files, member)) throw new Error("Missing tile member");
623
+ return { ...tile, data: files[member] };
624
+ })
625
+ };
609
626
  });
610
627
  const candidate = { ...projectRaw, images, tilesets };
611
- return validateProjectShape(candidate);
628
+ const project = validateProjectShape(candidate);
629
+ validateProject(project);
630
+ return project;
612
631
  }
613
632
 
614
633
  // packages/codecs/src/zlib.ts
@@ -2779,13 +2798,13 @@ function importGIF(bytes, name = "Imported GIF") {
2779
2798
  }
2780
2799
 
2781
2800
  export {
2782
- ByteReader,
2783
- ByteWriter,
2784
- crc32,
2785
2801
  makeImage,
2786
2802
  imageHash,
2787
2803
  LIMITS,
2788
2804
  validateProjectShape,
2805
+ ByteReader,
2806
+ ByteWriter,
2807
+ crc32,
2789
2808
  saveNative,
2790
2809
  loadNative,
2791
2810
  deflateSync,
package/lib/core.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { g as Project, F as Frame, L as Layer, T as Tag, R as RGBA, I as ImageDataModel, M as Mode, C as Cel, h as Rect, m as Tool, f as Point, P as PaintOptions, B as Blend } from './types/types-DV6m_OJI.js';
2
- export { A as AtlasOptions, a as AtlasRecord, b as AtlasResult, D as Diagnostic, c as Direction, d as Ink, e as PaletteKey, S as Slice, i as SliceKey, j as TileCell, k as TileMap, l as TileSet } from './types/types-DV6m_OJI.js';
1
+ import { P as Project, F as Frame, L as Layer, T as Tag, R as RGBA, I as ImageDataModel, M as Mode, C as Cel, h as Rect, m as Tool, g as Point, e as PaintOptions, B as Blend } from './types/types-Bzr7gnEZ.js';
2
+ export { A as AtlasOptions, a as AtlasRecord, b as AtlasResult, D as Diagnostic, c as Direction, d as Ink, f as PaletteKey, S as Slice, i as SliceKey, j as TileCell, k as TileMap, l as TileSet } from './types/types-Bzr7gnEZ.js';
3
3
 
4
4
  /** Stable unique id generator (crypto UUID when available). */
5
5
  declare function uid(): string;
package/lib/index.d.ts CHANGED
@@ -1,3 +1,14 @@
1
- export { A as AtlasOptions, a as AtlasRecord, b as AtlasResult, B as Blend, C as Cel, D as Diagnostic, c as Direction, F as Frame, I as ImageDataModel, d as Ink, L as Layer, M as Mode, P as PaintOptions, e as PaletteKey, f as Point, g as Project, R as RGBA, h as Rect, S as Slice, i as SliceKey, T as Tag, j as TileCell, k as TileMap, l as TileSet, m as Tool } from './types/types-DV6m_OJI.js';
1
+ import { P as Project } from './types/types-Bzr7gnEZ.js';
2
+ export { A as AtlasOptions, a as AtlasRecord, b as AtlasResult, B as Blend, C as Cel, D as Diagnostic, c as Direction, F as Frame, I as ImageDataModel, d as Ink, L as Layer, M as Mode, e as PaintOptions, f as PaletteKey, g as Point, R as RGBA, h as Rect, S as Slice, i as SliceKey, T as Tag, j as TileCell, k as TileMap, l as TileSet, m as Tool } from './types/types-Bzr7gnEZ.js';
2
3
  export { AdjustmentKind, AdjustmentOptions, AdvancedTransformOptions, CustomBrush, DEFAULT_DYNAMICS, Dynamics, History, SUPPORTED_BLENDS, StrokeSample, addFrame, addLayer, addTag, adjust, animationSequence, applyAdjustment, blendPixel, boundsOfSelection, brushFromJSON, brushToJSON, captureBrush, cloneImage, cloneImageWithId, cloneProject, combineSelection, compositePixel, convertMode, createProject, cropBuffer, currentBackgroundColor, editTile, effectiveLocked, effectiveVisible, ensureCel, getCel, growSelection, imagePixelsRGBA, imageRGBA, imageToRGBA, isEmptySelection, isSupportedBlend, linkCel, makeEllipseSelection, makePolygonSelection, makeSelection, moveCel, paint, paintDynamic, paletteFor, rasterOverlay, removeFrame, removeLayer, renderFrame, renderOnion, reorderLayer, resizeProject, rgbaToGray, shrinkSelection, strokeStamps, transformAdvanced, transformCel, uid, unlinkCel, validateBrush, validateProject, wandSelection } from './core.js';
3
4
  export { ByteReader, ByteWriter, EncodeASEResult, GifEncodeOptions, LIMITS, imageToRGBA as codecImageToRGBA, crc32, createAtlas, decodeASE, decodeGIF, decodePNG, deflateSync, encodeASE, encodeGIF, encodeGIFDetailed, encodePNG, exportMetadata, frameToRGBA, imageHash, importGIF, inflateReusable, loadNative, makeImage, planGIFAnimation, saveNative, validateProjectShape, verifyPNG } from './io.js';
5
+
6
+ interface HeadlessEditor {
7
+ getProject(): Project;
8
+ transact(mutate: (draft: Project) => void): void;
9
+ replace(project: Project): void;
10
+ }
11
+ declare function createHeadlessEditor(initialProject?: Project): HeadlessEditor;
12
+
13
+ export { Project, createHeadlessEditor };
14
+ export type { HeadlessEditor };
package/lib/index.js CHANGED
@@ -25,7 +25,7 @@ import {
25
25
  saveNative,
26
26
  validateProjectShape,
27
27
  verifyPNG
28
- } from "./chunks/chunk-DBS4XKWD.js";
28
+ } from "./chunks/chunk-DVGCTRCE.js";
29
29
  import {
30
30
  DEFAULT_DYNAMICS,
31
31
  History,
@@ -87,6 +87,45 @@ import {
87
87
  validateProject,
88
88
  wandSelection
89
89
  } from "./chunks/chunk-NUS76ZK4.js";
90
+
91
+ // sdk/editor.ts
92
+ function normalize(project) {
93
+ const candidate = validateProjectShape(structuredClone(project));
94
+ validateProject(candidate);
95
+ return candidate;
96
+ }
97
+ function createHeadlessEditor(initialProject = createProject()) {
98
+ let current = normalize(initialProject);
99
+ let transacting = false;
100
+ function assertIdle() {
101
+ if (transacting) throw new Error("Headless editor mutations cannot be nested");
102
+ }
103
+ return {
104
+ getProject() {
105
+ return structuredClone(current);
106
+ },
107
+ transact(mutate) {
108
+ assertIdle();
109
+ transacting = true;
110
+ try {
111
+ const draft = structuredClone(current);
112
+ const result = mutate(draft);
113
+ if (result !== null && (typeof result === "object" || typeof result === "function") && typeof result.then === "function") {
114
+ void Promise.resolve(result).catch(() => {
115
+ });
116
+ throw new Error("Headless editor transactions must be synchronous");
117
+ }
118
+ current = normalize(draft);
119
+ } finally {
120
+ transacting = false;
121
+ }
122
+ },
123
+ replace(project) {
124
+ assertIdle();
125
+ current = normalize(project);
126
+ }
127
+ };
128
+ }
90
129
  export {
91
130
  ByteReader,
92
131
  ByteWriter,
@@ -114,6 +153,7 @@ export {
114
153
  convertMode,
115
154
  crc32,
116
155
  createAtlas,
156
+ createHeadlessEditor,
117
157
  createProject,
118
158
  cropBuffer,
119
159
  currentBackgroundColor,
package/lib/io.js CHANGED
@@ -24,7 +24,7 @@ import {
24
24
  saveNative,
25
25
  validateProjectShape,
26
26
  verifyPNG
27
- } from "./chunks/chunk-DBS4XKWD.js";
27
+ } from "./chunks/chunk-DVGCTRCE.js";
28
28
  import "./chunks/chunk-NUS76ZK4.js";
29
29
  export {
30
30
  ByteReader,
@@ -192,4 +192,4 @@ interface AtlasResult {
192
192
  meta: Record<string, unknown>;
193
193
  }
194
194
 
195
- export type { AtlasOptions as A, Blend as B, Cel as C, Diagnostic as D, Frame as F, ImageDataModel as I, Layer as L, Mode as M, PaintOptions as P, RGBA as R, Slice as S, Tag as T, AtlasRecord as a, AtlasResult as b, Direction as c, Ink as d, PaletteKey as e, Point as f, Project as g, Rect as h, SliceKey as i, TileCell as j, TileMap as k, TileSet as l, Tool as m };
195
+ export type { AtlasOptions as A, Blend as B, Cel as C, Diagnostic as D, Frame as F, ImageDataModel as I, Layer as L, Mode as M, Project as P, RGBA as R, Slice as S, Tag as T, AtlasRecord as a, AtlasResult as b, Direction as c, Ink as d, PaintOptions as e, PaletteKey as f, Point as g, Rect as h, SliceKey as i, TileCell as j, TileMap as k, TileSet as l, Tool as m };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bookklik/pivo-craft",
3
3
  "description": "A local-first browser pixel-art and animation editor.",
4
- "version": "0.6.0",
4
+ "version": "0.6.1",
5
5
  "license": "MIT",
6
6
  "author": "a-hakim",
7
7
  "private": false,
@@ -13,6 +13,7 @@
13
13
  "test": "vitest run",
14
14
  "test:coverage": "vitest run --coverage",
15
15
  "test:browser": "node scripts/browser-readiness.mjs",
16
+ "test:embed": "node scripts/browser-embed.mjs",
16
17
  "typecheck": "tsc --noEmit",
17
18
  "docs:dev": "npm run docs:build && node scripts/preview-docs.mjs",
18
19
  "docs:build": "vitepress build docs",
@@ -114,6 +115,8 @@
114
115
  "editor"
115
116
  ],
116
117
  "overrides": {
118
+ "glob": "14.0.3",
119
+ "minimatch": "11.0.1",
117
120
  "fast-uri": "3.1.9",
118
121
  "serialize-javascript": "7.1.3",
119
122
  "vitepress": {