@bygga.dev/editor 1.8.4 → 1.9.4

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,17 +185,31 @@ 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 = {
186
- // Replaces the free spectrum with exactly these swatches, everywhere a colour is
187
- // picked. Hex with or without '#', shorthand expanded, case-insensitive.
196
+ // Your colours, as swatches everywhere a colour is picked. Hex with or without '#',
197
+ // shorthand expanded, case-insensitive. By default they REPLACE the free spectrum;
198
+ // `anyColor: true` keeps it underneath them.
188
199
  colors: ['#2f2a72', '#a28c67'],
189
200
 
201
+ // A second, local palette under the first — a department's colours inside your
202
+ // brand, say — and the headings the two sections show. Unnamed, two palettes read
203
+ // "Brand colors" and "Local colors"; a single one has no heading at all. A colour in
204
+ // both lists is shown in the first only.
205
+ localColors: ['#8a2be2', '#2e8b57'],
206
+ colorsLabel: 'Acme',
207
+ localColorsLabel: 'Acme Studio',
208
+
209
+ // Keep the free picker (colour field, hue, hex) under the palettes, so authors may
210
+ // still pick any colour. Absent or false: only palette colours can be picked.
211
+ anyColor: false,
212
+
190
213
  // Narrows the email-safe faces the font pickers offer, in your order. The FIRST is
191
214
  // the font a new blank document starts on.
192
215
  fonts: ['georgia', 'arial'],
@@ -224,7 +247,56 @@ never appears. Set `type: 'file'` on those:
224
247
  The email-safe faces are `arial`, `tahoma`, `trebuchet`, `verdana`, `georgia`,
225
248
  `times`, `courier`. Invalid entries (an unparseable colour, a non-http(s) font href)
226
249
  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
250
+ must not take down your editor.
251
+
252
+ #### Block styles, plugin values and hidden tiles
253
+
254
+ ```js
255
+ editor.brand = {
256
+ ...brand,
257
+ // The look a newly placed Header, Text or Button block starts with. Every field is
258
+ // optional. `font` is an email-safe face or the `name` of one of your customFonts;
259
+ // `fontSize` is one of 10, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48; colours are hex.
260
+ styles: {
261
+ header: { font: 'georgia', fontSize: 32, color: '#2f2a72', bold: true,
262
+ align: 'center', level: 2, padding: 8, background: '#ffffff',
263
+ margin: { top: 0, right: 0, bottom: 16, left: 0 } },
264
+ text: { font: 'arial', fontSize: 16, color: '#333333', align: 'left',
265
+ paragraphSpacing: 8 },
266
+ button: { background: '#2f2a72', textColor: '#ffffff', font: 'arial',
267
+ fontSize: 16, bold: true, align: 'center',
268
+ padding: { top: 12, right: 24, bottom: 12, left: 24 },
269
+ // One number rounds every corner; name the corners for a shape.
270
+ borderRadius: { topLeft: 0, topRight: 12, bottomRight: 0, bottomLeft: 0 },
271
+ border: { width: 2, color: '#064c70', style: 'solid' } },
272
+ },
273
+ // Anything your block plugins read to style their own blocks (see Block plugins).
274
+ custom: { cardRadius: 8, accent: '#a28c67' },
275
+ // Block tiles not to offer: built-in types and/or plugin names.
276
+ hiddenBlocks: ['html', 'acme.legacy-banner'],
277
+ }
278
+ ```
279
+
280
+ Each style can also set the block's box — `margin` and `padding` (a number or
281
+ `{ top, right, bottom, left }`), `borderRadius` (a number, or
282
+ `{ topLeft, topRight, bottomRight, bottomLeft }`), and `border`
283
+ (`{ width, color?, style? }`, `style` one of `solid`, `dashed`, `dotted`) — plus
284
+ `underline` for header and text, and `paragraphSpacing` (`0`, `4`, `8`, `12`, `16`,
285
+ `20`, `24` or `32` px) for text.
286
+
287
+ A style is a **starting value, written into the block when it is placed** — exactly as
288
+ if the author had chosen each setting by hand. Revising your Brand changes the blocks
289
+ placed next, never the ones already in your documents, and the compiled email knows
290
+ nothing about Brands. A header's title and a button carry their whole style from the
291
+ moment they appear; a text block is placed with its alignment, background and padding,
292
+ and the face, size, colour and weight are applied to what the author types into an
293
+ empty paragraph (a mark needs text to sit on). The author can change any of it
294
+ afterwards, as with any block.
295
+
296
+ A hidden tile only narrows the menu: a hidden block type already in a document still
297
+ renders and edits. An entry naming neither a built-in type nor a registered plugin is
298
+ warned about, as is any style field the editor can't use — that field is dropped and
299
+ the rest of the style kept. The Transparent option stays available beside a
228
300
  restricted palette, since "no fill" is a structural choice rather than a brand colour.
229
301
 
230
302
  A custom face renders in the editor and wherever the compiled HTML is shown in a real
@@ -235,7 +307,7 @@ carries what it uses, so it keeps rendering even if you later change the registr
235
307
  ### `savedBlocks` property
236
308
  The Saved block library this embed offers — see [Saved blocks](#saved-blocks). An
237
309
  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.
310
+ the Saved blocks section renders; the editor keeps no library of its own.
239
311
 
240
312
  ### Events
241
313
  Events do not bubble — listen on the element itself.
@@ -246,6 +318,34 @@ Events do not bubble — listen on the element itself.
246
318
  editor dirty.
247
319
  - **`dirtychange`** — fired when the unsaved-changes state flips; `event.detail[0]` is
248
320
  a boolean. Drives, e.g., a Save button's enabled state.
321
+ - **`autosave`** — fired when the author pauses after editing (about five seconds of
322
+ quiet), or after about thirty seconds of editing without a pause; `event.detail[0]`
323
+ is `{ document, digest }`. Never per keystroke (that is `change`), never for a
324
+ document with nothing unsaved, and never twice for the same content. The editor saves
325
+ nothing itself: persist the payload and call `markSaved()`, or call `save()` to store
326
+ it with its compiled HTML — though each `save()` spends a compile from your licence's
327
+ quota. Edits made within the last pause have not autosaved yet, so guard page exits
328
+ with `isDirty()` (a `beforeunload` handler) rather than relying on the event alone.
329
+
330
+ `digest` fingerprints the document's *content* — SHA-256, as lowercase hex, over its
331
+ [RFC 8785](https://www.rfc-editor.org/rfc/rfc8785) canonical JSON — so you can skip a
332
+ write when you already hold that content (another tab saved it, or your last save
333
+ did). Equal content always has an equal digest, whatever order your storage keeps the
334
+ keys in. Keep the digest of what you last stored, or compute it for any document with
335
+ the exported `documentDigest(document)`; a backend can compute the same value with any
336
+ JCS library.
337
+
338
+ ```js
339
+ import { documentDigest } from '@bygga.dev/editor'
340
+
341
+ let storedDigest = await documentDigest(storedDocument)
342
+ editor.addEventListener('autosave', async (event) => {
343
+ const [{ document, digest }] = event.detail
344
+ if (digest === storedDigest) return // already saved — nothing changed
345
+ await api.saveDraft(document)
346
+ storedDigest = digest
347
+ })
348
+ ```
249
349
  - **`licenseerror`** — fired once when the editor refuses to open; `event.detail[0]` is
250
350
  `{ reason }`, one of `key`, `domain`, `origin`, `malformed`, `unreachable`,
251
351
  `invalid`, `insecure-context`. The panel and a fuller `console.error` diagnostic are
@@ -321,11 +421,15 @@ editor.savedBlocks = rows.map(parseSavedBlock)
321
421
 
322
422
  `id` and `name` are ordinary strings you can key rows by and show in your own admin;
323
423
  `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
424
+ `saveBlock` does **not** put the block in the library — `savedBlocks` is the only
325
425
  truth the editor renders, so reassign it. A malformed or duplicate-id entry is dropped
326
426
  with a warning rather than taking down the editor. Entries are offered only in
327
427
  documents of the kind they were saved from.
328
428
 
429
+ The library appears under the block tiles, in view whenever they are. A Saved block
430
+ keeps any [lock](#locked-blocks) it was saved with, so an admin can curate locked
431
+ sections — a masthead, a legal footer — for content creators to drop in.
432
+
329
433
  ## Block plugins
330
434
 
331
435
  Custom block types — a video embed, a product card — registered per embed through
@@ -345,6 +449,46 @@ deployment a static-file update that rebuilds nothing. The contract is `BlockPlu
345
449
  as permanent and namespace it (`acme.product-card`). `email()` must be deterministic
346
450
  and `data` must be plain JSON — the editor re-bakes on load and on every change.
347
451
 
452
+ **The Brand reaches a plugin where it chooses values.** `create(context)` receives
453
+ `context.brand` — your Brand after validation: `colors`, `localColors`, `colorsLabel`,
454
+ `localColorsLabel`, `anyColor`, `fonts`, `customFonts`, `styles`, `custom` (whatever
455
+ you put in `brand.custom`), and `css`, the block styles
456
+ as ready-to-use CSS — so a new block can start on your look; the settings contexts
457
+ carry the same `brand`, to offer your palette or a "reset to brand". `css.header`
458
+ is how a custom header of your own takes the look the Brand gives the built-in one:
459
+ `css.header.cssText` is a string like `font-family:Georgia, 'Times New Roman', serif;font-size:32px`
460
+ to write into your heading's `style` (escape it like any attribute value), with each
461
+ property also available on its own. A custom font written there is carried into the
462
+ compiled email automatically, as long as your baked HTML names it. Copy what you use into the block's `data`: `email()` and `preview()`
463
+ deliberately do not receive the Brand, because a block's HTML must follow from its
464
+ stored data alone — otherwise the same document would bake differently in two embeds,
465
+ and revising your Brand would silently rewrite every document it touched.
466
+
467
+ ```js
468
+ create(context) {
469
+ return { title: '', accent: context.brand.custom.accent ?? context.brand.colors[0] }
470
+ },
471
+ ```
472
+
473
+ ## Locked blocks
474
+
475
+ An admin can **lock** a columns block from its settings. For a content creator (the
476
+ `creator` [permission](#permission-attribute)) a locked block is fixed: it can't be
477
+ moved, duplicated, removed, restyled or relaid out, nothing can be added to or taken
478
+ out of it, and its images, buttons and other blocks can't be changed — but **the text
479
+ inside it stays editable** — including a button's label, and whether it is bold,
480
+ through the button's own cut-down settings. A block that *holds* a locked block can't be moved,
481
+ duplicated, removed or hidden (per device or per recipient) by a creator either, since
482
+ that would take the lock with it, and a layout change that would drop a column holding
483
+ one isn't offered. An admin edits
484
+ locked blocks like any other and sees the same "Locked" marker.
485
+
486
+ The lock is stored on the block (`locked: true`) and travels with the document and
487
+ with Saved blocks. It is **guidance for the people editing, not a security boundary**:
488
+ the editor enforces it in its own UI, but you receive the whole document on every
489
+ save, so anything that must hold against a determined user has to be checked where
490
+ you store documents.
491
+
348
492
  ## AI assistance
349
493
 
350
494
  The editor can offer authors AI help on the text they're writing — but it ships **the UI
@@ -363,7 +507,7 @@ editor.config = {
363
507
  // one off means removing it here — there is no separate flag to keep in step.
364
508
  actions: ['proofread', 'rewrite', 'shorten'],
365
509
 
366
- // Called with the author's selection. Answer with the replacement text.
510
+ // Called once per paragraph of the author's selection. Answer with its replacement text.
367
511
  async run({ action, text, locale }) {
368
512
  const res = await fetch('/api/ai', { // YOUR endpoint, your model, your key
369
513
  method: 'POST',
@@ -383,8 +527,11 @@ editor doesn't know is dropped with a console warning, and an empty `actions` ar
383
527
  means no controls — same as no `ai` at all.
384
528
 
385
529
  **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.
530
+ actions. It runs on their selection, or on the whole block when nothing is selected, one
531
+ paragraph at a time: each paragraph or heading is its own `run` call, all sent at once, so
532
+ a block keeps its paragraphs, heading level, line breaks and styling. The answers land
533
+ together as one undo step, around anything the author typed while waiting. The button is
534
+ disabled while requests are in flight.
388
535
 
389
536
  ### Implementing the endpoint
390
537
 
@@ -432,29 +579,28 @@ export async function handleAi(req, res) {
432
579
  if (text.length > 5000) return res.status(413).json({ error: 'selection too long' })
433
580
 
434
581
  const message = await client.messages.parse({
435
- model: 'claude-opus-5',
582
+ model: 'claude-opus-5-5',
436
583
  // These are short transforms on an email selection; a low cap bounds both latency
437
584
  // and a runaway bill. Raise it if you allow long selections.
438
585
  max_tokens: 4000,
439
586
  system: [
440
587
  `You edit marketing email copy. ${instruction}`,
441
- `Reply in the same language as the input (the author is writing in "${locale}").`,
588
+ `Reply in the same language as the input (the author's interface language is "${locale}").`,
442
589
  'Return ONLY the edited text — no commentary, no explanation, no quotes around it.',
443
590
  'Preserve any {{merge_tag}} placeholders EXACTLY as they appear, including spelling and position.',
444
- 'Return plain text: no Markdown, no HTML.',
591
+ 'Return plain text: no Markdown, no HTML. Keep line breaks where they are; add none.',
445
592
  ].join(' '),
446
593
  messages: [{ role: 'user', content: text }],
447
594
  output_config: { format: zodOutputFormat(Result) },
448
595
  })
449
596
 
450
- res.json({ text: message.parsed_output?.text ?? text })
597
+ res.json({ text: message.parsed_output?.text ?? '' })
451
598
  }
452
599
  ```
453
600
 
454
601
  **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.
602
+ matched surrounding quotes — models add them. An empty answer, or the text unchanged, is
603
+ nothing to do: the editor leaves that paragraph exactly as it was, formatting included.
458
604
 
459
605
  ### Security, cost and abuse
460
606
 
@@ -463,18 +609,19 @@ returning the input makes that explicit.
463
609
  - **Authenticate and authorise the endpoint** with the same session your app already
464
610
  uses, and check the user may edit that document. Without it you've published an open
465
611
  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).
612
+ - **Rate limit per user**, not per IP — this is a button an author can hold down, and one
613
+ click sends one request per paragraph, all at once. Allow a burst (say ten) inside a
614
+ short per-user quota, plus a max input length.
615
+ - **Cap the input**: reject a paragraph beyond a size you're willing to pay for — one long
616
+ paragraph is still one request.
471
617
  - **Log for support, not surveillance**: the action, size and outcome are enough to debug
472
618
  "the AI button doesn't work"; the copy itself is your customer's content.
473
619
 
474
620
  ### Failure and timeouts
475
621
 
476
622
  **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
623
+ logs your reason to the console, and leaves their text exactly as it was — every paragraph
624
+ of it, since an action's answers land together or not at all. Throw with a
478
625
  message worth reading — a quota refusal and a network blip shouldn't look the same to
479
626
  whoever supports the author:
480
627
 
@@ -512,9 +659,9 @@ ai: {
512
659
 
513
660
  | | |
514
661
  | --- | --- |
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 |
662
+ | `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 |
663
+ | `run` returns | `Promise<string>` — the paragraph's replacement text |
664
+ | 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
665
  | On rejection | author sees an error, console gets your reason, text is unchanged |
519
666
  | TypeScript | `AiAssistant`, `AiAction`, `AiRequest` are exported |
520
667
 
@@ -684,17 +831,22 @@ and forced-colors modes are handled behind the tokens and keep working under a r
684
831
  The package augments `HTMLElementTagNameMap`, so
685
832
  `document.querySelector('bygga-editor')` is typed as `ByggaEditorElement` — its
686
833
  properties, methods, and a typed `addEventListener` for `change` / `dirtychange` /
687
- `licenseerror` included.
834
+ `autosave` / `licenseerror` included.
688
835
 
689
836
  - **Types:** `Document`, `EditorConfig`, `ByggaEditorElement`, `ByggaEditorEventMap`,
690
- `Brand`, `BrandFont`, `BrandCustomFont`, `MergeTags`, `MergeTagEntry`,
691
- `MergeTagLabels`, `MergeTagOption`, `SavedBlock`, `SavedBlockContent`, `BlockPlugin`,
837
+ `AutosaveDetail`,
838
+ `Brand`, `BrandFont`, `BrandCustomFont`, `BrandStyles`, `BrandTextStyle`,
839
+ `BrandHeaderStyle`, `BrandButtonStyle`, `BrandBox`, `BrandCorners`, `BrandBorder`,
840
+ `BrandBlockType`, `Permission`,
841
+ `MergeTags`, `MergeTagEntry`, `MergeTagLabels`, `MergeTagOption`, `SavedBlock`,
842
+ `SavedBlockContent`, `BlockPlugin`, `BlockPluginCreateContext`, `BlockPluginBrand`,
843
+ `BlockPluginStyleCss`,
692
844
  `BlockPluginSettingsContext`, `BlockPluginGlobalsContext`,
693
845
  `BlockPluginGlobalSettings`, `AiAssistant`, `AiAction`, `AiRequest`, `Locale`,
694
846
  `LicenseError`, `LicenseErrorReason`.
695
847
  - **Values:** `createEmptyDocument`, `parseDocument`, `parseSavedBlock`,
696
- `SUPPORTED_LOCALES`, `DEFAULT_LOCALE`, `EDITOR_TAG`, and `ByggaEditor` (the element
697
- constructor, already registered on import).
848
+ `documentDigest`, `SUPPORTED_LOCALES`, `DEFAULT_LOCALE`, `EDITOR_TAG`, and
849
+ `ByggaEditor` (the element constructor, already registered on import).
698
850
 
699
851
  ## Framework integration
700
852