Skip to content

Use the new release of NudeElement#221

Merged
DmitrySharabin merged 4 commits intomainfrom
plugins-experiments
Mar 6, 2026
Merged

Use the new release of NudeElement#221
DmitrySharabin merged 4 commits intomainfrom
plugins-experiments

Conversation

@DmitrySharabin
Copy link
Copy Markdown
Member

@DmitrySharabin DmitrySharabin commented Dec 17, 2025

Summary

NudeElement migration

  • toggleState() (via states plugin) replaces manual ElementInternals state management.
  • static formBehavior replaces static formAssociated.

This is part 1 of 2 in a stack made with GitButler:

@netlify
Copy link
Copy Markdown

netlify Bot commented Dec 17, 2025

Deploy Preview for color-elements ready!

Name Link
🔨 Latest commit 0d9ceab
🔍 Latest deploy log https://app.netlify.com/projects/color-elements/deploys/69ab129c009c930007883f90
😎 Deploy Preview https://deploy-preview-221--color-elements.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@DmitrySharabin DmitrySharabin changed the title Use the upcoming release of NudeElement Use the new release of NudeElement and Nudeps Mar 4, 2026
@LeaVerou LeaVerou self-requested a review March 4, 2026 15:46
@DmitrySharabin DmitrySharabin force-pushed the plugins-experiments branch 4 times, most recently from 70737cb to a9f2fdc Compare March 4, 2026 21:02
Copy link
Copy Markdown
Member

@LeaVerou LeaVerou left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. One thing is we need to update the docs around installation: linking straight from element.colorjs.io won't work anymore. We should recommend a CDN, e.g. esm.sh

@DmitrySharabin DmitrySharabin requested a review from LeaVerou March 5, 2026 14:14
Comment thread _includes/component.njk Outdated
If you don't use a bundler, you can use [esm.sh](https://esm.sh):

```html
<script src="https://esm.sh/color-elements/{{ name }}" type="module"></script>
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this actually work?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We won't know until we release a new version with the exports. 😛

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can see what happens with other packages that use similar patterns

Comment thread src/color-slider/README.md Outdated
Comment thread package.json Outdated
Comment thread package.json Outdated
@DmitrySharabin DmitrySharabin force-pushed the plugins-experiments branch 2 times, most recently from 3ed0573 to 7ad24ae Compare March 6, 2026 12:13
@DmitrySharabin
Copy link
Copy Markdown
Member Author

DmitrySharabin commented Mar 6, 2026

One issue left (when testing with bundlers) is that shadow styles are not applied. I don't know why. Investigating.

EDIT: For Vite, the results are in the next comment.

I asked Claude whether other bundlers suffer from the Vite issue, and it looks like they all do. Here is the Claude's comment:

Build-time (affects ALL bundlers)
static styles = "./foo.css" is an opaque string literal — not an ES import. No bundler (webpack, Rollup, esbuild, Parcel) tracks it as a dependency, so the .css files are never emitted to the output directory. The runtime fetch() will 404 in every bundler's production build.

Dev server (mostly Vite-specific)
The wrong Content-Type issue is largely Vite-specific. Vite aggressively transforms all .css requests into JS HMR modules. Other dev servers (webpack-dev-server, Parcel dev) serve .css files as raw text/css, so the fetch() would actually work during development with those tools. The shadow styles would appear in dev but vanish in production — a subtler and more confusing failure mode.

Summary:

Bundler Dev server Production build
Vite ❌ Wrong Content-Type ❌ CSS not emitted
webpack ✅ Works ❌ CSS not emitted
Rollup / esbuild ✅ Works (no dev server) ❌ CSS not emitted
Parcel ✅ Likely works ❌ CSS not emitted

The root issue is that the runtime fetch() pattern is fundamentally incompatible with any bundler — the fix (using actual import ... ?inline or equivalent) is the right direction regardless of which bundler is used.

@DmitrySharabin
Copy link
Copy Markdown
Member Author

DmitrySharabin commented Mar 6, 2026

Root Cause

nude-element loads shadow DOM styles at runtime via fetch(). Each element class declares:

static styles = "./color-picker.css";
static url = import.meta.url; // used to resolve the relative CSS path

At runtime, nude-element resolves the full URL, fetches the CSS text, creates a CSSStyleSheet, and adopts it onto the shadow root via adoptedStyleSheets.

This mechanism breaks with Vite in two different ways:

1. Dev server — Vite intercepts all .css file requests and serves them as JavaScript modules (for HMR), with Content-Type: text/javascript. When nude-element fetches that URL and calls sheet.replaceSync(jsCode), the result is an empty stylesheet.

2. Production build — The static styles = "./color-picker.css" string is an opaque string literal to Vite. Vite has no visibility into it as a CSS dependency, so the .css files are never emitted to dist/. At runtime, fetch() tries to load ./color-picker.css relative to the JS bundle URL (e.g., /assets/index-abc123.js) → the resolved path /assets/color-picker.css does not exist → 404.

Solution

Add a small Vite plugin to vite.config.js that rewrites static styles = "./foo.css" into a ?inline CSS import during Vite's transform phase:

// Before (what's in source):
static styles = "./color-picker.css";

// After (what Vite sees):
import __shadow_styles_0 from "./color-picker.css?inline";
static styles = [{ css: __shadow_styles_0 }];

Vite's ?inline modifier returns the CSS as a plain string (no DOM injection, no HMR wrapper). nude-element's defineStyles() already handles { css: string } objects natively via its adoptStyle function — no library changes needed.

vite.config.js:

function inlineShadowStyles() {
  const RE = /\bstatic\s+styles\s*=\s*(['"])(\.\/[^'"]+\.css)\1/g;

  return {
    name: "inline-shadow-styles",
    transform(code, id) {
      if (!id.includes("/color-elements/") || !id.endsWith(".js")) return null;

      RE.lastIndex = 0;
      if (!RE.test(code)) return null;
      RE.lastIndex = 0;

      const imports = [];
      let i = 0;
      const newCode = code.replace(RE, (_match, _quote, cssPath) => {
        const varName = `__shadow_styles_${i++}`;
        imports.push(`import ${varName} from "${cssPath}?inline";`);
        return `static styles = [{ css: ${varName} }]`;
      });

      return { code: imports.join("\n") + "\n" + newCode, map: null };
    },
  };
}

export default {
  plugins: [inlineShadowStyles()],
  // ... rest of config
};

This fix works for both dev and production builds work in Chrome and Safari.

UPDATE: It doesn't work in the production build.

UPDATE 2: It doesn't work in the production build in Firefox. 🤦‍♂️

Comment thread _build/copy-config.js Outdated
});
});

// Inject import map script into copied plain.njk, before the first script or </head>
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is extremely awkward, do we really need it? There must be a better solution, e.g. a separate headinclude template that this pulls in.

Comment thread src/color-slider/color-slider.js
Comment thread src/color-slider/README.md Outdated
Comment thread package.json Outdated
"dependencies": {
"@11ty/eleventy": "^3.1.2",
"colorjs.io": "^0.5.0",
"colorjs.io": "^0.6.1",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should have a very liberal version range here so that npm just uses whatever colorjs.io is installed in the project already and only installs a separate one in very rare cases.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If people install this in a package that already has its own colorjs.io, we want it to use that colorjs.io, not some other one. But we don't want to declare it as a peerDependency because then if they don't have it they now have to install two packages.
Though according to https://stackoverflow.com/questions/35207380/how-to-install-npm-peer-dependencies-automatically a peer dependency might work. I wonder if this means we need to support peer dependencies. I wonder how JSPM handles them? 🤔

Copy link
Copy Markdown
Member Author

@DmitrySharabin DmitrySharabin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Production Build Investigation (Vite bundling)

Test Setup

  • Test project with a single <color-picker space="oklch" color="oklch(60% 30% 180)">
  • Loaded via <script type="module">import "color-elements"</script>
  • Bundled with Vite 7, build.target: "esnext", minify: false

Browser Results

Browser Status
Chrome Canary ✅ Works
Safari ✅ Works
Zen (Firefox) ❌ Blank page

Zen Error

Uncaught (in promise) TypeError: can't access property "space_picker", this._el is undefined

Call stack (bottom → top):

constructed()          ← NudeElement constructor calls this.constructed() during super()
  $hook()             ← fires a hook
    run() ×4          ← hook chain
      constructed()   ← "constructed" hook handler (props plugin)
        initializeFor ← processes observed HTML attributes
          attributeChanged ← processes "space" attribute
            set       ← sets spaceId prop
              convert ← converts value
                get   ← reads spaceId
                  get ← reads space (depends on spaceId)
                    update
                      get ← space.get() → this._el.space_picker.selectedSpace → 💥

Root Cause Analysis

The <color-picker> is in the HTML before customElements.define() runs (module script is deferred), so it gets upgraded. During upgrade: constructor → attributeChangedCallbackconnectedCallback.

nude-element's constructed() method defers the "constructed" hook via microtask (Promise.resolve().then()), and connectedCallback() also fires it synchronously (with { once: true }). In both cases, the hook should fire after the full constructor chain completes, meaning this._el = named(this) in ColorPicker's constructor should have already run.

But the stack trace shows the "constructed" hook firing synchronously during super() in NudeElement's this.constructed() call — i.e., before this._el = named(this) runs in the ColorPicker constructor.

Hypotheses (unconfirmed)

  1. Firefox fires connectedCallback during upgrade construction — If Firefox fires connectedCallback synchronously as part of the constructor during element upgrade (unlike Chrome/Safari), the "constructed" hook would fire before the subclass constructor completes.
  2. Child element creation triggers parent callback — When this.shadowRoot.innerHTML = template runs in ColorElement's constructor (during super()), child custom elements (<space-picker>, <color-swatch>) are created. Their connection to the already-connected shadow root might somehow trigger a callback on the parent.

Build Notes

  • CSS is correctly inlined as [{ css: "..." }] objects (not fetched at runtime)
  • static globalStyles = "./color-chart-global.css" on ColorChart is still a URL string (would 404 at runtime but doesn't cause the blank page)
  • import.meta.url in bundled components all resolve to the bundle URL (doesn't matter since shadow styles are inlined)

Next Steps

  1. Test in Zen with vite dev (not build) to isolate bundling vs browser behavior
  2. Inspect $hook() implementation for a synchronous firing path
  3. Try a guard fix: this._el?.space_picker?.selectedSpace

@LeaVerou
Copy link
Copy Markdown
Member

LeaVerou commented Mar 6, 2026

@DmitrySharabin Just saw the posts about bundlers. This is fantastic research, but it should not be lost in this PR, create an issue!

@DmitrySharabin
Copy link
Copy Markdown
Member Author

@DmitrySharabin Just saw the posts about bundlers. This is fantastic research, but it should not be lost in this PR, create an issue!

Done. #222

@DmitrySharabin DmitrySharabin changed the title Use the new release of NudeElement and Nudeps Use the new release of NudeElement Mar 6, 2026
Copy link
Copy Markdown
Member

@LeaVerou LeaVerou left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we also update package.json? LGTM otherwise

@DmitrySharabin
Copy link
Copy Markdown
Member Author

Shouldn't we also update package.json? LGTM otherwise

In package.json, we have "nude-element": "latest", so we are safe.

@DmitrySharabin DmitrySharabin merged commit e9a2b16 into main Mar 6, 2026
4 checks passed
@DmitrySharabin DmitrySharabin deleted the plugins-experiments branch March 6, 2026 21:33
@LeaVerou
Copy link
Copy Markdown
Member

LeaVerou commented Mar 6, 2026

Shouldn't we also update package.json? LGTM otherwise

In package.json, we have "nude-element": "latest", so we are safe.

That's a bit weird, it means if someone cloned this repo and ran npm install before this PR was merged they'd get a broken version.

@DmitrySharabin
Copy link
Copy Markdown
Member Author

Shouldn't we also update package.json? LGTM otherwise

In package.json, we have "nude-element": "latest", so we are safe.

That's a bit weird, it means if someone cloned this repo and ran npm install before this PR was merged they'd get a broken version.

This is how the things were all the time. I'll fix the version in the following PR.

@DmitrySharabin
Copy link
Copy Markdown
Member Author

Shouldn't we also update package.json? LGTM otherwise

In package.json, we have "nude-element": "latest", so we are safe.

That's a bit weird, it means if someone cloned this repo and ran npm install before this PR was merged they'd get a broken version.

This is how the things were all the time. I'll fix the version in the following PR.

Fixed. Now the package version is explicitely specified.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants