@bygga.dev/editor 1.8.4 → 1.9.3

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
@@ -105,6 +105,15 @@ assistive technology. Settable as the attribute or the `.locale` property. An
105
105
  unsupported value warns and falls back to the default. The supported set is exported
106
106
  as `SUPPORTED_LOCALES` / `DEFAULT_LOCALE`.
107
107
 
108
+ ### `permission` attribute
109
+ What this user may do with [locked blocks](#locked-blocks): `admin` (may lock, unlock,
110
+ and edit a locked block like any other) or `creator` (a content creator: may edit the
111
+ text inside a locked block and nothing else about it). Reactive; settable as the
112
+ attribute or the `.permission` property. **Absent — or anything unrecognised — is
113
+ `creator`**, the permission that can do least, so an embed that never mentions it offers
114
+ no lock controls at all, and a typo'd `admn` warns rather than handing every user the
115
+ keys.
116
+
108
117
  ### `config` property *(required)*
109
118
  Host callbacks. Assign it **before** connecting the element (the editor throws at
110
119
  mount if `uploadImage` is missing). It's a plain object, re-read on assignment, so
@@ -176,10 +185,11 @@ backend must later resolve every id it sees; see
176
185
  [Sending and publishing](#sending-and-publishing).
177
186
 
178
187
  ### `brand` property
179
- The colours and fonts your embed permits — a **menu for the pickers, never a rewrite of
180
- a document**. It bounds what can be picked next; it never rejects or edits a document,
181
- and a value already in a document that the Brand doesn't contain keeps rendering. All
182
- halves are optional; an absent one is unrestricted.
188
+ The colours and fonts your embed permits, the look new blocks start with, values for
189
+ your block plugins, and which block tiles to offer — a **menu, never a rewrite of a
190
+ document**. It bounds and seeds what is added next; it never rejects or edits a
191
+ document, and a value already in a document that the Brand doesn't contain keeps
192
+ rendering. All halves are optional; an absent one is unrestricted.
183
193
 
184
194
  ```js
185
195
  editor.brand = {
@@ -224,7 +234,56 @@ never appears. Set `type: 'file'` on those:
224
234
  The email-safe faces are `arial`, `tahoma`, `trebuchet`, `verdana`, `georgia`,
225
235
  `times`, `courier`. Invalid entries (an unparseable colour, a non-http(s) font href)
226
236
  are dropped with a `[bygga-editor]` console warning rather than throwing — one typo
227
- must not take down your editor. The Transparent option stays available beside a
237
+ must not take down your editor.
238
+
239
+ #### Block styles, plugin values and hidden tiles
240
+
241
+ ```js
242
+ editor.brand = {
243
+ ...brand,
244
+ // The look a newly placed Header, Text or Button block starts with. Every field is
245
+ // optional. `font` is an email-safe face or the `name` of one of your customFonts;
246
+ // `fontSize` is one of 10, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48; colours are hex.
247
+ styles: {
248
+ header: { font: 'georgia', fontSize: 32, color: '#2f2a72', bold: true,
249
+ align: 'center', level: 2, padding: 8, background: '#ffffff',
250
+ margin: { top: 0, right: 0, bottom: 16, left: 0 } },
251
+ text: { font: 'arial', fontSize: 16, color: '#333333', align: 'left',
252
+ paragraphSpacing: 8 },
253
+ button: { background: '#2f2a72', textColor: '#ffffff', font: 'arial',
254
+ fontSize: 16, bold: true, align: 'center',
255
+ padding: { top: 12, right: 24, bottom: 12, left: 24 },
256
+ // One number rounds every corner; name the corners for a shape.
257
+ borderRadius: { topLeft: 0, topRight: 12, bottomRight: 0, bottomLeft: 0 },
258
+ border: { width: 2, color: '#064c70', style: 'solid' } },
259
+ },
260
+ // Anything your block plugins read to style their own blocks (see Block plugins).
261
+ custom: { cardRadius: 8, accent: '#a28c67' },
262
+ // Block tiles not to offer: built-in types and/or plugin names.
263
+ hiddenBlocks: ['html', 'acme.legacy-banner'],
264
+ }
265
+ ```
266
+
267
+ Each style can also set the block's box — `margin` and `padding` (a number or
268
+ `{ top, right, bottom, left }`), `borderRadius` (a number, or
269
+ `{ topLeft, topRight, bottomRight, bottomLeft }`), and `border`
270
+ (`{ width, color?, style? }`, `style` one of `solid`, `dashed`, `dotted`) — plus
271
+ `underline` for header and text, and `paragraphSpacing` (`0`, `4`, `8`, `12`, `16`,
272
+ `20`, `24` or `32` px) for text.
273
+
274
+ A style is a **starting value, written into the block when it is placed** — exactly as
275
+ if the author had chosen each setting by hand. Revising your Brand changes the blocks
276
+ placed next, never the ones already in your documents, and the compiled email knows
277
+ nothing about Brands. A header's title and a button carry their whole style from the
278
+ moment they appear; a text block is placed with its alignment, background and padding,
279
+ and the face, size, colour and weight are applied to what the author types into an
280
+ empty paragraph (a mark needs text to sit on). The author can change any of it
281
+ afterwards, as with any block.
282
+
283
+ A hidden tile only narrows the menu: a hidden block type already in a document still
284
+ renders and edits. An entry naming neither a built-in type nor a registered plugin is
285
+ warned about, as is any style field the editor can't use — that field is dropped and
286
+ the rest of the style kept. The Transparent option stays available beside a
228
287
  restricted palette, since "no fill" is a structural choice rather than a brand colour.
229
288
 
230
289
  A custom face renders in the editor and wherever the compiled HTML is shown in a real
@@ -235,7 +294,7 @@ carries what it uses, so it keeps rendering even if you later change the registr
235
294
  ### `savedBlocks` property
236
295
  The Saved block library this embed offers — see [Saved blocks](#saved-blocks). An
237
296
  array property (arrays can't arrive through an attribute). This is the **only** thing
238
- the Saved tab renders; the editor keeps no library of its own.
297
+ the Saved blocks section renders; the editor keeps no library of its own.
239
298
 
240
299
  ### Events
241
300
  Events do not bubble — listen on the element itself.
@@ -246,6 +305,34 @@ Events do not bubble — listen on the element itself.
246
305
  editor dirty.
247
306
  - **`dirtychange`** — fired when the unsaved-changes state flips; `event.detail[0]` is
248
307
  a boolean. Drives, e.g., a Save button's enabled state.
308
+ - **`autosave`** — fired when the author pauses after editing (about five seconds of
309
+ quiet), or after about thirty seconds of editing without a pause; `event.detail[0]`
310
+ is `{ document, digest }`. Never per keystroke (that is `change`), never for a
311
+ document with nothing unsaved, and never twice for the same content. The editor saves
312
+ nothing itself: persist the payload and call `markSaved()`, or call `save()` to store
313
+ it with its compiled HTML — though each `save()` spends a compile from your licence's
314
+ quota. Edits made within the last pause have not autosaved yet, so guard page exits
315
+ with `isDirty()` (a `beforeunload` handler) rather than relying on the event alone.
316
+
317
+ `digest` fingerprints the document's *content* — SHA-256, as lowercase hex, over its
318
+ [RFC 8785](https://www.rfc-editor.org/rfc/rfc8785) canonical JSON — so you can skip a
319
+ write when you already hold that content (another tab saved it, or your last save
320
+ did). Equal content always has an equal digest, whatever order your storage keeps the
321
+ keys in. Keep the digest of what you last stored, or compute it for any document with
322
+ the exported `documentDigest(document)`; a backend can compute the same value with any
323
+ JCS library.
324
+
325
+ ```js
326
+ import { documentDigest } from '@bygga.dev/editor'
327
+
328
+ let storedDigest = await documentDigest(storedDocument)
329
+ editor.addEventListener('autosave', async (event) => {
330
+ const [{ document, digest }] = event.detail
331
+ if (digest === storedDigest) return // already saved — nothing changed
332
+ await api.saveDraft(document)
333
+ storedDigest = digest
334
+ })
335
+ ```
249
336
  - **`licenseerror`** — fired once when the editor refuses to open; `event.detail[0]` is
250
337
  `{ reason }`, one of `key`, `domain`, `origin`, `malformed`, `unreachable`,
251
338
  `invalid`, `insecure-context`. The panel and a fuller `console.error` diagnostic are
@@ -321,11 +408,15 @@ editor.savedBlocks = rows.map(parseSavedBlock)
321
408
 
322
409
  `id` and `name` are ordinary strings you can key rows by and show in your own admin;
323
410
  `content` is an opaque blob to write verbatim and hand back untouched. Resolving
324
- `saveBlock` does **not** put the block in the Saved tab — `savedBlocks` is the only
411
+ `saveBlock` does **not** put the block in the library — `savedBlocks` is the only
325
412
  truth the editor renders, so reassign it. A malformed or duplicate-id entry is dropped
326
413
  with a warning rather than taking down the editor. Entries are offered only in
327
414
  documents of the kind they were saved from.
328
415
 
416
+ The library appears under the block tiles, in view whenever they are. A Saved block
417
+ keeps any [lock](#locked-blocks) it was saved with, so an admin can curate locked
418
+ sections — a masthead, a legal footer — for content creators to drop in.
419
+
329
420
  ## Block plugins
330
421
 
331
422
  Custom block types — a video embed, a product card — registered per embed through
@@ -345,6 +436,45 @@ deployment a static-file update that rebuilds nothing. The contract is `BlockPlu
345
436
  as permanent and namespace it (`acme.product-card`). `email()` must be deterministic
346
437
  and `data` must be plain JSON — the editor re-bakes on load and on every change.
347
438
 
439
+ **The Brand reaches a plugin where it chooses values.** `create(context)` receives
440
+ `context.brand` — your Brand after validation: `colors`, `fonts`, `customFonts`,
441
+ `styles`, `custom` (whatever you put in `brand.custom`), and `css`, the block styles
442
+ as ready-to-use CSS — so a new block can start on your look; the settings contexts
443
+ carry the same `brand`, to offer your palette or a "reset to brand". `css.header`
444
+ is how a custom header of your own takes the look the Brand gives the built-in one:
445
+ `css.header.cssText` is a string like `font-family:Georgia, 'Times New Roman', serif;font-size:32px`
446
+ to write into your heading's `style` (escape it like any attribute value), with each
447
+ property also available on its own. A custom font written there is carried into the
448
+ compiled email automatically, as long as your baked HTML names it. Copy what you use into the block's `data`: `email()` and `preview()`
449
+ deliberately do not receive the Brand, because a block's HTML must follow from its
450
+ stored data alone — otherwise the same document would bake differently in two embeds,
451
+ and revising your Brand would silently rewrite every document it touched.
452
+
453
+ ```js
454
+ create(context) {
455
+ return { title: '', accent: context.brand.custom.accent ?? context.brand.colors[0] }
456
+ },
457
+ ```
458
+
459
+ ## Locked blocks
460
+
461
+ An admin can **lock** a columns block from its settings. For a content creator (the
462
+ `creator` [permission](#permission-attribute)) a locked block is fixed: it can't be
463
+ moved, duplicated, removed, restyled or relaid out, nothing can be added to or taken
464
+ out of it, and its images, buttons and other blocks can't be changed — but **the text
465
+ inside it stays editable** — including a button's label, and whether it is bold,
466
+ through the button's own cut-down settings. A block that *holds* a locked block can't be moved,
467
+ duplicated, removed or hidden (per device or per recipient) by a creator either, since
468
+ that would take the lock with it, and a layout change that would drop a column holding
469
+ one isn't offered. An admin edits
470
+ locked blocks like any other and sees the same "Locked" marker.
471
+
472
+ The lock is stored on the block (`locked: true`) and travels with the document and
473
+ with Saved blocks. It is **guidance for the people editing, not a security boundary**:
474
+ the editor enforces it in its own UI, but you receive the whole document on every
475
+ save, so anything that must hold against a determined user has to be checked where
476
+ you store documents.
477
+
348
478
  ## AI assistance
349
479
 
350
480
  The editor can offer authors AI help on the text they're writing — but it ships **the UI
@@ -363,7 +493,7 @@ editor.config = {
363
493
  // one off means removing it here — there is no separate flag to keep in step.
364
494
  actions: ['proofread', 'rewrite', 'shorten'],
365
495
 
366
- // Called with the author's selection. Answer with the replacement text.
496
+ // Called once per paragraph of the author's selection. Answer with its replacement text.
367
497
  async run({ action, text, locale }) {
368
498
  const res = await fetch('/api/ai', { // YOUR endpoint, your model, your key
369
499
  method: 'POST',
@@ -383,8 +513,11 @@ editor doesn't know is dropped with a console warning, and an empty `actions` ar
383
513
  means no controls — same as no `ai` at all.
384
514
 
385
515
  **What the author sees:** an AI button in the text toolbar listing exactly your declared
386
- actions. It runs on their selection, or on the whole block when nothing is selected. The
387
- button is disabled while a request is in flight.
516
+ actions. It runs on their selection, or on the whole block when nothing is selected, one
517
+ paragraph at a time: each paragraph or heading is its own `run` call, all sent at once, so
518
+ a block keeps its paragraphs, heading level, line breaks and styling. The answers land
519
+ together as one undo step, around anything the author typed while waiting. The button is
520
+ disabled while requests are in flight.
388
521
 
389
522
  ### Implementing the endpoint
390
523
 
@@ -432,29 +565,28 @@ export async function handleAi(req, res) {
432
565
  if (text.length > 5000) return res.status(413).json({ error: 'selection too long' })
433
566
 
434
567
  const message = await client.messages.parse({
435
- model: 'claude-opus-5',
568
+ model: 'claude-opus-5-5',
436
569
  // These are short transforms on an email selection; a low cap bounds both latency
437
570
  // and a runaway bill. Raise it if you allow long selections.
438
571
  max_tokens: 4000,
439
572
  system: [
440
573
  `You edit marketing email copy. ${instruction}`,
441
- `Reply in the same language as the input (the author is writing in "${locale}").`,
574
+ `Reply in the same language as the input (the author's interface language is "${locale}").`,
442
575
  'Return ONLY the edited text — no commentary, no explanation, no quotes around it.',
443
576
  'Preserve any {{merge_tag}} placeholders EXACTLY as they appear, including spelling and position.',
444
- 'Return plain text: no Markdown, no HTML.',
577
+ 'Return plain text: no Markdown, no HTML. Keep line breaks where they are; add none.',
445
578
  ].join(' '),
446
579
  messages: [{ role: 'user', content: text }],
447
580
  output_config: { format: zodOutputFormat(Result) },
448
581
  })
449
582
 
450
- res.json({ text: message.parsed_output?.text ?? text })
583
+ res.json({ text: message.parsed_output?.text ?? '' })
451
584
  }
452
585
  ```
453
586
 
454
587
  **Clean what you return anyway.** Even with structured output, trim whitespace and strip
455
- matched surrounding quotes — models add them. If the result is empty, return the original
456
- text rather than an empty string; the editor treats an empty answer as nothing to do, but
457
- returning the input makes that explicit.
588
+ matched surrounding quotes — models add them. An empty answer, or the text unchanged, is
589
+ nothing to do: the editor leaves that paragraph exactly as it was, formatting included.
458
590
 
459
591
  ### Security, cost and abuse
460
592
 
@@ -463,18 +595,19 @@ returning the input makes that explicit.
463
595
  - **Authenticate and authorise the endpoint** with the same session your app already
464
596
  uses, and check the user may edit that document. Without it you've published an open
465
597
  proxy to your model account.
466
- - **Rate limit per user**, not per IP — this is a button an author can hold down. A short
467
- per-user quota (say N requests/minute) plus a max input length is enough.
468
- - **Cap the input**: reject selections beyond a size you're willing to pay for (the
469
- editor sends the whole block when nothing is selected, so this is reachable by
470
- accident).
598
+ - **Rate limit per user**, not per IP — this is a button an author can hold down, and one
599
+ click sends one request per paragraph, all at once. Allow a burst (say ten) inside a
600
+ short per-user quota, plus a max input length.
601
+ - **Cap the input**: reject a paragraph beyond a size you're willing to pay for — one long
602
+ paragraph is still one request.
471
603
  - **Log for support, not surveillance**: the action, size and outcome are enough to debug
472
604
  "the AI button doesn't work"; the copy itself is your customer's content.
473
605
 
474
606
  ### Failure and timeouts
475
607
 
476
608
  **Rejecting is a first-class outcome, not a bug.** The editor shows the author an error,
477
- logs your reason to the console, and leaves their text exactly as it was. Throw with a
609
+ logs your reason to the console, and leaves their text exactly as it was — every paragraph
610
+ of it, since an action's answers land together or not at all. Throw with a
478
611
  message worth reading — a quota refusal and a network blip shouldn't look the same to
479
612
  whoever supports the author:
480
613
 
@@ -512,9 +645,9 @@ ai: {
512
645
 
513
646
  | | |
514
647
  | --- | --- |
515
- | `run` receives | `{ action, text, locale }` — `text` is plain text with merge tags as `{{tagId}}`; `locale` is the editor's current locale |
516
- | `run` returns | `Promise<string>` — the replacement text |
517
- | Applied as | plain text plus rebuilt merge-tag chips. **Formatting marks inside the replaced range are lost**; merge tags survive |
648
+ | `run` receives | `{ action, text, locale }`, once per paragraph in range, all at once — `text` is that paragraph's plain text with merge tags as `{{tagId}}` and line breaks as `\n`; `locale` is the editor's interface language |
649
+ | `run` returns | `Promise<string>` — the paragraph's replacement text |
650
+ | Applied as | plain text in the paragraph's main formatting, with merge-tag chips and line breaks rebuilt; every paragraph in one undo step. **Formatting within a paragraph is flattened**; merge tags survive; an empty or unchanged answer changes nothing |
518
651
  | On rejection | author sees an error, console gets your reason, text is unchanged |
519
652
  | TypeScript | `AiAssistant`, `AiAction`, `AiRequest` are exported |
520
653
 
@@ -684,17 +817,22 @@ and forced-colors modes are handled behind the tokens and keep working under a r
684
817
  The package augments `HTMLElementTagNameMap`, so
685
818
  `document.querySelector('bygga-editor')` is typed as `ByggaEditorElement` — its
686
819
  properties, methods, and a typed `addEventListener` for `change` / `dirtychange` /
687
- `licenseerror` included.
820
+ `autosave` / `licenseerror` included.
688
821
 
689
822
  - **Types:** `Document`, `EditorConfig`, `ByggaEditorElement`, `ByggaEditorEventMap`,
690
- `Brand`, `BrandFont`, `BrandCustomFont`, `MergeTags`, `MergeTagEntry`,
691
- `MergeTagLabels`, `MergeTagOption`, `SavedBlock`, `SavedBlockContent`, `BlockPlugin`,
823
+ `AutosaveDetail`,
824
+ `Brand`, `BrandFont`, `BrandCustomFont`, `BrandStyles`, `BrandTextStyle`,
825
+ `BrandHeaderStyle`, `BrandButtonStyle`, `BrandBox`, `BrandCorners`, `BrandBorder`,
826
+ `BrandBlockType`, `Permission`,
827
+ `MergeTags`, `MergeTagEntry`, `MergeTagLabels`, `MergeTagOption`, `SavedBlock`,
828
+ `SavedBlockContent`, `BlockPlugin`, `BlockPluginCreateContext`, `BlockPluginBrand`,
829
+ `BlockPluginStyleCss`,
692
830
  `BlockPluginSettingsContext`, `BlockPluginGlobalsContext`,
693
831
  `BlockPluginGlobalSettings`, `AiAssistant`, `AiAction`, `AiRequest`, `Locale`,
694
832
  `LicenseError`, `LicenseErrorReason`.
695
833
  - **Values:** `createEmptyDocument`, `parseDocument`, `parseSavedBlock`,
696
- `SUPPORTED_LOCALES`, `DEFAULT_LOCALE`, `EDITOR_TAG`, and `ByggaEditor` (the element
697
- constructor, already registered on import).
834
+ `documentDigest`, `SUPPORTED_LOCALES`, `DEFAULT_LOCALE`, `EDITOR_TAG`, and
835
+ `ByggaEditor` (the element constructor, already registered on import).
698
836
 
699
837
  ## Framework integration
700
838