@khalidadil/sign-studio (0.5.0)
Installation
@khalidadil:registry=npm install @khalidadil/sign-studio@0.5.0"@khalidadil/sign-studio": "0.5.0"About this package
@khalidadil/sign-studio
Embeddable sign/pattern design studio: a single-project authoring editor (React) plus an Express mount (better-sqlite3 + @khalidadil/laser-geometry).
This is a standalone repo — git.kadil.dev/khalidadil/sign-studio-module —
extracted from, and now consumed by, pattern-svg-builder (a separate repo),
the module's first consumer and living reference host. apparel-designer is the
next target host. A self-contained minimal host lives in examples/embed-host/.
The build is self-contained: npm install && npm run build needs no parent —
devDependencies carry the toolchain (vite, @vitejs/plugin-react) and mirror
every peer, so the module never free-rides a parent install. Do not reintroduce a
dependency on a parent/hoisted node_modules; it breaks on clone, CI, and publish.
Install
Route the @khalidadil scope to the Gitea registry. This repo ships an .npmrc
with the registry line; a consumer needs the same line, plus an auth token
supplied out-of-band (a global ~/.npmrc or NODE_AUTH_TOKEN in CI — never
commit the token):
@khalidadil:registry=https://git.kadil.dev/api/packages/khalidadil/npm/
// package.json — published, or a sibling checkout for fleet-local dev:
"@khalidadil/sign-studio": "^0.1.0-alpha.1"
// "@khalidadil/sign-studio": "file:../sign-studio-module"
Provide these peer dependencies (the module's lib build externalizes them and bundles none — the host supplies the one copy):
| peer | range | notes |
|---|---|---|
react, react-dom |
>=18 <20 |
|
fabric |
^6 |
dynamic-imported; the host bundler code-splits it |
@khalidadil/laser-geometry |
>=0.3 <1 |
single copy — palette live-bindings |
express |
^4.18 || ^5 |
server mount only |
Your bundler MUST dedupe the shared singletons. The module can be consumed
from source through a symlink whose realpath sits outside your project, which
otherwise lets react/three/laser-geometry resolve to a second copy — a
green build that then throws invalid-hook-call at runtime, forks three, or
desyncs the palette. With Vite:
// vite.config.js
export default { resolve: { dedupe: ['react', 'react-dom', 'three'] } };
Keep @khalidadil/laser-geometry a single copy the same way (a single install,
or one symlink both halves share). three reaches you transitively via the
engine's lazy ./three 3D view; dedupe it even though it isn't a direct peer.
Server
import { mountSignStudioApi } from '@khalidadil/sign-studio/server';
const studio = await mountSignStudioApi(app, {
basePath: '/api/signs',
dataDir: '/var/data/sign-studio', // sqlite + fonts/patterns/exports nest here
auth: { isEnabled: () => true, isValid: (token) => ... }, // omit → no guard (guard app-level yourself)
features: { patternBundles: false }, // zip routes need `zip`/`unzip` binaries
uploads: { maxFileSizeMb: 50 },
logger,
});
// → { db, router, ensureProject(data?) }
- Explicit init, no import side effects. First mount on a
dataDirseeds 28 fonts + 398 pre-measured patterns by COPY from the module's ownassets/; against an existing pre-UUID database the mount runs the PK migration (with a.baksnapshot) — treat first-mount-after-upgrade as a migration event. - One project per host product: call
studio.ensureProject()once and store the returned UUID on your product record. - Auth must be cookie-compatible for three GET families the browser
loads natively (no headers possible):
fonts/:id/file,patterns/:id/file,exports/*. The built-in guard exempts them; your own must too. - Geometry (sync Clipper2/earcut) is serialized on two lanes (interactive previews vs exports) so an export can't freeze typing previews. The native Clipper2 binding ships darwin-arm64 prebuilt; Linux images build it with a Rust toolchain (see the repo Dockerfile, glibc not musl).
POST :basePath/projects/:id/restoreatomically reconciles a project tree to a client snapshot (the studio's undo/redo persistence).
Client
import { SignStudio } from '@khalidadil/sign-studio';
import '@khalidadil/sign-studio/styles.css'; // scoped under .sign-studio
<SignStudio
projectId={product.builderProjectId}
api={{ baseUrl: '/api/signs' }} // + getAuthHeaders / onAuthError
palette={hostPalette} // FULL palette, getPalette() shape
scenes={[{ name, label, wallImageUrl }]} // optional room-wall backdrops
resolveTextureUrl={(imageUrl) => ...} // palette colour textures → loadable URLs
features={{ patternBundles: false }}
singleArtboard
productName="Name sign" // omit onRename → read-only title
onSaveStatus={(s) => ...}
onThumbnail={(png) => ...} // host persists cover images
onPublish={async (projectId) => ...} // saves are flushed first
onSelectionChange={({ patternId }) => ...} // deep-link support
pendingPatternId={fromUrl}
ref={studioRef} // { flushSaves, cancelSaves, undo, redo, fitAll, refreshPatterns, refreshFonts }
/>
Host rules:
- One studio per page (module-level transport config + save-status bus).
- Pass the full palette, never a per-product offered subset —
setPaletteFromServerstate is global in@khalidadil/laser-geometry/renderand shared with any storefrontArtSvgon the page. Offers filtering is host UI. - Wood-photo texture: colours without an
imageUrlfall back to the engine's/textures/oak-photo.jpg— serve that asset at your origin or give every wood colour an explicit texture, or the wood face renders as a flat tone. - Peers:
react/react-dom(>=18 <20),fabric^6 (dynamic-imported — your bundler code-splits it),@khalidadil/laser-geometry(>=0.3 <1, must be a SINGLE copy in the bundle — live bindings). See Install for the requiredresolve.dedupe. - Uploaded fonts register as namespaced
sign-<id>FontFaces (no collisions with the host page's fonts); style previews withpreviewFamily().
Customer view (the dual-view embed)
The same design serves both surfaces with no publish step between them:
the admin edits it in <SignStudio>; the storefront renders it through a
write-free public surface and substitutes the customer's text on the fly.
// server — mount OUTSIDE the admin auth boundary:
await mountSignCustomerApi(app, { basePath: '/api/signs-public', maxTextLength: 24 });
import { SignCustomizer } from '@khalidadil/sign-studio';
<SignCustomizer
designId={product.builderProjectId}
api={{ baseUrl: '/api/signs-public' }}
palette={hostPalette} offers={product.offers}
onChange={({ text, material, colorId, backerColorId, twoTone, widthMm, heightMm }) => ...}
/>
- Which text the customer can edit: elements the admin flags with “Customer can edit this text” (TextToolbar); with no flags, a design whose only text element it is stays editable automatically. Unflagged text is fixed decor and renders as-authored.
- The render endpoint accepts ONLY
{ text, backer? }(length-capped; Two-Tone honored only when the design offers it) — customers can never post geometry. Elements with auto-fit re-converge their font size so long names shrink to fit. Nothing is ever written. examples/embed-host/customize.htmlis the runnable storefront demo (node server.mjs+npx vite, then /customize.html).
Publishing
From the repo root: npm install (self-contained — pulls the toolchain + peer
mirrors), then npm run build emits dist/sign-studio.js + the concatenated
scoped stylesheet (prepublishOnly runs the build for you). npm publish
targets the Gitea registry (publishConfig); bump the version first — the
registry rejects a re-publish of an existing version. dist/ is committed and
listed in files, so consumers on file: links (no build step) get the bundle
too — rebuild and commit it whenever src/ changes. Consumers add the
@khalidadil registry line from Install above.
Dependencies
Dependencies
| ID | Version |
|---|---|
| @khalidadil/laser-geometry | ^0.11.0 |
| better-sqlite3 | ^12.4.1 |
| dotenv | ^17.2.3 |
| earcut | ^3.0.2 |
| multer | ^2.0.2 |
| opentype.js | ^1.3.4 |
| sharp | ^0.35.4 |
Development Dependencies
| ID | Version |
|---|---|
| @khalidadil/laser-geometry | ^0.11.0 |
| @vitejs/plugin-react | ^4.3.0 |
| express | ^4.22.2 |
| fabric | ^6.0.0 |
| react | ^18.3.0 |
| react-dom | ^18.3.0 |
| vite | ^5.4.0 |
Peer Dependencies
| ID | Version |
|---|---|
| @khalidadil/laser-geometry | >=0.11.0 <1.0.0 |
| express | ^4.18.0 || ^5.0.0 |
| fabric | ^6.0.0 |
| react | >=18 <20 |
| react-dom | >=18 <20 |