Skip to content

v2.2.6

Choose a tag to compare

@Samyssmile Samyssmile released this 05 Jul 10:59
· 78 commits to main since this release

Fixed

  • Host-page CSS Shadow Part styling and PrintOptions.customCSS now apply in print output (#202). A consumer styling the table from the host page, for example notectl-editor::part(table-cell) { padding: 0; }, saw it work in the live editor but silently vanish in print, and passing an equivalent rule through PrintOptions.customCSS did not help either: the old print pipeline flattened the editor out of its shadow root into a plain document where ::part() can never match, and it never read the host page's stylesheets at all. The print document is now built with Declarative Shadow DOM. The cloned content lives in a real shadow root on a replicated <notectl-editor> host element, and the host page's stylesheets (including document-level adopted sheets and sheets from enclosing shadow roots) are copied verbatim into the print document inside a notectl-host cascade layer. Because the cascade compares tree context before layers, the copied rules keep their full override power over the editor's shadow styles while the print document's own unlayered rules (page setup, host-element reset, forced light theme) stay in control. As a result, ::part() selectors, selector specificity, custom properties (var(--...)), @media/@supports/@layer conditions, CSS nesting, bare ::part() without a host prefix, and @import chains all behave exactly as in the live editor; nothing is translated or re-emulated. Cross-origin stylesheets are re-referenced via layered @import so the print document loads them itself (printing waits for the document load event, bounded by a timeout so an unreachable stylesheet can never block the dialog), disabled stylesheets are skipped, and stylesheet-level media conditions are preserved. Host rules that style the editor widget itself (height, border, padding) are neutralized so screen chrome cannot constrain the paginated output. The neutralization and the forced light theme are emitted as !important declarations inside a notectl-print cascade layer declared before notectl-host; because the layer order reverses for important declarations, both guarantees hold even against !important host rules. A second customCSS copy in the earliest notectl-custom layer keeps an escape hatch: consumer !important rules outrank the print guards and any host rule. Embedded CSS is also hardened against literal </style> sequences (for example content: "</style>"), which are escaped to the equivalent CSS escape <\/ so they cannot terminate a <style> element of the print document early. The editor's own base styles sit in a @layer notectl-base cascade layer inside the shadow root while runtime style-token rules (user-applied text formatting in strict-CSP mode) stay outside it, so PrintOptions.customCSS overrides the editor's built-in element styling regardless of specificity, which fixes the reported customCSS case: a bare [part~="table-cell"] (specificity 0,1,0) previously lost to the editor's .notectl-table td (0,1,1). With the default forceLightTheme: true, theme-conditional host rules such as html.dark notectl-editor::part(...) stay inactive in print; with forceLightTheme: false the page's theme context (class/data-* on <html> and <body>) is carried so they apply exactly as live. Covered by unit tests for the stylesheet copier and both CSS scopes, plus sixteen Playwright regression tests (because ::part() cannot be exercised under happy-dom) spanning part styling, customCSS, specificity, nesting, layers, media conditions, var(), @import, disabled sheets, widget-chrome neutralization (including against !important host rules), forced-light-theme token protection, the customCSS !important escape hatch, </style> escaping, and theme-conditional rules.

  • Print output is hardened against host-page CSS that would blank, clip, or discolor it (#202 review follow-up). Copying the host page's stylesheets verbatim (the mechanism behind Shadow Part fidelity in print) also copies rules that were never meant for the print document, and four cases could degrade output severely. First, page-level hiding and clipping: the classic "print only one section" pattern @media print { body * { visibility: hidden } } blanked the printed editor entirely, and app-shell rules like html, body { height: 100%; overflow: hidden } clipped it to a single viewport. The host-element reset now restores visibility, and a new html/body page reset inside the notectl-print layer guards every hide/clip-capable property (display, visibility, opacity, position, height, overflow, transform, filter, contain) with !important, so it beats even !important host rules while customCSS (earlier layer) still wins; box properties like margin stay unguarded so plain body { margin: … } customCSS keeps working. Second, content clipping: a host-carried --notectl-content-max-height token or ::part(content) height rule turned the printed content area into a scroll container, so long documents printed only the first scroll fold; the shadow-scope print rules now force height: auto, max-height: none, min-height: 0, and overflow: visible on the content area (shadow-tree !important outranks even !important host ::part() rules, and the screen min-height no longer pads short documents either). Third, a hanging stylesheet: printing waits for the iframe's load event so hoisted cross-origin @import stylesheets are styled on first paint, but an unreachable CDN meant the load event never fired, the dialog never opened, and every retry leaked a hidden iframe; the wait is now bounded by a timeout that proceeds with the styles loaded so far. Fourth, dark-theme output: the print document unconditionally pinned body { background: #ffffff }, so print({ forceLightTheme: false }) on a dark-themed editor produced near-white text on a white page; the pinned background now follows the mode: white when forcing the light theme, otherwise the background the editor content visually sits on, resolved by walking up from the content area across the shadow boundary to the first non-transparent ancestor. Covered by new unit tests for the page reset, the content unclipping, the background resolution, and both timeout paths, plus five new Playwright regression tests that prove the guards against real !important host CSS, the carried dark background, and the static-replica and fallback-script behavior of the embedded output.

  • Copied host CSS now survives the move into the print document with its URLs, cascade layers, and conditions intact (#202 review follow-up, part two). Four fidelity gaps in the stylesheet copy are closed. First, relative url() references: copied cssText keeps URLs as authored, but in the print document they re-resolved against the page URL instead of the stylesheet's location, so fonts and background images from stylesheets in other directories 404ed; relative url() tokens (bare, single- and double-quoted) are now rewritten to absolute URLs against their source stylesheet's URL (inline <style> rules against the document base, inlined @import content against the imported sheet's URL), while fragment-only references (url(#gradient) SVG paint servers), data:/blob:/scheme'd, and protocol-relative URLs stay untouched. Second, @import layer(...): inlining a layered import dropped its cascade-layer assignment, so @import url(defaults.css) layer(defaults) content competed on raw specificity against page rules it loses to live (layered loses to unlayered); the import's layer now survives as a nested @layer block (a notectl-host sublayer, which orders before the parent layer's direct rules, exactly mirroring the live relationship), anonymous layer imports stay anonymous via nameless blocks, and hoisted cross-origin imports carry layer(notectl-host.<name>). Third, @import ... supports(...): the condition was dropped on inlining, applying rules the live browser never applied; it is now preserved as an @supports (...) wrapper (parenthesized, since the import grammar allows a bare declaration where the block grammar does not) and carried verbatim on hoisted imports. Fourth, forceLightTheme: false theme carrying regained its computed-token fallback: tokens set via the host's inline style attribute (stripped from the replica), via JS setProperty, or via rules scoped to wrapper elements absent from the print document could not travel through the stylesheet copy and silently fell back to default theme colors; all computed --notectl-* tokens are now snapshotted onto :root and the host element (unlayered, see the fourth review follow-up below for the final precedence). Additionally, under a strict CSP the print document previously rendered completely unstyled because its inline <style> elements carried no nonce; the style-root nonce (from registerStyleRoot) is now applied to the styles of the transient print iframe document (the serialized toHTML()/AFTER_PRINT output deliberately stays nonce-free, see the third review follow-up below). Covered by new unit tests for URL rebasing (quoting forms, non-rebasable schemes, document-base and import-base resolution), layer/supports preservation on both the inline and hoist paths, the token snapshot and its layer placement, and nonce emission, plus two Playwright tests proving in a real browser that a layered import loses to an unlayered page rule in print exactly as live (with a false supports() import staying inactive) and that relative url() references print with the stylesheet-relative path.

  • Print pipeline hardening after a second production-readiness review (#202 review follow-up, part three). Ten confirmed defects are fixed. (1) The print-color-adjust: exact guard lived only inside the shadow scope, so the printed page background (notably the carried dark background for forceLightTheme: false) was stripped by the browser's default "no background graphics" behavior, leaving near-white text on white paper; a document-scope copy now sits in the notectl-print layer. (2) The export document (toHTML()/AFTER_PRINT) rendered blank for consumers that inject it via innerHTML (no declarative-shadow-DOM parsing, no script execution); it now embeds a static light-DOM fallback inside the replica host. A shadow host's unslotted light DOM never renders, so the fallback is invisible wherever DSD parses, and the fallback script removes it once a shadow root attaches so extracted text is not doubled. (3) The NotectlEditor constructor preserved any pre-existing declarative shadow root, so a replica whose data-notectl-static marker was stripped (sanitizers, template pipelines), or one that received a direct init() call, booted a live editor stacked below the leftover print markup; unmarked declarative roots are now cleared when the editor boots (restoring the pre-DSD invariant) and init() on a marked replica throws. (4) The forced light theme tokens were emitted only on the host element, so page-level customCSS referencing var(--notectl-*) on body/html resolved to guaranteed-invalid; they now target :root and the host element. (5) url() rebasing matched tokens inside CSS string values (content: "url(info)") and corrupted the declaration by injecting quotes; the scan is now string-aware and also resolves CSS escape sequences before building the absolute URL. (6) The forceLightTheme: false theme snapshot alone could not preserve runtime token overrides set as inline style on the editor element (the only page-side declaration that beats the shadow theme defaults) because the replica strips the style attribute; inline --notectl-* declarations of the host, <html>, and <body> are now re-emitted on the replicated elements, keeping their live cascade strength over every copied stylesheet rule. (7) The page's live CSP style nonce was serialized as a plain attribute into toHTML()/AFTER_PRINT output, persisting a per-session secret into stored artifacts; the export document is now always nonce-free and only the transient print iframe document carries the nonce. (8) The DSD fallback script was stamped with the style nonce, which a split style-src/script-src policy rejects; the script is now emitted without a nonce (the static fallback covers blocked-script consumers) and the internal print iframe embeds no script at all. (9) Hoisted cross-origin @import statements were emitted ahead of all copied rules, flipping equal-specificity source-order ties against the live page; the host CSS copy is now an ordered segment list and each hoisted import is emitted as its own <style> element at its original source position. (10) A print waiting on its iframe's load event (bounded 4s) could pop the dialog after the editor was destroyed, e.g. over an unrelated SPA view; PrintPlugin.destroy() now cancels pending print triggers and removes the parked iframes. Covered by new unit tests across the service, collector, copier, and editor shell, plus six new Playwright regression tests (document-scope color-adjust, page-level var() resolution, inline token carry, innerHTML static fallback, stripped-marker upgrade, and cross-origin source-order fidelity).

  • The export document is now embed-safe, and print theming survives more host-page patterns (#202 review follow-up, part four). Five defects around embedding and theming are fixed. (1) Registering the component before the markup is parsed (bundle as a classic script in <head>) blanked the whole export: the parser runs a custom element constructor before attributes and children exist, so the constructor's eagerly attached empty shadow root blocked the declarative template from attaching, and the fallback script then removed both the template and the static fallback. Shadow-root creation is deferred out of the constructor (it happens when the editor actually boots), so the declarative root always attaches regardless of registration order. (2) The export document's page-level styles (body reset, html/body and notectl-editor !important resets, :root light-theme tokens, @page, and the full host-CSS copy) applied document-wide when the output was inlined into a live page via the documented innerHTML/setHTMLUnsafe flow, turning dark consumer apps white, flattening and force-lighting every live editor on the page, and re-applying the copied page CSS. The export's inline styles are now qualified with the replica marker (notectl-editor[data-notectl-static]), and everything page-level ships inside an inert <template data-notectl-print-styles> bundle that is only activated for standalone documents; a marker <meta> in the export's head serves as the standalone detector, since fragment parsing never places it into the embedding page's document.head. Standalone renderings (file, srcdoc, document.write, HTML-to-PDF engines) keep full page fidelity including hoisted cross-origin @imports; inline embeds are inert by construction, and host ::part() styling of the embedding page applies to the replica natively. (3) The DSD fallback script swept template[shadowrootmode] document-wide, consuming declarative templates that belong to the consumer's own components when the export was inlined through a script-executing sink; it now only attaches a template that is a direct child of a marked replica. (4) With forceLightTheme: false, the carried theme-token snapshot sat in a cascade layer below the host-CSS copy, so a host token rule that matches differently in the print document (its wrapper ancestors are not replicated, leaving only an unscoped light default) overrode the live dark values and produced dark text on the carried dark background; the snapshot is now emitted unlayered and targeted at the host element itself, so the computed live tokens always win over the copy, and a deliberate print-specific token override needs !important (or customCSS). (5) The carried-background walk stopped at body, so themes that paint html and leave body transparent (a common dark-theme pattern) printed light text on a white fallback background; the walk now continues to the root element. Covered by new unit tests across the editor shell, style collector, and print service, plus five Playwright regression tests: a UMD-in-head page proving parser-order replicas render, an embed probe proving the consumer page's computed styles stay untouched, a consumer-template probe for the scoped fallback script, a wrapper-scoped dark theme surviving into print, and an html-level background carried onto the printed page.

  • Embedded print exports no longer collide with the live editor's id, and whenReady() settles on static replicas (#202 review follow-up, part five). Two defects on the embed path are fixed. (1) The replicated host element copied the live editor's id attribute, so embedding the export next to the live editor created a duplicate id: document.getElementById resolved to the static replica, and consumer code that boots or addresses the editor by id silently operated on the wrong element. The export variant now strips the id; the internal print iframe keeps it, since the iframe is its own document (no collision is possible) and host rules like #editor::part(cell) must keep matching there. (2) whenReady() on a static replica returned a promise that never settled, because a replica never initializes; a consumer awaiting readiness of every editor-tagged element on the page (for example via Promise.all) hung forever. It now rejects immediately with the same descriptive error init() throws. Both fixes are covered by new unit tests.

Changed

  • The print document returned by PrintService.toHTML() (and passed to AFTER_PRINT) now uses Declarative Shadow DOM. The editor content sits inside a <template shadowrootmode="open"> on a replicated host element instead of lying flat in <body>. Rendering the string in an iframe (document.write, srcdoc) or a new window works unchanged; injecting it into an existing page via innerHTML does not parse declarative shadow roots; the document then renders its embedded static fallback (readable, statically styled, without host ::part() fidelity), so use setHTMLUnsafe() for full fidelity. That path is safe even in a page that has <notectl-editor> registered: the replicated host carries a data-notectl-static marker, the component defers shadow-root creation out of its constructor entirely (so a pre-parsed declarative root always attaches, whatever the registration order), and connectedCallback skips initialization for marked replicas, so the print markup never turns into a live editor. For consumers that execute scripts but do not parse Declarative Shadow DOM (older WebViews, headless HTML-to-PDF engines), the document embeds a small fallback script that attaches the remaining shadow template manually and then removes the static fallback so extracted text is not doubled; engines without Shadow DOM keep the rendered static fallback. Previously working toHTML() pipelines therefore keep producing readable output instead of a blank page, with or without script execution. customCSS is applied both at document level (so @page and body rules keep working) and inside the editor's shadow root (for content rules); host-page ::part() rules rank above customCSS for the same property, matching live-editor cascade semantics. Restyling the print host element itself or overriding the forced light theme from customCSS now requires !important on those declarations (they then outrank everything in the print document). Rules scoped to ancestor elements of the editor (for example .dashboard-card notectl-editor::part(...)) are not carried: the print document replicates only the host element, so wrapper-scoped selectors have nothing to match there; use an unscoped rule or customCSS for styling that must survive into print.

Full changelog: https://github.com/Samyssmile/notectl/blob/main/CHANGELOG.md