@scaleflex/template-builder 0.1.1 → 0.2.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.
@@ -43,7 +43,8 @@ storage and does not want to map them onto Scaleflex tenants. Pick
43
43
 
44
44
  Statelessness applies to the **document only**. The editor still needs an
45
45
  authenticated Filerobot tenant for server-side text rendering, custom fonts,
46
- asset browsing, and metadata variables.
46
+ asset browsing, and metadata variables — so a stateless embed still needs a
47
+ credential (Step 7).
47
48
 
48
49
  ## Step 2 — Detect the target framework
49
50
 
@@ -144,6 +145,39 @@ import { TemplateBuilder } from '@scaleflex/template-builder/react'
144
145
  />
145
146
  ```
146
147
 
148
+ ### Stateless — a template that does not exist yet
149
+
150
+ A host whose user is creating their first template has no XML to pass in, and
151
+ should not have to author any. Set `new-template` instead of `content` and the
152
+ widget supplies the empty document:
153
+
154
+ ```html
155
+ <sfx-template-builder stateless new-template template-name="Untitled" …>
156
+ ```
157
+
158
+ ```js
159
+ builder.createNew({ templateId: 'your-own-id-43', name: 'Untitled' })
160
+ ```
161
+
162
+ ```tsx
163
+ <TemplateBuilder stateless newTemplate templateName="Untitled" … />
164
+ ```
165
+
166
+ The editor opens on its empty state, the user adds the first layout (canvas
167
+ size, background, preset), and the first `save` hands back a complete `.fdt`
168
+ document — store that and every later open is the ordinary load flow. Save is
169
+ refused until a layout exists.
170
+
171
+ - `templateId` is optional; without one the `save` payload just arrives without
172
+ an id, and the host allocates one when storing.
173
+ - `templateQuery` stays empty — a new document has no layouts or variables for
174
+ a query to select. It comes back on the first save.
175
+ - `content` wins when both are set, so one element can serve both cases.
176
+ - Empty `content` alone does **not** start a blank template: it means the host
177
+ is still fetching, and the editor keeps waiting. Only the flag changes that.
178
+ - For a house-style starting point (standard canvas, locked logo layer), pass
179
+ it as ordinary `content` — a starter template is just a template.
180
+
147
181
  ### DAM-backed
148
182
 
149
183
  `template-id` is a Filerobot file uuid; omit it to open the new-template flow.
@@ -232,6 +266,19 @@ What it costs:
232
266
  In stateless mode a single service tenant is usually right either way: one
233
267
  machine credential, with all per-user permission logic staying in the host app.
234
268
 
269
+ **If asked why a stateless embed needs a credential at all** — because the
270
+ preview is not drawn by the browser. A `.fdt` is rendered server-side with
271
+ ImageMagick when its CDN URL is requested, and browser text APIs cannot
272
+ reproduce that layout (kerning, letter spacing, wrapping, baselines,
273
+ antialiasing differ per browser and from the export), so the editor rasterizes
274
+ every text and shape layer through the same engine via
275
+ `POST /api/render-layers` on the `base-url` deployment — batched, debounced,
276
+ supersampled 3×, composited with CSS. That endpoint fetches fonts and images
277
+ server-side, so it authenticates on the handed-over credential and returns
278
+ `401` without one: no credential, no visible text or shape layers. The security
279
+ template is what authorizes it when there is no Hub session. Full explanation:
280
+ *Why the editor still calls a server* in the README.
281
+
235
282
  ## Step 8 — Register the embedding origin (required)
236
283
 
237
284
  The host page's origin must be in the deployment's `frame-ancestors` allowlist,
@@ -270,8 +317,9 @@ template document.
270
317
  | `company-uuid`, `project-uuid` | no | Company / project scoping (session auth only) |
271
318
  | `template-id` / `templateId` | no | DAM: Filerobot uuid. Stateless: opaque host id |
272
319
  | `mode` | no | `inline` (default) or `modal` (starts closed — call `open()`) |
273
- | `stateless` | no | Host owns the document; requires `content` |
320
+ | `stateless` | no | Host owns the document; requires `content`, or `new-template` |
274
321
  | `content` (property only) | stateless | Template as `.fdt` XML |
322
+ | `new-template` / `newTemplate` | no | Stateless: open on a new, empty template — the widget supplies the blank document. Ignored when `content` is set |
275
323
  | `template-name` / `templateName` | no | Stateless: header title |
276
324
  | `template-query` / `templateQuery` | no | Stateless: the render to open on — the `templateQuery` from the last save. Empty uses the XML's `default=` values. |
277
325
  | `brand-color`, `theme` | no | See Step 9 |
@@ -294,7 +342,8 @@ template document.
294
342
  ## Public Methods
295
343
 
296
344
  `open(templateId?)`, `close()`, `load({ content, templateId?, name? })`,
297
- `confirmSave(ok, message?)`. Read-only: `status`, `isDirty`.
345
+ `createNew({ templateId?, name? })`, `confirmSave(ok, message?)`. Read-only:
346
+ `status`, `isDirty`.
298
347
 
299
348
  In React these are reached through a forwarded ref — **required for
300
349
  `mode="modal"`**, which renders nothing until `open()` is called:
package/CHANGELOG.md CHANGED
@@ -8,6 +8,28 @@ Protocol message *values* are wire format: an existing string is never changed,
8
8
  only new messages added, so an older widget keeps working against a newer app
9
9
  deployment and vice versa.
10
10
 
11
+ ## [0.2.0] - 2026-08-11
12
+
13
+ ### Added
14
+
15
+ - `new-template` attribute / `newTemplate` prop and `createNew({ templateId?,
16
+ name? })` — start a template from scratch in stateless mode. The widget
17
+ supplies the empty document (`BLANK_TEMPLATE_XML`, exported from
18
+ `./protocol`), so a host with nothing stored yet needs no knowledge of the
19
+ `.fdt` format: the editor opens on its empty state, the user adds the first
20
+ layout, and the first `save` hands back a complete document to store.
21
+ `content` wins when both are set. Empty `content` still means "the host is
22
+ still fetching" and keeps the editor waiting — only the flag turns that into
23
+ a blank document.
24
+
25
+ ### Changed
26
+
27
+ - The demo's Base URL defaults to `https://design-templates.scaleflex.com`
28
+ instead of `http://localhost:3000`, so the published page is usable without
29
+ filling a field first. It also logs the full `.fdt` and `templateQuery` to the
30
+ console at both crossings — going into the editor, and coming back on save —
31
+ while the on-page panel keeps summarizing.
32
+
11
33
  ## [0.1.1] - 2026-08-10
12
34
 
13
35
  First published release. `<sfx-template-builder>` custom element, `./react`
package/README.md CHANGED
@@ -30,9 +30,11 @@
30
30
  - [Quick Start](#quick-start)
31
31
  - [Vanilla JS / Web Component](#vanilla-js--web-component)
32
32
  - [React](#react)
33
+ - [Hub session (internal)](#hub-session-internal)
33
34
  - [Modes](#modes)
34
35
  - [DAM-backed](#dam-backed-default)
35
36
  - [Stateless](#stateless)
37
+ - [Starting a template from scratch](#starting-a-template-from-scratch)
36
38
  - [Reporting a failed save](#reporting-a-failed-save)
37
39
  - [Configuration](#configuration)
38
40
  - [Attributes & properties](#attributes--properties)
@@ -77,6 +79,9 @@ protocol adapter.
77
79
  template](#security-template-guest-auth) when you have no Hub account to hand
78
80
  over per user.
79
81
  - **Inline or modal** — fill a box in your layout, or cover the viewport.
82
+ - **New templates without the format** — [`new-template`](#starting-a-template-from-scratch)
83
+ starts an empty document for the user to build; you only ever store what
84
+ comes back.
80
85
  - **Themeable** — one [brand colour](#brand-color) drives the editor's whole
81
86
  accent ramp; light, dark, or follow the OS.
82
87
  - **Origin-checked both ways** — the widget only accepts messages from the app
@@ -108,7 +113,7 @@ npm i @scaleflex/template-builder
108
113
  ### CDN
109
114
 
110
115
  ```html
111
- <script type="module" src="https://cdn.scaleflex.com/design-template-builder/0.1.1/template-builder.min.js"></script>
116
+ <script type="module" src="https://cdn.scaleflex.com/design-template-builder/0.2.0/template-builder.min.js"></script>
112
117
  ```
113
118
 
114
119
  The CDN bundle is self-registering — it defines `<sfx-template-builder>` on
@@ -134,6 +139,15 @@ load, with Lit bundled in. Pin the major version.
134
139
 
135
140
  ## Quick Start
136
141
 
142
+ You need two things from your Filerobot project: its **token**, and a
143
+ **security template** key — a named, permission-scoped credential you define
144
+ once, the same guest-auth mechanism the other Scaleflex widgets use. No Hub
145
+ account, and no user of yours ever needs a Scaleflex identity. See
146
+ [Authentication](#authentication) for how to scope one.
147
+
148
+ The template document stays on your side: you hand the widget its XML, and the
149
+ edit comes back to you on save. That is [stateless](#stateless) mode.
150
+
137
151
  ### Vanilla JS / Web Component
138
152
 
139
153
  ```html
@@ -143,16 +157,26 @@ load, with Lit bundled in. Pin the major version.
143
157
 
144
158
  <sfx-template-builder
145
159
  base-url="https://<your-design-templates-deployment>"
146
- token="FILEROBOT_TOKEN"
147
- sass-key="SASS_KEY"
148
- session-uuid="SESSION_UUID"
149
- template-id="TEMPLATE_UUID"
160
+ token="PROJECT_TOKEN"
161
+ sec-template="SEC_TEMPLATE_KEY"
162
+ stateless
150
163
  style="display:block;height:800px"
151
164
  ></sfx-template-builder>
152
165
 
153
166
  <script>
154
167
  const builder = document.querySelector('sfx-template-builder')
155
- builder.addEventListener('save', (e) => console.log('saved', e.detail)) // { uuid, name }
168
+
169
+ // 1 — hand it the template to edit
170
+ const { content, name } = await fetch(`/api/templates/${id}`).then((r) => r.json())
171
+ builder.load({ templateId: id, name, content })
172
+
173
+ // 2 — take the edit back and store it
174
+ builder.addEventListener('save', async (e) => {
175
+ const { templateId, content, name, templateQuery } = e.detail
176
+ const ok = await saveToYourApi(templateId, { content, name, templateQuery })
177
+ builder.confirmSave(ok) // false → the editor keeps its unsaved-changes warning
178
+ })
179
+
156
180
  builder.addEventListener('error', (e) => console.error(e.detail)) // { code, message }
157
181
  </script>
158
182
  ```
@@ -164,6 +188,38 @@ Size the element yourself — in `inline` mode it fills the box you give it.
164
188
  ```tsx
165
189
  import { TemplateBuilder } from '@scaleflex/template-builder/react'
166
190
 
191
+ <TemplateBuilder
192
+ stateless
193
+ baseUrl="https://<deployment>"
194
+ token={projectToken}
195
+ secTemplate={secTemplateKey}
196
+ templateId={id}
197
+ name={name}
198
+ content={xml}
199
+ style={{ height: 800 }}
200
+ onSave={async (data) => (await saveToYourApi(data)).ok}
201
+ />
202
+ ```
203
+
204
+ <!-- internal:start -->
205
+ ### Hub session (internal)
206
+
207
+ Scaleflex-side embeds inside the Hub authenticate with a session instead of a
208
+ security template, which unlocks DAM-backed storage and Hub-project features.
209
+ Mint the session server-side; never put a long-lived credential in client code.
210
+
211
+ ```html
212
+ <sfx-template-builder
213
+ base-url="https://<your-design-templates-deployment>"
214
+ token="FILEROBOT_TOKEN"
215
+ sass-key="SASS_KEY"
216
+ session-uuid="SESSION_UUID"
217
+ template-id="TEMPLATE_UUID"
218
+ style="display:block;height:800px"
219
+ ></sfx-template-builder>
220
+ ```
221
+
222
+ ```tsx
167
223
  <TemplateBuilder
168
224
  baseUrl="https://<deployment>"
169
225
  token={token}
@@ -174,6 +230,7 @@ import { TemplateBuilder } from '@scaleflex/template-builder/react'
174
230
  onSave={(data) => console.log(data)}
175
231
  />
176
232
  ```
233
+ <!-- internal:end -->
177
234
 
178
235
  ---
179
236
 
@@ -203,9 +260,8 @@ point.
203
260
  <sfx-template-builder
204
261
  stateless
205
262
  base-url="https://<deployment>"
206
- token="FILEROBOT_TOKEN"
207
- sass-key="SASS_KEY"
208
- session-uuid="SESSION_UUID"
263
+ token="PROJECT_TOKEN"
264
+ sec-template="SEC_TEMPLATE_KEY"
209
265
  style="display:block;height:800px"
210
266
  ></sfx-template-builder>
211
267
 
@@ -270,9 +326,8 @@ function TemplateEditor({ id }: { id: string }) {
270
326
  <TemplateBuilder
271
327
  stateless
272
328
  baseUrl="https://<deployment>"
273
- token={token}
274
- sassKey={sassKey}
275
- sessionUuid={sessionUuid}
329
+ token={projectToken}
330
+ secTemplate={secTemplateKey}
276
331
  // 2 — pass it in.
277
332
  templateId={id}
278
333
  content={tpl.content}
@@ -357,7 +412,8 @@ you navigated away from.
357
412
  Statelessness applies to the **document**, not to the infrastructure. The editor
358
413
  still needs an authenticated Filerobot tenant for:
359
414
 
360
- - **text rendering** — text layers are rasterized server-side,
415
+ - **text rendering** — text and shape layers are rasterized server-side (see
416
+ [the render round-trip](#why-the-editor-still-calls-a-server) below),
361
417
  - **fonts** — custom fonts are served from the tenant's `.studio/fonts/`,
362
418
  - **asset browsing and upload** — image layers are picked from the DAM,
363
419
  - **metadata variables and regional settings**.
@@ -370,6 +426,135 @@ Images referenced by a template may live on your own CDN, but the render
370
426
  service only fetches from allowlisted hosts — add yours to the deployment's
371
427
  `RENDER_ALLOWED_HOSTS_EXTRA`.
372
428
 
429
+ #### Why the editor still calls a server
430
+
431
+ This is the reason a stateless embed still needs a credential, so it is worth
432
+ being concrete about.
433
+
434
+ A `.fdt` template is not an image. The image only exists once someone requests
435
+ the template's CDN URL, and it is Filerobot that renders it there — server-side,
436
+ with ImageMagick:
437
+
438
+ ```
439
+ https://<tenant>.filerobot.com/<path>/<template>.fdt?<templateQuery>&force_format=png
440
+ ```
441
+
442
+ The editor's contract is that what you see while editing is what that URL will
443
+ return. That rules out drawing the text in the browser. Line breaking, kerning,
444
+ letter spacing, baseline placement, shrink-to-fit and antialiasing are FreeType
445
+ and ImageMagick behaviours; canvas `fillText` and DOM text go through the
446
+ browser's own shaping and hinting instead, so the same layer lands differently
447
+ in Chrome, Safari and Firefox — and differently from the export in all three. A
448
+ few pixels of drift is enough to move a headline off a product shot. There is
449
+ no JS library that reimplements that layout either: the only faithful
450
+ implementation of ImageMagick's text rendering is ImageMagick.
451
+
452
+ So the editor does not approximate the export — it runs the same engine.
453
+ Text and shape layers are rasterized by an ImageMagick 7 build hosted in your
454
+ `base-url` deployment (the export pipeline drives ImageMagick 7 from PHP; the
455
+ editor drives a WebAssembly build of it), and each layer comes back as a
456
+ transparent PNG that the canvas positions with CSS:
457
+
458
+ ```
459
+ browser — widget iframe app deployment (base-url)
460
+ ─────────────────────── ─────────────────────────
461
+ edit a text layer
462
+ │ batched across layers, debounced
463
+ ├────── POST /api/render-layers ─────▶ ImageMagick (WASM)
464
+ │ layers + fonts + variables ├─ resolve fonts: bundled,
465
+ │ │ then tenant /.studio/fonts
466
+ │ ├─ draw at 3×, downscale
467
+ ◀────── transparent PNG per layer ─────┘
468
+
469
+ └─ position / rotate / fade with CSS — no round trip
470
+ ```
471
+
472
+ Consequences you can observe from the outside:
473
+
474
+ - **Content edits cost a round trip; placement edits do not.** Text, font,
475
+ weight, colour, alignment, letter spacing and box size re-render. Dragging,
476
+ rotating and opacity are CSS transforms on the PNG already in the page, so
477
+ they stay at pointer speed.
478
+ - **Bursts collapse.** Requests are debounced (~300 ms) and batched across
479
+ layers, and an in-flight batch is aborted when you keep typing — so a
480
+ sentence typed at speed costs one render, not one per keystroke.
481
+ - **Layers are drawn at 3× and downscaled**, so preview antialiasing matches
482
+ the export rather than the browser's rasterizer.
483
+ - **Fonts are resolved server-side** against the tenant's `/.studio/fonts`
484
+ folder and cached there, so the page never downloads a rendering engine or a
485
+ font binary per weight. Image layers are the exception — they are plain
486
+ `<img>` elements, drawn by the browser.
487
+
488
+ **That endpoint is authenticated, and it has to be.** It fetches fonts and
489
+ images by URL on the server's behalf, so it is not open to anonymous callers:
490
+ `POST /api/render-layers` requires the credential the widget handed over, and
491
+ without a valid one it answers `401` and text and shape layers simply never
492
+ appear. The same credential authorizes the font list/upload calls and the asset
493
+ picker.
494
+
495
+ In DAM-backed mode a Hub session covers that. A stateless embed has no Hub
496
+ session to hand over — and that is exactly the gap a
497
+ [security template](#security-template-guest-auth) fills: a permission-scoped,
498
+ project-level guest credential that authorizes rendering, fonts and asset
499
+ browsing without authenticating any particular user, and without your users
500
+ existing in Filerobot at all.
501
+
502
+ ### Starting a template from scratch
503
+
504
+ A template your user has not created yet has no XML to pass in, and you should
505
+ not have to author one. In [stateless](#stateless) mode, set `new-template`
506
+ instead of `content` and the widget supplies the empty document itself.
507
+ (DAM-backed mode has its own new-template flow — leave `template-id` empty.)
508
+
509
+ ```html
510
+ <sfx-template-builder
511
+ stateless
512
+ new-template
513
+ template-name="Untitled"
514
+ base-url="https://<deployment>"
515
+ token="PROJECT_TOKEN"
516
+ sec-template="SEC_TEMPLATE_KEY"
517
+ style="display:block;height:800px"
518
+ ></sfx-template-builder>
519
+ ```
520
+
521
+ ```js
522
+ // Or imperatively, on an element that is already showing something else.
523
+ builder.createNew({ templateId: 'your-own-id-43', name: 'Untitled' })
524
+ ```
525
+
526
+ ```tsx
527
+ <TemplateBuilder stateless newTemplate templateName="Untitled" … />
528
+ ```
529
+
530
+ The editor opens on its empty state — *"No layouts yet. Click + Add to create
531
+ one."* — and the user picks the canvas size, background and preset there. Save
532
+ is refused until at least one layout exists, so the first `save` you receive
533
+ already carries a complete, well-formed `.fdt` document; store it as `content`
534
+ and every later open is the ordinary [load flow](#the-template-comes-from-your-api).
535
+
536
+ - **`templateId` is optional.** Pass one if your record already exists and you
537
+ want it echoed back; otherwise the `save` payload simply arrives without an
538
+ id and you allocate one when you store it.
539
+ - **`templateQuery` stays empty.** A new document has no layouts and no
540
+ variables, so there is no render for a query to select. You get one back on
541
+ the first save — persist it then.
542
+ - **`content` wins when both are set,** so a host that renders one element for
543
+ both cases can simply pass the XML when it has one.
544
+ - **Empty `content` on its own does not start a blank template.** It means "the
545
+ host has nothing yet" — the editor keeps waiting, which is what lets you
546
+ mount the builder while your fetch is still in flight. Only `new-template`
547
+ turns that wait into a document.
548
+
549
+ Calling `createNew()` again while the blank template is already open does
550
+ nothing: resending would discard whatever the user has built since. Close and
551
+ reopen the editor to genuinely start over.
552
+
553
+ If you would rather ship your own starting point — a house style, a standard
554
+ canvas size, a locked logo layer — pass it as ordinary `content`. A starter
555
+ template is just a template, and `BLANK_TEMPLATE_XML` is exported from
556
+ `@scaleflex/template-builder/protocol` if you want the empty document as a base.
557
+
373
558
  ### Reporting a failed save
374
559
 
375
560
  The editor clears its unsaved-changes state as soon as it posts `save` —
@@ -411,8 +596,9 @@ without it a failed write is invisible to the user.
411
596
  | `company-uuid`, `project-uuid` | no | Company / project scoping (session auth only) |
412
597
  | `template-id` / `templateId` | no | DAM-backed: Filerobot uuid to edit, empty opens the new-template flow. Stateless: opaque id echoed back on `save` |
413
598
  | `mode` | no | `inline` (default; size the element) or `modal` (fullscreen overlay, starts closed — call `open()`) |
414
- | `stateless` | no | Pass the template in and take it back out instead of using the DAM (see [Stateless](#stateless)). Requires `content` |
599
+ | `stateless` | no | Pass the template in and take it back out instead of using the DAM (see [Stateless](#stateless)). Requires `content`, or `new-template` |
415
600
  | `content` (property only) | stateless | The template to edit, as `.fdt` XML. Assigning a new value loads it into a running editor |
601
+ | `new-template` / `newTemplate` | no | Stateless: open on a new, empty template instead of supplying `content` — the widget provides the blank document. Ignored when `content` is set. See [Starting a template from scratch](#starting-a-template-from-scratch) |
416
602
  | `template-name` / `templateName` | no | Stateless: header title |
417
603
  | `template-query` / `templateQuery` | no | Stateless: the render to open on — the `templateQuery` from the last save. Empty uses the XML's `default=` values. |
418
604
  | `brand-color` / `brandColor` | no | Accent colour for the editor chrome, `#rgb` / `#rrggbb` |
@@ -432,18 +618,27 @@ something to hardcode in a public bundle.
432
618
  | Metadata fields, regional variants, project branding | yes | **empty** |
433
619
  | Rendering, fonts, asset picker | yes | yes, within the template's scope |
434
620
 
621
+ <!-- internal:start -->
435
622
  #### Hub session
436
623
 
437
624
  Mint the Hub session **server-side** and inject `session-uuid` / `sass-key` /
438
625
  `token` into your page. Issue short-lived per-user sessions; never embed a
439
626
  long-lived master credential in client-side code.
627
+ <!-- internal:end -->
440
628
 
441
629
  #### Security template (guest auth)
442
630
 
443
631
  A Filerobot **security template** is a named, permission-scoped credential you
444
632
  define once in your Filerobot project — the same guest-auth mechanism the other
445
633
  Scaleflex widgets use. Hand one to the widget and no Hub session is involved at
446
- all:
634
+ all.
635
+
636
+ It is what makes a stateless embed work without Hub accounts. Even when the
637
+ document never leaves your side, the editor rasterizes every text and shape
638
+ layer on the server to stay pixel-identical to the CDN render, and resolves
639
+ fonts and assets from your tenant — all of it authenticated. See
640
+ [why the editor still calls a server](#why-the-editor-still-calls-a-server) for
641
+ what those calls are.
447
642
 
448
643
  ```html
449
644
  <sfx-template-builder
@@ -469,6 +664,16 @@ all:
469
664
  The app exchanges the key for a short-lived access key itself and renews it when
470
665
  it expires, so the embed does not die mid-session.
471
666
 
667
+ **Scoping the template.** Grant it `LIST` on the folders you want browsable,
668
+ plus `LIST` + `UPLOAD` on `/.studio/fonts*` if users are to see or add custom
669
+ fonts — anything the template cannot reach simply isn't there. Prefer a short
670
+ TTL: the app re-exchanges the key when it expires, so a short-lived template
671
+ costs you nothing and limits the blast radius if one leaks.
672
+
673
+ If the key is rejected — revoked, wrong project token, typo — the widget emits
674
+ `error` with code `auth`.
675
+
676
+ <!-- internal:start -->
472
677
  **What it costs.** A security template authenticates *nobody in particular*: no
473
678
  user identity, no Hub project behind it. That has consequences worth knowing
474
679
  before you pick it:
@@ -479,20 +684,20 @@ before you pick it:
479
684
  - **Hub-project features come back empty** — metadata fields, regional variants
480
685
  and dynamic fields have no model to read, and project branding does not apply
481
686
  (theme the chrome with `brand-color` / `theme` instead).
482
- - **Its scope is the app's scope.** Grant the template `LIST` on the folders you
483
- want browsable, plus `LIST` + `UPLOAD` on `/.studio/fonts*` if users are to
484
- see or add custom fonts. Anything it cannot reach simply isn't there.
485
- - **Prefer short TTLs.** The app re-exchanges on expiry, so a short-lived
486
- template costs you nothing but limits the blast radius of a leaked key.
487
-
488
- If the key is rejected — revoked, wrong project token, typo — the widget emits
489
- `error` with code `auth`.
687
+ <!-- internal:end -->
490
688
 
491
689
  ### Origin registration
492
690
 
493
691
  Your page's origin must be in the deployment's `frame-ancestors` allowlist
494
692
  (`NEXT_PUBLIC_TRUSTED_HUB_ORIGINS`), otherwise the browser refuses to render the
495
- iframe and the widget reports `handshake-timeout`.
693
+ iframe Chrome shows "refused to connect" in place of the editor, and the
694
+ widget reports `handshake-timeout`.
695
+
696
+ A deployment allows `'self'`, `https://*.scaleflex.com`,
697
+ `https://*.filerobot.com` and `http://localhost:5173` (the demo's dev server)
698
+ out of the box, plus whatever its `NEXT_PUBLIC_TRUSTED_HUB_ORIGINS` names. Your
699
+ own domain has to be added there — the list is baked in at build time, so it
700
+ takes a rebuild of the app, not just a restart.
496
701
 
497
702
  ### Cookies
498
703
 
@@ -509,6 +714,7 @@ without CHIPS support that block third-party cookies will fail with `auth` or
509
714
  | `open(templateId?)` | Open the editor, loading the iframe. Optionally switch template first. |
510
715
  | `close()` | Close the editor and unload the iframe. Does not emit `close`. |
511
716
  | `load({ content, templateId?, name?, templateQuery? })` | Stateless: load a template, opening the editor if needed. `templateQuery` picks the render to open on — see [About `templateQuery`](#about-templatequery). |
717
+ | `createNew({ templateId?, name? })` | Stateless: open on a new, empty template — no XML needed. See [Starting a template from scratch](#starting-a-template-from-scratch). |
512
718
  | `confirmSave(ok, message?)` | Stateless: report whether you persisted the content. See [Reporting a failed save](#reporting-a-failed-save). |
513
719
 
514
720
  **Read-only properties:** `status` (`idle` \| `loading` \| `ready` \| `error`),
@@ -636,10 +842,12 @@ blocks third-party cookies the editor cannot authenticate and the widget reports
636
842
 
637
843
  ---
638
844
 
845
+ <!-- internal:start -->
639
846
  ## Development
640
847
 
641
848
  ```bash
642
- yarn dev:demo # demo site (expects the app on http://localhost:3000)
849
+ yarn dev:demo # demo site (defaults to the deployed app; point Base URL at
850
+ # http://localhost:3000 to drive a local one)
643
851
  yarn test # vitest
644
852
  yarn typecheck # tsc --noEmit
645
853
  yarn build # dist/ — npm artifact (ESM + CJS + types)
@@ -666,7 +874,18 @@ the README reloads the dev server.
666
874
  the folder can be zipped and handed to a client, or dropped behind any static
667
875
  host at any path — GitHub Pages, S3, a subdirectory of an existing site. The
668
876
  only thing it needs at runtime is a reachable `design-templates-app` for the
669
- demo page's Base URL field to point at.
877
+ demo page's Base URL field to point at; it defaults to
878
+ `https://design-templates.scaleflex.com`.
879
+
880
+ That default frames successfully only from an origin that deployment allows —
881
+ `'self'`, `*.scaleflex.com`, `*.filerobot.com` and `http://localhost:5173`,
882
+ which covers both the demo as published to the CDN and `yarn dev:demo`. Serving
883
+ it anywhere else — another port (`yarn preview:demo` uses 4173), or a copy on
884
+ your own domain — gets "refused to connect" until that origin joins the
885
+ deployment's allowlist, which is baked in at build time and so needs a rebuild
886
+ (see [Origin registration](#origin-registration)). Driving a *locally* running
887
+ app has the same requirement in reverse: `'self'` does not cover
888
+ `localhost:5173` framing `localhost:3000`, but the default list now does.
670
889
 
671
890
  The demo page takes its XML from a URL, from a paste, or from a picker listing
672
891
  the `.fdt` files in the project its credentials point at, which fills the id,
@@ -707,11 +926,23 @@ npm publish, git commit + tag + push. It needs a `.env.local` in this package:
707
926
  ```env
708
927
  FILEROBOT_CDN_TOKEN=scaleflex
709
928
  FILEROBOT_CDN_SECU=<secu key>
710
- FILEROBOT_CDN_FOLDER=/plugins/scaleflex/template-builder/{version}/
929
+ FILEROBOT_CDN_FOLDER=/plugins/scaleflex/design-template-builder/{version}/
711
930
  ```
712
931
 
713
932
  Update [CHANGELOG.md](./CHANGELOG.md) before releasing.
714
933
 
934
+ `yarn release:demo` publishes the demo site into `<that folder>/demo/`, next to
935
+ the bundle it demonstrates. `yarn release:demo:probe` sends a single file first,
936
+ reporting the content-type the CDN serves it as. The pages are built as
937
+ self-contained HTML with their JS and CSS inlined, because the CDN project
938
+ refuses `.js` uploads.
939
+
940
+ Note the CDN caches for 24h: re-uploading over a path that has already been
941
+ fetched keeps serving the old copy until it expires. Version folders are the
942
+ way around it — don't overwrite a published one.
943
+
944
+ <!-- internal:end -->
945
+
715
946
  ---
716
947
 
717
948
  ## Claude Code Integration
package/dist/define.cjs CHANGED
@@ -1,2 +1,2 @@
1
- "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("./template-builder-CSyPZni9.cjs");typeof customElements<"u"&&!customElements.get("sfx-template-builder")&&customElements.define("sfx-template-builder",e.SfxTemplateBuilder);exports.SfxTemplateBuilder=e.SfxTemplateBuilder;
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("./template-builder-CK2Zlo7E.cjs");typeof customElements<"u"&&!customElements.get("sfx-template-builder")&&customElements.define("sfx-template-builder",e.SfxTemplateBuilder);exports.SfxTemplateBuilder=e.SfxTemplateBuilder;
2
2
  //# sourceMappingURL=define.cjs.map
package/dist/define.js CHANGED
@@ -1,4 +1,4 @@
1
- import { S as e } from "./template-builder-S33H_d5T.js";
1
+ import { S as e } from "./template-builder-De0hRO4s.js";
2
2
  typeof customElements < "u" && !customElements.get("sfx-template-builder") && customElements.define("sfx-template-builder", e);
3
3
  export {
4
4
  e as SfxTemplateBuilder
package/dist/index.cjs CHANGED
@@ -1,2 +1,2 @@
1
- "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const E=require("./template-builder-CSyPZni9.cjs");exports.AUTH_MODES=E.AUTH_MODES;exports.BRAND_COLOR_PATTERN=E.BRAND_COLOR_PATTERN;exports.BUILDER_CLOSE=E.BUILDER_CLOSE;exports.BUILDER_CONTENT=E.BUILDER_CONTENT;exports.BUILDER_CONTENT_REQUEST=E.BUILDER_CONTENT_REQUEST;exports.BUILDER_DIRTY=E.BUILDER_DIRTY;exports.BUILDER_ERROR=E.BUILDER_ERROR;exports.BUILDER_OPEN=E.BUILDER_OPEN;exports.BUILDER_READY=E.BUILDER_READY;exports.BUILDER_SAVE=E.BUILDER_SAVE;exports.EMBED_PARAMS=E.EMBED_PARAMS;exports.EMBED_ROUTE=E.EMBED_ROUTE;exports.HOST_LOAD=E.HOST_LOAD;exports.HOST_SAVED=E.HOST_SAVED;exports.PROTOCOL_VERSION=E.PROTOCOL_VERSION;exports.SfxTemplateBuilder=E.SfxTemplateBuilder;exports.builderRoute=E.builderRoute;
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const E=require("./template-builder-CK2Zlo7E.cjs");exports.AUTH_MODES=E.AUTH_MODES;exports.BLANK_TEMPLATE_XML=E.BLANK_TEMPLATE_XML;exports.BRAND_COLOR_PATTERN=E.BRAND_COLOR_PATTERN;exports.BUILDER_CLOSE=E.BUILDER_CLOSE;exports.BUILDER_CONTENT=E.BUILDER_CONTENT;exports.BUILDER_CONTENT_REQUEST=E.BUILDER_CONTENT_REQUEST;exports.BUILDER_DIRTY=E.BUILDER_DIRTY;exports.BUILDER_ERROR=E.BUILDER_ERROR;exports.BUILDER_OPEN=E.BUILDER_OPEN;exports.BUILDER_READY=E.BUILDER_READY;exports.BUILDER_SAVE=E.BUILDER_SAVE;exports.EMBED_PARAMS=E.EMBED_PARAMS;exports.EMBED_ROUTE=E.EMBED_ROUTE;exports.HOST_LOAD=E.HOST_LOAD;exports.HOST_SAVED=E.HOST_SAVED;exports.PROTOCOL_VERSION=E.PROTOCOL_VERSION;exports.SfxTemplateBuilder=E.SfxTemplateBuilder;exports.builderRoute=E.builderRoute;
2
2
  //# sourceMappingURL=index.cjs.map
package/dist/index.js CHANGED
@@ -1,21 +1,22 @@
1
- import { A as a, B as s, a as _, b as D, c as O, d as T, e as B, f as L, g as S, h as U, E as A, i as I, H as N, j as e, P as C, S as P, k as r } from "./template-builder-S33H_d5T.js";
1
+ import { A as a, B as _, a as s, b as D, c as O, d as T, e as L, f as B, g as A, h as S, i as U, E as I, j as N, H as e, k as M, P, S as C, l } from "./template-builder-De0hRO4s.js";
2
2
  export {
3
3
  a as AUTH_MODES,
4
+ _ as BLANK_TEMPLATE_XML,
4
5
  s as BRAND_COLOR_PATTERN,
5
- _ as BUILDER_CLOSE,
6
- D as BUILDER_CONTENT,
7
- O as BUILDER_CONTENT_REQUEST,
8
- T as BUILDER_DIRTY,
6
+ D as BUILDER_CLOSE,
7
+ O as BUILDER_CONTENT,
8
+ T as BUILDER_CONTENT_REQUEST,
9
+ L as BUILDER_DIRTY,
9
10
  B as BUILDER_ERROR,
10
- L as BUILDER_OPEN,
11
+ A as BUILDER_OPEN,
11
12
  S as BUILDER_READY,
12
13
  U as BUILDER_SAVE,
13
- A as EMBED_PARAMS,
14
- I as EMBED_ROUTE,
15
- N as HOST_LOAD,
16
- e as HOST_SAVED,
17
- C as PROTOCOL_VERSION,
18
- P as SfxTemplateBuilder,
19
- r as builderRoute
14
+ I as EMBED_PARAMS,
15
+ N as EMBED_ROUTE,
16
+ e as HOST_LOAD,
17
+ M as HOST_SAVED,
18
+ P as PROTOCOL_VERSION,
19
+ C as SfxTemplateBuilder,
20
+ l as builderRoute
20
21
  };
21
22
  //# sourceMappingURL=index.js.map
@@ -150,6 +150,30 @@ export interface HostLoadMessage {
150
150
  type: typeof HOST_LOAD;
151
151
  data: HostLoadData;
152
152
  }
153
+ /**
154
+ * An empty `.fdt` document: no layouts, no layers, no variables. What the
155
+ * widget sends as `HOST_LOAD` content when the host asked for a new template
156
+ * (`new-template`) instead of supplying one, so starting from scratch costs a
157
+ * host no knowledge of the template format.
158
+ *
159
+ * The editor opens on its empty state — "No layouts yet. Click + Add to create
160
+ * one." — and the user picks the canvas size there. Save is refused until a
161
+ * layout exists, and hands back a fully-formed document serialized by the app,
162
+ * not this skeleton.
163
+ *
164
+ * Sent as ordinary `HOST_LOAD` content rather than a new message so it works
165
+ * against app deployments that predate this widget version: the document is
166
+ * the whole signal, and every app that can parse a template can parse this.
167
+ *
168
+ * `version` tracks the app's `TEMPLATE_VERSION` for the benefit of whoever
169
+ * reads this next: nothing consumes it. The parser never looks at it, and the
170
+ * backend never sees this document — the app refuses to save a template with
171
+ * no layouts, so what reaches the render pipeline was re-serialized by the app
172
+ * with a layout present. A widget lagging the app by a version still loads.
173
+ * `design-templates` pins the pair in
174
+ * `src/lib/xml/__tests__/blank-template.test.ts`.
175
+ */
176
+ export declare const BLANK_TEMPLATE_XML: string;
153
177
  /**
154
178
  * Stateless mode only (protocol v2). Reports whether the host managed to
155
179
  * persist the content it received in `BUILDER_CONTENT`.
package/dist/react.cjs CHANGED
@@ -1,2 +1,2 @@
1
- "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const o=require("react");require("./define.cjs");const h=o.forwardRef(function(y,v){const{className:U,style:b,onReady:c,onOpen:d,onClose:m,onSave:i,onError:p,onDirtyChange:f,...e}=y,l=o.useRef(null);return o.useImperativeHandle(v,()=>l.current,[]),o.useLayoutEffect(()=>{const t=l.current;t&&(t.baseUrl=e.baseUrl,t.token=e.token,t.sassKey=e.sassKey??"",t.sessionUuid=e.sessionUuid??"",t.secTemplate=e.secTemplate??"",t.companyUuid=e.companyUuid??"",t.projectUuid=e.projectUuid??"",t.templateId=e.templateId??"",t.mode=e.mode??"inline",t.stateless=e.stateless??!1,t.templateName=e.templateName??"",t.templateQuery=e.templateQuery??"",t.brandColor=e.brandColor??"",t.theme=e.theme??"",t.content=e.content??"",e.readyTimeout!==void 0&&(t.readyTimeout=e.readyTimeout))},[e.baseUrl,e.token,e.sassKey,e.sessionUuid,e.secTemplate,e.companyUuid,e.projectUuid,e.templateId,e.mode,e.stateless,e.content,e.templateName,e.templateQuery,e.brandColor,e.theme,e.readyTimeout]),o.useLayoutEffect(()=>{const t=l.current;if(!t)return;const u=[],a=(r,s)=>{if(!s)return;const n=(T=>s(T.detail));t.addEventListener(r,n),u.push([r,n])};if(a("ready",c),a("open",d),a("close",m),a("error",p),a("dirtychange",f),i){const r=(s=>{Promise.resolve().then(()=>i(s.detail)).then(n=>t.confirmSave(n!==!1)).catch(n=>{console.error("[sfx-template-builder] onSave failed:",n),t.confirmSave(!1)})});t.addEventListener("save",r),u.push(["save",r])}return()=>{for(const[r,s]of u)t.removeEventListener(r,s)}},[c,d,m,i,p,f]),o.createElement("sfx-template-builder",{ref:l,class:U,style:b})});exports.TemplateBuilder=h;
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const a=require("react");require("./define.cjs");const h=a.forwardRef(function(y,v){const{className:T,style:U,onReady:m,onOpen:c,onClose:d,onSave:i,onError:p,onDirtyChange:f,...e}=y,l=a.useRef(null);return a.useImperativeHandle(v,()=>l.current,[]),a.useLayoutEffect(()=>{const t=l.current;t&&(t.baseUrl=e.baseUrl,t.token=e.token,t.sassKey=e.sassKey??"",t.sessionUuid=e.sessionUuid??"",t.secTemplate=e.secTemplate??"",t.companyUuid=e.companyUuid??"",t.projectUuid=e.projectUuid??"",t.templateId=e.templateId??"",t.mode=e.mode??"inline",t.stateless=e.stateless??!1,t.templateName=e.templateName??"",t.templateQuery=e.templateQuery??"",t.brandColor=e.brandColor??"",t.theme=e.theme??"",t.newTemplate=e.newTemplate??!1,t.content=e.content??"",e.readyTimeout!==void 0&&(t.readyTimeout=e.readyTimeout))},[e.baseUrl,e.token,e.sassKey,e.sessionUuid,e.secTemplate,e.companyUuid,e.projectUuid,e.templateId,e.mode,e.stateless,e.content,e.newTemplate,e.templateName,e.templateQuery,e.brandColor,e.theme,e.readyTimeout]),a.useLayoutEffect(()=>{const t=l.current;if(!t)return;const u=[],o=(s,r)=>{if(!r)return;const n=(b=>r(b.detail));t.addEventListener(s,n),u.push([s,n])};if(o("ready",m),o("open",c),o("close",d),o("error",p),o("dirtychange",f),i){const s=(r=>{Promise.resolve().then(()=>i(r.detail)).then(n=>t.confirmSave(n!==!1)).catch(n=>{console.error("[sfx-template-builder] onSave failed:",n),t.confirmSave(!1)})});t.addEventListener("save",s),u.push(["save",s])}return()=>{for(const[s,r]of u)t.removeEventListener(s,r)}},[m,c,d,i,p,f]),a.createElement("sfx-template-builder",{ref:l,class:T,style:U})});exports.TemplateBuilder=h;
2
2
  //# sourceMappingURL=react.cjs.map