From 40676486f8ee3990faaf53f8b37172087aeec809 Mon Sep 17 00:00:00 2001 From: Jamie Chapman <104535858+bilbospocketses@users.noreply.github.com> Date: Thu, 4 Jun 2026 04:10:56 -0400 Subject: [PATCH] docs: accuracy pass, THEMING.md, EMBED_API reconcile + embed fixes - Reconcile fork docs with shipped code: README, CONTRIBUTING, STYLE, SECURITY (elix->lit), docs/tutorials/Events.md. - Add THEMING.md: design tokens, html[data-theme] theming, hex-guard; cross-linked from README/CONTRIBUTING/STYLE and added to markdownlint. - Reconcile EMBED_API.md to src/embed (theme html[data-theme]; light/dark only; prompt default returns its arg; setPalette/__setPalette; wildcard + error-code text), then reformat it + SECURITY.md under markdownlint. - Remove dead jPickerShim.ts (zero importers; jGraduate Lit rewrite orphan). - Embed: console.warn on wildcard origin; attach PROTOCOL_VERSION_MISMATCH to the ready-reject and DIALOG_HANDLER_TIMEOUT to the timeout error event. --- .markdownlint-cli2.jsonc | 3 + CHANGELOG.md | 38 ++++ CONTRIBUTING.md | 9 +- EMBED_API.md | 205 ++++++++++++------ README.md | 6 +- SECURITY.md | 19 +- STYLE.md | 19 +- THEMING.md | 106 +++++++++ docs/tutorials/Events.md | 5 +- .../components/jgraduate/jPickerShim.ts | 179 --------------- src/embed/client.ts | 7 +- src/embed/server.ts | 6 +- 12 files changed, 328 insertions(+), 274 deletions(-) create mode 100644 THEMING.md delete mode 100644 src/editor/components/jgraduate/jPickerShim.ts diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc index 7187f787e..2e7137d45 100644 --- a/.markdownlint-cli2.jsonc +++ b/.markdownlint-cli2.jsonc @@ -9,6 +9,9 @@ "CONTRIBUTING.md", "AGENTS.md", "CHANGELOG.md", + "THEMING.md", + "EMBED_API.md", + "SECURITY.md", "docs/tutorials/*.md" ], "ignores": [ diff --git a/CHANGELOG.md b/CHANGELOG.md index 75ac017fa..bacabcd3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added (Theming guide -- 2026-06-04) + +- New top-level `THEMING.md` documents the two-layer design-token system + (`src/editor/styles/tokens.css`), the runtime `html[data-theme]` light/dark model + (`theme.ts`, `se-theme-toggle`, `svgedit-themechange`, startup precedence, persistence), + and the `hex-guard` tokens-only color policy with its `hex-guard-allow` escape hatch. + Cross-linked from `README`, `CONTRIBUTING`, and `STYLE` §8; added to markdownlint. + +### Changed (Documentation accuracy pass -- 2026-06-04) + +- Reconciled the fork docs with the shipped code: `README` (tutorials no longer "rewrite + pending"; e2e is Chromium + Firefox), `CONTRIBUTING` (`scripts/run-e2e.ts`; + `feat/ts-migration` marked historical; no jQuery), `SECURITY` (`elix` → `lit`), and `STYLE` + §8/§9 (markdownlint scope; that it runs in `npm run lint`; 12.B–D doc sweep complete). + `Events.md` now states the embed API has shipped. +- Reconciled `EMBED_API.md` with `src/embed`: theme applies via `html[data-theme]` (not the + removed `body.theme-*`) and is `light`/`dark` only; the default `prompt` returns its default + value with no built-in input dialog (hosts must register a handler); documented `setPalette` + and `__setPalette`; corrected the wildcard-origin and error-code text to match the code. +- `EMBED_API.md` and `SECURITY.md` brought under markdownlint (reflowed to the 100-col cap; + tables, headings, and fences normalized) — all fork Markdown docs are now linted. + +### Removed (Dead jPickerShim -- 2026-06-04) + +- Deleted `src/editor/components/jgraduate/jPickerShim.ts` — orphaned by the jGraduate Lit + rewrite, with zero importers anywhere in the repo. The color picker reaches `se-color-picker` + directly through `se-gradient-editor`. + +### Fixed (Embed API — error codes + wildcard warning -- 2026-06-04) + +- The embed client and server now `console.warn` when `allowedOrigins` includes the wildcard `*` + (origin checking disabled — dev/test only). (`src/embed/client.ts`, `src/embed/server.ts`) +- `ERROR_CODES.PROTOCOL_VERSION_MISMATCH` is now attached to the `editor.ready` rejection, and + `DIALOG_HANDLER_TIMEOUT` to the timeout `error` event — both were defined but never attached. + +**Verification:** `npm run lint` clean — eslint, markdownlint (16 files), hex-guard; 84/84 embed +unit tests pass (`vitest run embed`). + ### Added (M2 -- light/dark theme toggle + persistence -- 2026-06-02) - New top-bar `se-theme-toggle` (sun/moon) — one-click light/dark switch. (M2 / #96) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d6847dc64..55d0d722b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,7 +45,7 @@ npm run lint # lint only packages/svgcanvas/ svgcanvas workspace package (core SVG manipulation) src/editor/ editor UI shell (chrome, panels, dialogs) src/editor/extensions/ built-in editor extensions -scripts/ build + copy-static helpers, run-e2e.mjs +scripts/ build + copy-static helpers, run-e2e.ts tests/ Vitest unit suite + Playwright e2e docs/ AUDIT, design specs, plans, manual checklists ``` @@ -60,10 +60,11 @@ TODO #19. General conventions: -- Vanilla DOM + web components — no React; new code avoids jQuery (legacy jQuery files are being - migrated out). +- Vanilla DOM + web components — no React, no jQuery. - ES modules throughout; no CommonJS. - 2-space indentation, single quotes. +- Colors/theming: use the semantic design tokens — see [`THEMING.md`](./THEMING.md). The hex-guard + blocks raw color outside `tokens.css`. ## Tests @@ -97,7 +98,7 @@ See [`STYLE.md` § 6](./STYLE.md#6-commit-messages) for conventions. ## Branch Strategy - `master` — releases and stable work -- `feat/ts-migration` — the ongoing JS → TS migration (long-lived feature branch, draft PR #1) +- `feat/ts-migration` — the now-merged JS → TS migration, kept for historical reference - Short-lived feature branches off `master` for everything else ## Reporting Bugs diff --git a/EMBED_API.md b/EMBED_API.md index 1ec7f0639..e3aa3a39e 100644 --- a/EMBED_API.md +++ b/EMBED_API.md @@ -1,6 +1,7 @@ # svgedit Embed API -**protocolVersion 1** — v1 scope: iframe embed, postMessage RPC, chrome control, two-way theme sync, dialog hooks, typed events. +**protocolVersion 1** — v1 scope: iframe embed, postMessage RPC, chrome control, two-way theme +sync, dialog hooks, typed events. ## Quickstart @@ -30,7 +31,8 @@ Drop svgedit into any host page with four lines: ``` -Calls issued before `await editor.ready` are queued and flushed automatically once the editor reports ready — no manual gating required. +Calls issued before `await editor.ready` are queued and flushed automatically once the editor +reports ready — no manual gating required. --- @@ -39,16 +41,16 @@ Calls issued before `await editor.ready` are queued and flushed automatically on All params are optional. Apply them to the editor iframe `src`. | Param | Type | Default | Effect | -|---|---|---|---| +| --- | --- | --- | --- | | `embed` | `1` | absent | Activates embed mode. Enables the postMessage listener and chrome system. Without this param the editor runs in standalone mode and ignores all embed messages. | | `chrome` | `full` \| `minimal` \| `none` | `none` when `embed=1` | Initial chrome preset. Set before first paint — no flash of unwanted chrome. See [Chrome control](#chrome-control). | | `theme` | `light` \| `dark` | editor's persisted choice, else OS `prefers-color-scheme` | Initial theme, applied as `html[data-theme=""]` (drives the design tokens). An invalid value falls back to the OS preference. | -| `allowedOrigins` | comma-separated origins | same-origin only | Origins the editor will accept postMessages from. `*` accepts any origin (logs a console warning). | +| `allowedOrigins` | comma-separated origins | same-origin only | Origins the editor will accept postMessages from. `*` accepts any origin — logs a dev-only console warning; use only for local dev/test. | | `dialogTimeout` | integer (ms) | `30000` | How long (ms) the editor waits for a host dialog handler to respond before falling back to its own internal modal. | **Examples:** -``` +```text /index.html?embed=1 /index.html?embed=1&chrome=minimal&theme=dark /index.html?embed=1&chrome=none&allowedOrigins=https://app.example.com @@ -79,10 +81,11 @@ new SvgEditEmbed(iframe: HTMLIFrameElement, opts?: SvgEditEmbedOptions) `SvgEditEmbedOptions`: | Field | Type | Default | Description | -|---|---|---|---| +| --- | --- | --- | --- | | `allowedOrigins` | `string[]` | `[new URL(iframe.src).origin]` | Origins the host will accept messages from. Pass `['*']` only for local dev. | -Constructing `SvgEditEmbed` immediately attaches a `window.message` listener. The handshake with the editor begins as soon as the iframe loads. +Constructing `SvgEditEmbed` immediately attaches a `window.message` listener. The handshake with +the editor begins as soon as the iframe loads. ### Public API @@ -105,6 +108,9 @@ class SvgEditEmbed { // Theme control (runtime) setTheme(theme: string): Promise + // Palette control (runtime) — replace the swatch palette + setPalette(colors: readonly string[]): Promise + // Dialog hooks setDialogHandler( kind: 'prompt' | 'alert' | 'confirm', @@ -125,7 +131,7 @@ class SvgEditEmbed { type ReadyPayload = { version: string // editor's package version (e.g. "7.5.0") protocolVersion: number // always 1 for v1 - capabilities: string[] // e.g. ["chrome", "theme", "dialog-hooks"] + capabilities: string[] // e.g. ["chrome", "theme", "dialog-hooks", "palette"] } ``` @@ -135,7 +141,8 @@ type ReadyPayload = { ### The Proxy pattern -`editor.editor` is an ES `Proxy`. Any property access returns a function that sends a postMessage `call` envelope to the iframe and resolves a Promise when the result arrives: +`editor.editor` is an ES `Proxy`. Any property access returns a function that sends a postMessage +`call` envelope to the iframe and resolves a Promise when the result arrives: ```js await editor.ready @@ -150,7 +157,9 @@ await editor.editor.loadFromString(' { ## Chrome control -Chrome refers to the editor's surrounding UI: toolbars, menus, layer panel, palette, status bar, and header. In embed mode you choose which pieces to show. +Chrome refers to the editor's surrounding UI: toolbars, menus, layer panel, palette, status bar, +and header. In embed mode you choose which pieces to show. ### URL params (initial state) Set chrome before first paint — no flash of unwanted UI: -``` +```text ?embed=1&chrome=full # all UI visible ?embed=1&chrome=minimal # toolbox only; menu/layers/palette/statusbar/header hidden ?embed=1&chrome=none # no UI at all — canvas only ?embed=1 # shorthand for chrome=none ``` -### Runtime API +### Runtime API (chrome) Toggle chrome live after the editor is ready: @@ -312,28 +331,32 @@ type ChromeState = { Preset resolution: | Preset | menu | toolbox | layers | palette | statusbar | header | -|---|---|---|---|---|---|---| +| --- | --- | --- | --- | --- | --- | --- | | `full` | true | true | true | true | true | true | | `minimal` | false | true | false | false | false | false | | `none` | false | false | false | false | false | false | -Implementation: the server applies CSS classes (`no-menu`, `no-toolbox`, etc.) to ``. The `embed` class is always added when embed mode is active. +Implementation: the server applies CSS classes (`no-menu`, `no-toolbox`, etc.) to ``. The +`embed` class is always added when embed mode is active. --- ## Palette -Replace the editor's color swatch strip (`se-palette`) with your own brand colors. Replace-semantics: your colors become the whole palette. The `none` (no-fill/no-stroke) swatch is always kept — it is prepended if you omit it. +Replace the editor's color swatch strip (`se-palette`) with your own brand colors. +Replace-semantics: your colors become the whole palette. The `none` (no-fill/no-stroke) swatch is +always kept — it is prepended if you omit it. ### URL param (initial state) -``` +```text ?embed=1&palette=%23ff0000,%23223344,none ``` -Comma-separated CSS colors, URL-encoded. Applied before first paint — no flash of the default swatches. +Comma-separated CSS colors, URL-encoded. Applied before first paint — no flash of the default +swatches. -### Runtime API +### Runtime API (palette) ```js import { SvgEditEmbed, DEFAULT_PALETTE } from 'svgedit/embed' @@ -348,13 +371,19 @@ await editor.setPalette([...DEFAULT_PALETTE, '#00a3e0']) // append (host- ### Validation -Each entry must be `none` or a valid CSS color. Invalid entries are dropped (the rest still apply); if any are dropped at runtime the editor emits an `error` event with `source: 'invalid-palette-color'`. If nothing valid remains, the default palette is restored. `'palette'` appears in the `ready` payload's `capabilities`. +Each entry must be `none` or a valid CSS color. Invalid entries are dropped (the rest still +apply); if any are dropped at runtime the editor emits an `error` event with +`source: 'invalid-palette-color'`. If nothing valid remains, the default palette is restored. +`'palette'` appears in the `ready` payload's `capabilities`. --- ## Dialog hooks -By default the editor shows its own built-in modal for `alert`, `confirm`, and `prompt` calls. Hosts can intercept these to render dialogs in their own design system. +By default the editor handles `alert` and `confirm` with its own built-in modals (`seAlert` / +`seConfirm`). `prompt` has no built-in input dialog yet — its default handler just returns the +supplied default value (or `null`), so a host needing real prompt input MUST register a `prompt` +handler. Hosts can intercept any of the three to render dialogs in their own design system. ### Registering handlers @@ -385,7 +414,8 @@ unregAlert() // editor uses its own modal again for alert ### Timeout behavior -If a registered handler does not resolve within the configured timeout (default 30 seconds, configurable via `?dialogTimeout=` URL param or `editor.setDialogTimeout(ms)`), the editor: +If a registered handler does not resolve within the configured timeout (default 30 seconds, +configurable via `?dialogTimeout=` URL param or `editor.setDialogTimeout(ms)`), the editor: 1. Falls back to its own built-in modal for that dialog call. 2. Emits an `error` event with `source: 'dialog-handler-timeout'`. @@ -404,7 +434,10 @@ editor.on('error', ({ source, message }) => { ### sePromptDialog rename note -The V7 editor has a component named `sePromptDialog` that actually functions as a status-display modal (not an interactive prompt-with-input). The embed dialog hook system uses the correct `prompt` / `alert` / `confirm` naming and wires to the appropriate components. The `sePromptDialog` rename is tracked separately as a UI cleanup item and does not affect the embed API contract. +The V7 editor has a component named `sePromptDialog` that actually functions as a status-display +modal (not an interactive prompt-with-input). The embed dialog hook system uses the correct +`prompt` / `alert` / `confirm` naming and wires to the appropriate components. The `sePromptDialog` +rename is tracked separately as a UI cleanup item and does not affect the embed API contract. --- @@ -414,7 +447,7 @@ Theme state flows both directions. Last write wins — there is no acknowledgeme ### Setting the initial theme -``` +```text ?embed=1&theme=dark ?embed=1&theme=light ``` @@ -424,14 +457,16 @@ Or at runtime after `ready`: ```js await editor.setTheme('dark') await editor.setTheme('light') -await editor.setTheme('my-custom-theme') ``` -The theme value is applied as `theme-` on the editor's ``. +The theme value is applied as `html[data-theme=""]` on the editor's document, which +activates the design tokens. Only `light` and `dark` are supported; any other value falls back +to the OS `prefers-color-scheme`. ### Listening for editor-initiated theme changes -When a user clicks a theme toggle inside the editor (visible only when chrome includes it), the editor emits `theme-changed`: +When a user clicks a theme toggle inside the editor (visible only when chrome includes it), the +editor emits `theme-changed`: ```js editor.on('theme-changed', ({ theme }) => { @@ -441,7 +476,9 @@ editor.on('theme-changed', ({ theme }) => { ### Echo-loop prevention -When the host calls `editor.setTheme(t)`, the editor applies the theme but does **not** emit a `theme-changed` event back. The event only fires for user-initiated changes inside the editor. This prevents the ping-pong loop: host applies → editor emits → host re-applies. +When the host calls `editor.setTheme(t)`, the editor applies the theme but does **not** emit a +`theme-changed` event back. The event only fires for user-initiated changes inside the editor. +This prevents the ping-pong loop: host applies → editor emits → host re-applies. ### Typical two-way sync pattern @@ -464,21 +501,27 @@ document.getElementById('my-theme-toggle').addEventListener('change', async (e) ## Security: allowedOrigins -The embed API validates origins on both sides independently. A bug on one side does not break the boundary. +The embed API validates origins on both sides independently. A bug on one side does not break the +boundary. ### Host side (SvgEditEmbed) -- Constructor option `allowedOrigins` defaults to `[new URL(iframe.src).origin]` — the iframe's own origin. -- Every inbound message is validated: `event.origin` must be in `allowedOrigins` AND `event.source` must be `iframe.contentWindow`. The second check prevents impersonation from other iframes in the page. +- Constructor option `allowedOrigins` defaults to `[new URL(iframe.src).origin]` — the iframe's + own origin. +- Every inbound message is validated: `event.origin` must be in `allowedOrigins` AND + `event.source` must be `iframe.contentWindow`. The second check prevents impersonation from + other iframes in the page. - Outbound messages use `iframe.contentWindow.postMessage(env, iframeOrigin)` — never `'*'`. -- Wildcard `'*'` is accepted but logs: `SvgEditEmbed: wildcard origin enabled — only safe for dev/test`. +- Wildcard `'*'` is accepted but logs a warning and disables origin checking (dev/test only). ### Editor side (server.ts) - URL param `?allowedOrigins=origin1,origin2` (comma-separated). Default is same-origin only. -- Inbound messages from unauthorized origins are silently dropped plus `console.warn`. The editor does not reply — it does not leak its existence to foreign origins. -- Outbound to `window.parent` uses the first allowed origin as `targetOrigin` (or `'*'` in wildcard mode). -- Wildcard `*` is accepted, warns. +- Inbound messages from unauthorized origins are silently dropped plus `console.warn`. The editor + does not reply — it does not leak its existence to foreign origins. +- Outbound to `window.parent` uses the first allowed origin as `targetOrigin` (or `'*'` in + wildcard mode). +- Wildcard `*` is accepted; logs a warning and disables origin checking. ### Recommended iframe sandbox attributes @@ -494,54 +537,67 @@ The embed API validates origins on both sides independently. A bug on one side d Caveats: -- `allow-same-origin` is required for the editor's localStorage persistence to work. Omitting it breaks the editor's settings save. +- `allow-same-origin` is required for the editor's localStorage persistence to work. Omitting it + breaks the editor's settings save. - `allow-downloads` is needed for the editor's SVG export / save-to-disk functionality. -- `allow-modals` is needed only if you have **not** registered dialog handlers for all three dialog kinds (`alert`, `confirm`, `prompt`). If you register all three, you can omit it. -- `allow-top-navigation` is not needed and should be omitted — the embed API uses postMessage, not navigation. +- `allow-modals` is needed only if you have **not** registered dialog handlers for all three + dialog kinds (`alert`, `confirm`, `prompt`). If you register all three, you can omit it. +- `allow-top-navigation` is not needed and should be omitted — the embed API uses postMessage, not + navigation. - Do not add `allow-popups` unless your use case requires it. --- ## Versioning -The postMessage envelope carries `v: 1` (the `protocolVersion`). This is separate from the editor's package version (its semver). +The postMessage envelope carries `v: 1` (the `protocolVersion`). This is separate from the +editor's package version (its semver). ### What protocolVersion covers -The envelope shape — `ns`, `v`, `kind`, `id`, field names — is the versioned contract. Method names on the canvas surface are not versioned; new methods auto-expose via the generic forwarder, and unknown method names return `METHOD_NOT_FOUND`. +The envelope shape — `ns`, `v`, `kind`, `id`, field names — is the versioned contract. Method +names on the canvas surface are not versioned; new methods auto-expose via the generic forwarder, +and unknown method names return `METHOD_NOT_FOUND`. ### Backward compatibility (editor evolves, host stays on v1 library) - New `svgCanvas` methods appear automatically — no host library update needed. -- New capabilities appear in the `ready` payload's `capabilities` array. Feature-detect rather than version-sniff: +- New capabilities appear in the `ready` payload's `capabilities` array. Feature-detect rather + than version-sniff: + ```js const { capabilities } = await editor.ready if (capabilities.includes('my-future-capability')) { /* ... */ } ``` -- Unknown method names → `METHOD_NOT_FOUND` error code — hosts can test for a method's existence without try/catch at every call site. + +- Unknown method names → `METHOD_NOT_FOUND` error code — hosts can test for a method's existence + without try/catch at every call site. ### Forward compatibility (host upgrades first, editor reports v2) -If the editor reports `protocolVersion: 2` but the host proxy library expects `1`, the proxy rejects the `ready` promise with a clear error: +If the editor reports `protocolVersion: 2` but the host proxy library expects `1`, the proxy +rejects the `ready` promise with a clear error: -``` +```text svgedit embed: protocolVersion mismatch — host expects 1, editor reports 2 ``` -Update the host's `SvgEditEmbed` library to match the new editor version before enabling the upgraded editor. +Update the host's `SvgEditEmbed` library to match the new editor version before enabling the +upgraded editor. --- ## Error codes -All errors arrive as a rejected Promise on the call site. The error object has a `code` property when the failure is typed. +All errors arrive as a rejected Promise on the call site. The error object has a `code` property +when the failure is typed. | Code | When | -|---|---| +| --- | --- | | `METHOD_NOT_FOUND` | The method name does not exist on `svgCanvas` or `Editor`. | | `ELEMENT_NOT_FOUND` | An element handle was passed but the element it referred to no longer exists in the document. | -| `PROTOCOL_VERSION_MISMATCH` | Editor and host proxy library disagree on `protocolVersion`. The proxy rejects `editor.ready`. | -| `DIALOG_HANDLER_TIMEOUT` | A registered dialog handler did not resolve within the timeout. The editor fell back to its own modal and emitted an `error` event. | +| `PROTOCOL_VERSION_MISMATCH` | Editor and host proxy library disagree on `protocolVersion`. The proxy rejects `editor.ready`; the rejection `Error` carries this `code`. | +| `DIALOG_HANDLER_TIMEOUT` | A registered dialog handler did not resolve within the timeout. Surfaced as an `error` event carrying `source: 'dialog-handler-timeout'` and this `code`; the editor falls back to its own modal. | Catching errors: @@ -568,11 +624,13 @@ editor.editor.getElem('deleted-element-id') ## Rolling your own client -If your host cannot use the JavaScript proxy library (non-JS host, WebAssembly, server-side rendering, etc.), you can speak the protocol directly via postMessage. +If your host cannot use the JavaScript proxy library (non-JS host, WebAssembly, server-side +rendering, etc.), you can speak the protocol directly via postMessage. ### Envelope shape -All messages share `ns: 'svgedit'` and `v: 1`. Foreign messages (wrong `ns` or `v`) are silently dropped by both sides. +All messages share `ns: 'svgedit'` and `v: 1`. Foreign messages (wrong `ns` or `v`) are silently +dropped by both sides. ```ts // Host → Editor @@ -586,12 +644,18 @@ All messages share `ns: 'svgedit'` and `v: 1`. Foreign messages (wrong `ns` or ` { ns: 'svgedit', v: 1, kind: 'dialog-request', id: number, dialog: 'prompt' | 'alert' | 'confirm', args: unknown[] } ``` -`id` is your correlation token. Use a monotonically increasing integer. Match a `result` or `error` back to a `call` by `id`. Match a `dialog-response` to a `dialog-request` by `id`. Events have no `id` — they are fire-and-forget. +`id` is your correlation token. Use a monotonically increasing integer. Match a `result` or +`error` back to a `call` by `id`. Match a `dialog-response` to a `dialog-request` by `id`. Events +have no `id` — they are fire-and-forget. ### targetOrigin rules -- **Host → Editor:** `iframe.contentWindow.postMessage(env, iframeOrigin)` where `iframeOrigin` is the editor host's origin. Never use `'*'` in production — it exposes the message to any embedded frame that may be co-resident. -- **Editor → Host:** the editor sends to `window.parent` with `targetOrigin` set to the first entry in `allowedOrigins` (or `'*'` in wildcard mode). Your host page must be at that origin to receive the message. +- **Host → Editor:** `iframe.contentWindow.postMessage(env, iframeOrigin)` where `iframeOrigin` is + the editor host's origin. Never use `'*'` in production — it exposes the message to any embedded + frame that may be co-resident. +- **Editor → Host:** the editor sends to `window.parent` with `targetOrigin` set to the first + entry in `allowedOrigins` (or `'*'` in wildcard mode). Your host page must be at that origin to + receive the message. ### Minimal raw-protocol example @@ -643,21 +707,24 @@ await call('loadFromString', ['`: `:root` holds the light values and +`html[data-theme="dark"]` activates the dark set. `src/editor/styles/theme.ts` owns the +transitions: + +- `applyTheme(theme)` sets `html[data-theme]` and dispatches a `svgedit-themechange` `CustomEvent` + (`detail: { theme }`). +- `toggleTheme()` flips light↔dark and applies it — it does **not** persist; the caller does. +- `resolveInitialTheme(stored)` returns a stored `'light'`/`'dark'`, else the OS + `prefers-color-scheme`; `applyInitialTheme(stored)` applies it. +- `getCurrentTheme()` / `getSystemTheme()` read the current and OS themes. + +**Startup precedence** (`editorInit.ts`): `?theme=` URL param > stored pref > system. The URL +value is a per-load override and is not persisted. + +**The toggle** `se-theme-toggle` (`src/editor/components/seThemeToggle.ts`) calls `toggleTheme()` +on click and emits a `toggle-theme` event; `MainMenu.ts` listens and persists the choice via +`ConfigObj.pref('theme')` (subject to the editor's storage opt-in, like any pref). The toggle +re-syncs its own sun/moon icon by listening for `svgedit-themechange`. + +**Re-theming non-CSS surfaces.** Anything painted outside CSS must re-read its colors on +`svgedit-themechange`. The Rulers (Canvas 2D) are the worked example — `Rulers.ts` redraws on the +event so tick ink follows `--se-text`: + +```js +document.addEventListener('svgedit-themechange', () => this.updateRulers()) +``` + +The embed bundle mirrors this contract (`html[data-theme]` + `svgedit-themechange`); hosts drive +it via `?theme=` and `__setTheme`. See `EMBED_API.md`. + +## Using and adding tokens + +In component styles (including Lit `static styles`), reference semantic tokens — never raw color: + +```css +button { + background: var(--se-surface); + color: var(--se-text); + border: 1px solid var(--se-border); + border-radius: var(--se-radius-sm); +} +``` + +To add a token: add (or reuse) a **primitive** in `tokens.css`, then add a **semantic** token in +both the `:root` block and the `html[data-theme="dark"]` block. Components reference only the +semantic name, so one addition themes automatically. + +## The hex-guard + +`scripts/check-no-raw-hex.mjs` enforces the tokens-only rule. It runs in `npm run lint` (as +`npm run lint:hex`, with `--error`) and fails the build on a raw color found anywhere under +`src/` in: + +- any line of a `.css` file, or +- any line inside a `css` tagged-template literal in a `.ts` file (Lit `static styles`). + +It flags hex (`#rgb` … `#rrggbbaa`), color functions (`rgb()`, `rgba()`, `hsl()`, `hsla()`), and a +set of CSS color keywords used as values (`color: black`, etc.). It does **not** flag +`transparent`, `currentColor`, `inherit`, `initial`, `unset`, `none`, or `var(--…)`. Only +`src/editor/styles/tokens.css` is allowlisted. Markdown and non-`css` TypeScript are out of scope, +so prose examples like this doc's are never flagged. + +**Escape hatch.** A line containing the comment marker `hex-guard-allow` is skipped. Reserve it +for color that is intentionally not a theme token — user/functional color the editor renders +literally: + +```css +.swatch-none { background: #fff; } /* hex-guard-allow: user palette swatch, not chrome */ +``` + +Intentionally exempt today: color pickers and gradient editors, palette swatches, and +contrast-critical selection handles — these carry user/functional color, not themeable chrome. diff --git a/docs/tutorials/Events.md b/docs/tutorials/Events.md index 438783569..991e05500 100644 --- a/docs/tutorials/Events.md +++ b/docs/tutorials/Events.md @@ -21,8 +21,9 @@ document.addEventListener('svgEditorReady', () => { }) ``` -A clean host-facing API for driving the embedded editor is planned separately -(the embed-API work; it will ship as `EMBED_API.md`). +A clean host-facing API for driving the embedded editor ships separately as +`EMBED_API.md` at the repo root (postMessage RPC, chrome control, two-way theme sync, +dialog hooks). ## Within-frame editor callbacks (`svgEditor.ready`) diff --git a/src/editor/components/jgraduate/jPickerShim.ts b/src/editor/components/jgraduate/jPickerShim.ts deleted file mode 100644 index ca9cae252..000000000 --- a/src/editor/components/jgraduate/jPickerShim.ts +++ /dev/null @@ -1,179 +0,0 @@ -/** - * @file jPickerShim — transitional bridge between jGraduate's internal - * jPickerMethod calls and the new se-color-picker Lit component. - * - * Exports the same `jPickerDefaults` and `jPickerMethod` API surface that - * jQuery.jPicker.ts exported, but delegates rendering to se-color-picker. - * Will be deleted in PR-4b when jGraduate itself is rewritten. - * - * @module jPickerShim - */ - -// Side-effect import — registers the custom element -import './se-color-picker.js' -import type { ColorModel } from './ColorModel.js' - -// --------------------------------------------------------------------------- -// jPickerDefaults — same shape as the original for external consumers -// --------------------------------------------------------------------------- - -export const jPickerDefaults = { - window: { - title: null as string | null, - effects: { - type: 'slide', - speed: { show: 'slow', hide: 'fast' } - }, - position: { x: 'screenCenter', y: 'top' }, - expandable: false, - liveUpdate: true, - alphaSupport: false, - alphaPrecision: 0, - updateInputColor: true - }, - color: { - mode: 'h', - active: 'ff0000ff' - }, - images: { - clientPath: '/images/jgraduate/images/' - } -} - -// --------------------------------------------------------------------------- -// Compatibility proxy — wraps ColorModel in the .val(name) API jGraduate expects -// --------------------------------------------------------------------------- - -/** - * Build a proxy object with a `.val(name)` method matching the old jPicker Color - * interface that jGraduate consumes. - * - * @param model - The ColorModel from se-color-picker's event detail - */ -/** Proxy returned to jGraduate callbacks — mirrors the old jPicker Color .val() API. */ -interface ValProxy { - val: (name: string) => unknown -} - -function buildValProxy (model: ColorModel): ValProxy { - return { - val (name: string): unknown { - switch (name) { - case 'hex': return model.hex - case 'ahex': return model.ahex - case 'a': return model.a - case 'r': return model.r - case 'g': return model.g - case 'b': return model.b - case 'h': return model.h - case 's': return model.s - case 'v': return model.v - case 'all': - return { - r: model.r, - g: model.g, - b: model.b, - a: model.a, - h: model.h, - s: model.s, - v: model.v, - hex: model.hex, - ahex: model.ahex - } - default: - return null - } - } - } -} - -// --------------------------------------------------------------------------- -// jPickerMethod — drop-in replacement for the original -// --------------------------------------------------------------------------- - -/** - * Instantiate an se-color-picker inside `elem`, wire callbacks. - * - * @param elem - The container element (will be cleared and shown) - * @param options - Options object with color.active, window.alphaSupport, etc. - * @param commitCallback - Called with a val-proxy when user clicks OK - * @param liveCallback - Called with a val-proxy on every live color change - * @param cancelCallback - Called when user clicks Cancel - * @param _i18next - Unused, kept for signature compat - */ -/** Shape of the options object passed by jGraduate to jPickerMethod. */ -interface JPickerOptions { - color?: { - active?: string | { val?: (name: string) => string; ahex?: string } - alphaSupport?: boolean - } - window?: { alphaSupport?: boolean } -} - -export function jPickerMethod ( - elem: HTMLElement, - options: JPickerOptions | undefined, - commitCallback: ((proxy: ValProxy) => void) | null, - liveCallback: ((proxy: ValProxy) => void) | null, - cancelCallback: (() => void) | null, - _i18next?: unknown -): void { - // Resolve the initial color string (8-char ahex expected) - let initColor = 'ff0000ff' - if (options?.color?.active) { - const active = options.color.active - if (typeof active === 'string') { - // Strip leading # if present, normalise to 8-char - let hex = active.replace(/^#/, '').toLowerCase() - if (hex.length === 6) hex += 'ff' - initColor = hex - } else if (typeof active === 'object' && active !== null && typeof active.val === 'function') { - // Legacy jPicker Color object with .val('ahex') - initColor = active.val('ahex') || 'ff0000ff' - } else if (typeof active?.ahex === 'string') { - initColor = active.ahex - } - } - - const alphaSupport = options?.color?.alphaSupport ?? options?.window?.alphaSupport ?? true - - // Create and configure the se-color-picker element - const picker = document.createElement('se-color-picker') - picker.setAttribute('color', initColor) - picker.setAttribute('alpha-support', String(alphaSupport)) - - // Clear container content, append picker, show container - elem.textContent = '' - elem.appendChild(picker) - elem.style.display = 'block' - - // Helper: hide container on commit/cancel - const hide = (): void => { - elem.style.display = 'none' - } - - // Wire event listeners - picker.addEventListener('commit', (e: Event) => { - const detail = (e as CustomEvent<{ color: ColorModel }>).detail - const proxy = buildValProxy(detail.color) - hide() - if (commitCallback) { - commitCallback(proxy) - } - }) - - picker.addEventListener('cancel', () => { - hide() - if (cancelCallback) { - cancelCallback() - } - }) - - if (liveCallback) { - picker.addEventListener('live', (e: Event) => { - const detail = (e as CustomEvent<{ color: ColorModel }>).detail - const proxy = buildValProxy(detail.color) - liveCallback(proxy) - }) - } -} diff --git a/src/embed/client.ts b/src/embed/client.ts index b9b03a064..25a715533 100644 --- a/src/embed/client.ts +++ b/src/embed/client.ts @@ -1,4 +1,4 @@ -import { PROTOCOL_VERSION, isValidEnvelope } from './protocol.js' +import { PROTOCOL_VERSION, isValidEnvelope, ERROR_CODES } from './protocol.js' import type { ReadyPayload, EmbedEventName, ChromeState, ChromePreset } from './protocol.js' import { isOriginAllowed } from './origin.js' @@ -27,6 +27,9 @@ export class SvgEditEmbed { constructor (iframe: HTMLIFrameElement, opts: SvgEditEmbedOptions = {}) { this.iframe = iframe this.allowedOrigins = opts.allowedOrigins ?? [new URL(iframe.src, window.location.href).origin] + if (this.allowedOrigins.includes('*')) { + console.warn('SvgEditEmbed: wildcard origin enabled — only safe for dev/test') + } this._ready = new Promise((resolve, reject) => { this._resolveReady = resolve @@ -77,7 +80,7 @@ export class SvgEditEmbed { if (env.kind === 'event' && env.name === 'ready') { const payload = env.payload as ReadyPayload if (payload.protocolVersion !== PROTOCOL_VERSION) { - this._rejectReady(new Error(`svgedit embed: protocolVersion mismatch — host expects ${PROTOCOL_VERSION}, editor reports ${payload.protocolVersion}`)) + this._rejectReady(Object.assign(new Error(`svgedit embed: protocolVersion mismatch — host expects ${PROTOCOL_VERSION}, editor reports ${payload.protocolVersion}`), { code: ERROR_CODES.PROTOCOL_VERSION_MISMATCH })) return } this.isReady = true diff --git a/src/embed/server.ts b/src/embed/server.ts index d9b13dc66..56659492e 100644 --- a/src/embed/server.ts +++ b/src/embed/server.ts @@ -107,6 +107,10 @@ export class EmbedServer { if (!embedMode) return + if (this.allowedOrigins.includes('*')) { + console.warn('EmbedServer: wildcard origin enabled — only safe for dev/test') + } + if (params.chrome) applyChrome(document.body, resolveChromePreset(params.chrome)) else applyChrome(document.body, resolveChromePreset('none')) @@ -243,7 +247,7 @@ export class EmbedServer { const winner = await Promise.race([responsePromise, timeoutPromise]) if (typeof winner === 'object' && winner !== null && 'timeout' in winner) { this.pendingDialogReplies.delete(id) - this.emit('error', { message: 'dialog handler timed out', source: 'dialog-handler-timeout' }) + this.emit('error', { message: 'dialog handler timed out', source: 'dialog-handler-timeout', code: ERROR_CODES.DIALOG_HANDLER_TIMEOUT }) return this.invokeDefaultDialog(kind, args) } return winner