Skip to content

Published pages: unlayered reset/framework CSS defeats @layer-scoped user stylesheets (inverts documented cascade order) #345

Description

@gaurangrshah

Summary

On published pages, Instatic's own reset-<hash>.css and framework-<hash>.css are emitted outside any cascade layer, while a user stylesheet that uses @layer keeps its layers (correctly preserved verbatim by "Keep as stylesheet" import mode, or by authoring in the code editor). Per CSS Cascade 5, unlayered author styles take precedence over all layered author styles for normal declarations — before specificity is considered. The result: the runtime reset silently overrides the entire user design system on every conflicting property.

This inverts the documented cascade contract in docs/features/publisher.md:

class registry, which wins over framework, which wins over reset

For layered user CSS, reset wins over everything instead.

Reproduction

  1. Import a static site whose stylesheets are organized with @layer (e.g. @layer reset, base, layout, chrome, components; @layer base { body { font-family: "Some Font", sans-serif } } @layer layout { .lede { margin-block-start: 3rem } }), choosing Keep as stylesheet in the Review step (correct choice, since @layer can't be converted — as documented in docs/features/site-import.md).
  2. Publish.
  3. Inspect any published page.

Expected: the kept stylesheet's rules style the page (they load last, and the docs say user styles win over framework/reset).

Actual: every property that the runtime reset also declares is taken from the reset, regardless of the user rules' specificity:

  • :where(*) { margin: 0; padding: 0 } zeroes all layered margins/paddings → vertical rhythm collapses. Note the :where() wrapper's zero specificity does not help here — layer ordering is resolved before specificity, and unlayered wins.
  • :where(body) { font-family: system-ui, ... } beats a layered body { font-family: ... } → whole site falls back to system fonts.
  • :where(button) { background: none; border: 0 } beats layered component styling → controls render unstyled.

Only rules the reset doesn't conflict with (backgrounds, colors, WebGL canvas positioning) survive, so the page looks almost right — which makes the failure easy to miss in QA. Observed on a source build at 2af2ecd1 (post-0.0.12).

Root cause

CSS Cascade 5 origin/layer sorting: within the author origin, normal declarations in the implicit outer (unlayered) layer beat all named-layer declarations. The published <head> loads resetframeworkuserStyles; the first two are unlayered, so any @layer-scoped user CSS — no matter how specific or how late-loaded — loses every direct property conflict.

Suggested fix

Wrap the runtime bundles in named layers declared before any user CSS, e.g. emit at the very top of the style head:

@layer instatic.reset, instatic.framework;

and emit PUBLISHER_RESET_CSS inside @layer instatic.reset { ... }, framework CSS inside @layer instatic.framework { ... } (also the module/class CSS if it should stay below user styles). Then:

  • unlayered user CSS still wins (unchanged behavior for the common case);
  • @layer-scoped user CSS now also wins, because user layers are declared later than the instatic.* layers — restoring the documented "user beats framework beats reset" ordering for both authoring styles.

The editor canvas's user-stylesheet pipeline would need the same treatment so preview and publish agree.

Workaround (for anyone hitting this today)

Flatten @layer out of stylesheets before import: emit the rules unlayered, ordered by the declared layer precedence (so equal-specificity conflicts still resolve like the layer order), then import. Downgrades the layer-order-beats-specificity semantics to specificity+source-order, but in practice restores the design faithfully — verified with computed-style probes on a real site.

Related docs

  • docs/features/site-import.md — documents @layer as unconvertible and "Keep as stylesheet" as the escape hatch; the kept file then hits this at publish time.
  • docs/features/publisher.md — the cascade table this behavior contradicts.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions