v2.2.6
Fixed
-
Host-page CSS Shadow Part styling and
PrintOptions.customCSSnow apply in print output (#202). A consumer styling the table from the host page, for examplenotectl-editor::part(table-cell) { padding: 0; }, saw it work in the live editor but silently vanish in print, and passing an equivalent rule throughPrintOptions.customCSSdid 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 anotectl-hostcascade 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/@layerconditions, CSS nesting, bare::part()without a host prefix, and@importchains all behave exactly as in the live editor; nothing is translated or re-emulated. Cross-origin stylesheets are re-referenced via layered@importso 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!importantdeclarations inside anotectl-printcascade layer declared beforenotectl-host; because the layer order reverses for important declarations, both guarantees hold even against!importanthost rules. A secondcustomCSScopy in the earliestnotectl-customlayer keeps an escape hatch: consumer!importantrules outrank the print guards and any host rule. Embedded CSS is also hardened against literal</style>sequences (for examplecontent: "</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-basecascade layer inside the shadow root while runtime style-token rules (user-applied text formatting in strict-CSP mode) stay outside it, soPrintOptions.customCSSoverrides the editor's built-in element styling regardless of specificity, which fixes the reportedcustomCSScase: a bare[part~="table-cell"](specificity 0,1,0) previously lost to the editor's.notectl-table td(0,1,1). With the defaultforceLightTheme: true, theme-conditional host rules such ashtml.dark notectl-editor::part(...)stay inactive in print; withforceLightTheme: falsethe 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!importanthost rules), forced-light-theme token protection, thecustomCSS!importantescape 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 likehtml, body { height: 100%; overflow: hidden }clipped it to a single viewport. The host-element reset now restoresvisibility, and a newhtml/bodypage reset inside thenotectl-printlayer guards every hide/clip-capable property (display,visibility,opacity,position,height,overflow,transform,filter,contain) with!important, so it beats even!importanthost rules whilecustomCSS(earlier layer) still wins; box properties likemarginstay unguarded so plainbody { margin: … }customCSS keeps working. Second, content clipping: a host-carried--notectl-content-max-heighttoken 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 forceheight: auto,max-height: none,min-height: 0, andoverflow: visibleon the content area (shadow-tree!importantoutranks even!importanthost::part()rules, and the screenmin-heightno longer pads short documents either). Third, a hanging stylesheet: printing waits for the iframe's load event so hoisted cross-origin@importstylesheets 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 pinnedbody { background: #ffffff }, soprint({ 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!importanthost 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: copiedcssTextkeeps 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; relativeurl()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@importcontent 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@layerblock (anotectl-hostsublayer, which orders before the parent layer's direct rules, exactly mirroring the live relationship), anonymouslayerimports stay anonymous via nameless blocks, and hoisted cross-origin imports carrylayer(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: falsetheme carrying regained its computed-token fallback: tokens set via the host's inlinestyleattribute (stripped from the replica), via JSsetProperty, 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:rootand 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 (fromregisterStyleRoot) is now applied to the styles of the transient print iframe document (the serializedtoHTML()/AFTER_PRINToutput 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 falsesupports()import staying inactive) and that relativeurl()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: exactguard lived only inside the shadow scope, so the printed page background (notably the carried dark background forforceLightTheme: 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 thenotectl-printlayer. (2) The export document (toHTML()/AFTER_PRINT) rendered blank for consumers that inject it viainnerHTML(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) TheNotectlEditorconstructor preserved any pre-existing declarative shadow root, so a replica whosedata-notectl-staticmarker was stripped (sanitizers, template pipelines), or one that received a directinit()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) andinit()on a marked replica throws. (4) The forced light theme tokens were emitted only on the host element, so page-levelcustomCSSreferencingvar(--notectl-*)onbody/htmlresolved to guaranteed-invalid; they now target:rootand 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) TheforceLightTheme: falsetheme 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 thestyleattribute; 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 intotoHTML()/AFTER_PRINToutput, 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 splitstyle-src/script-srcpolicy 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@importstatements 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-levelvar()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/bodyandnotectl-editor!importantresets,:rootlight-theme tokens,@page, and the full host-CSS copy) applied document-wide when the output was inlined into a live page via the documentedinnerHTML/setHTMLUnsafeflow, 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'sdocument.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 swepttemplate[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) WithforceLightTheme: 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(orcustomCSS). (5) The carried-background walk stopped atbody, so themes that painthtmland leavebodytransparent (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 anhtml-level background carried onto the printed page. -
Embedded print exports no longer collide with the live editor's
id, andwhenReady()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'sidattribute, so embedding the export next to the live editor created a duplicate id:document.getElementByIdresolved 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 theid; 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 viaPromise.all) hung forever. It now rejects immediately with the same descriptive errorinit()throws. Both fixes are covered by new unit tests.
Changed
- The print document returned by
PrintService.toHTML()(and passed toAFTER_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 viainnerHTMLdoes not parse declarative shadow roots; the document then renders its embedded static fallback (readable, statically styled, without host::part()fidelity), so usesetHTMLUnsafe()for full fidelity. That path is safe even in a page that has<notectl-editor>registered: the replicated host carries adata-notectl-staticmarker, the component defers shadow-root creation out of its constructor entirely (so a pre-parsed declarative root always attaches, whatever the registration order), andconnectedCallbackskips 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 workingtoHTML()pipelines therefore keep producing readable output instead of a blank page, with or without script execution.customCSSis applied both at document level (so@pageandbodyrules keep working) and inside the editor's shadow root (for content rules); host-page::part()rules rank abovecustomCSSfor the same property, matching live-editor cascade semantics. Restyling the print host element itself or overriding the forced light theme fromcustomCSSnow requires!importanton 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 orcustomCSSfor styling that must survive into print.
Full changelog: https://github.com/Samyssmile/notectl/blob/main/CHANGELOG.md