Skip to content

feat(media): emit @custom-media definitions with named condition references #64

Description

@Hebilicious

Goal

Close the Custom Media Queries entry in the README TODO with a media module that defines reusable named conditions once, emits native @custom-media rules, and lets the existing settings.atRule conditions reference those names.

The module is opt-in. Configurations without it keep their current CSS, JSON, TypeScript, and Style Dictionary output.

Motivation

Media conditions are duplicated as raw strings in every settings.atRule. The Colors example repeats @media (prefers-color-scheme: dark) for the dark theme and for each palette override, and example/basic/cssforge.config.ts repeats it once per dark palette color. Changing one breakpoint means finding every copy.

Media Queries Level 5 gives the same reason for the rule: repeated media queries are an editing hazard, and naming one turns a multi-site edit into a single line.

Current limitation

WithCondition carries only selector and atRule strings, and conditionalBuilder() emits the atRule value verbatim as a block opener. The reference half therefore already parses today: atRule: "@media (--dark)" produces @media (--dark) { ... }. What is missing is the definition, its placement, and validation of the name.

The definition cannot ride the current path. generateCSS() opens :root { at line 489, appends each module root output inside it, and appends only selector-based outside output after the closing brace at line 565. An atRule value of "@custom-media --dark (prefers-color-scheme: dark);" would emit @custom-media --dark (prefers-color-scheme: dark); { inside :root, and @custom-media belongs at the top level of the stylesheet.

Proposed API direction

export default defineConfig({
  media: {
    value: {
      "narrow-window": "(max-width: 30em)",
      "motion-safe": "(prefers-reduced-motion: no-preference)",
      modern: "(color), (hover)",
      "dark-scheme": true,
      legacy: false,
    },
  },
  colors: {
    palette: {
      value: {
        coral_dark: {
          value: { 100: { hex: "#FF6347" } },
          settings: { atRule: "@media (--dark-scheme)" },
        },
      },
    },
    theme: {
      dark: {
        value: {
          background: {
            value: { primary: "var(--coral)" },
            variables: { coral: "palette.coral.100" },
          },
        },
        settings: { atRule: "@media (--dark-scheme)" },
      },
    },
  },
});
/*____ CSSForge ____*/
/*____ Media ____*/
@custom-media --narrow-window (max-width: 30em);
@custom-media --modern (color), (hover);
@custom-media --dark-scheme true;
@custom-media --legacy false;

:root {
  /* Palette */
  @media (--dark-scheme) {
    --palette-coral_dark-100: oklch(...);
  }
}

Design choices that the implementation should settle explicitly:

Constraints from the specification

  • Grammar: @custom-media <extension-name> [ <media-query-list> | true | false ] ;. The name is a dashed ident and whitespace separates it from the value. at-custom-media-parsing.html marks @custom-media --query(max-width: 30em) invalid while @custom-media --query (max-width: 30em), --query (color), (hover), --query not all and (hover: hover), --query true, and --query false are valid.
  • A definition is referenced in boolean context as @media (--name). Normal and range contexts are syntax errors.
  • Evaluation is logical, not textual substitution. @custom-media --modern (color), (hover) used as @media (--modern) and (width > 1024px) means ((color) or (hover)) and (width > 1024px). The generated CSS must keep the definition and the reference, so a later build-time transform can decide how to lower it.
  • Cycles are forbidden, and the specification drops every custom media query in a loop. A diagnostic naming the loop is better than CSS that silently stops matching.
  • Repeated definitions of one name resolve by the rule in scope at evaluation time, which differs between the published Working Draft and current MDN description. Emitting all definitions before their first use satisfies both readings, and config keys cannot express redefinition, so a duplicate can only come from two keys normalizing to one name and should be reported like the collision in assertNoKeyCollisions().
  • @custom-media also qualifies @import, so definitions belong in a stylesheet prelude ahead of any @import and ahead of any referencing @media.

Platform reality

  • The rule is not Baseline. Firefox 148 implements it behind the layout.css.custom-media.enabled preference, Chrome tracks crbug.com/40781325, and Safari tracks webkit.org/b/233820, both without an implementation.
  • In a browser without support the definition is an unknown at-rule that is dropped, and @media (--name) holds an unknown media feature, which MQ5 error handling turns into not all. A theme override behind a named condition then never matches, without an error.
  • Consumers on everything except Firefox need a build-time transform, for example PostCSS Custom Media or Lightning CSS.
  • Every example Playwright project declares only a chromium project, for example example/vanilla-react-css/playwright.config.ts, so the current browser tests cannot prove that @media (--name) matches.

Acceptance criteria

  • The media section is opt-in: a configuration without it produces byte-identical CSS, JSON, TypeScript, and Style Dictionary output, proven against the existing snapshots.
  • Definitions are emitted at the top level of the stylesheet, ahead of :root { and ahead of every @media that references them, never inside a selector or at-rule block.
  • Emitted text follows the grammar, including the space between name and value, verbatim true and false, and comma separated lists. The valid and invalid cases from at-custom-media-parsing.html are covered at the library seam.
  • Generated names are validated as dashed idents (coordinate with bug(validation): reject or correctly escape token names that produce invalid CSS identifiers #29), and two configuration paths that generate one name are reported together.
  • A definition that references an unknown name fails with the configuration path and the missing name.
  • A reference cycle fails with a readable path such as media.a → media.b → media.a and writes no stylesheet.
  • settings.atRule: "@media (--name)" wraps palette, gradient, and theme declarations in that condition, and existing raw atRule strings keep their current output.
  • An atRule string that references a name cssforge does not define is reported at the configuration path that wrote it, while a plain native condition such as @media (min-width: 60em) stays valid and unvalidated.
  • Scope identity stays honest: describeScope() records the reference text, so @media (--narrow) and @media (max-width: 30em) remain distinct scopes even when they denote the same condition. Document that behavior and keep the feat(validation): add conservative diagnostics for selector- and condition-scoped token references #30 diagnostics consistent with it.
  • JSON, TypeScript, and Style Dictionary modes define what happens to the section, and the definitions neither appear as tokens nor collide with token paths.
  • The CLI (--mode css and --mode all) writes a stylesheet where every definition precedes its uses, proven by a CLI output test.
  • README documentation covers the config shape, the generated CSS through an md:generate block, the transform requirement, and the fallback behavior without @custom-media. The package README and docs pages are regenerated with moon run cssforge:readme-update and the docs sync-docs task, including the page list in sync-readme.ts, the sync-docs outputs, and the docs sidebar.
  • Verification records either a Firefox Playwright project with the preference enabled proving that a themed declaration applies only while the condition matches, or an explicit decision to rely on the transform path plus the documented unsupported-browser fallback. A Chromium-only run cannot prove this behavior.
  • The README TODO entry is checked off once the module lands.

Verification

  • moon run cssforge:test
  • moon run cssforge:typecheck
  • moon run cssforge:format
  • moon run cssforge:readme-check
  • moon run vanilla-react-css:cssforge-generate for the example integration, once an example uses a named condition

Non-goals

  • Expanding or substituting custom media queries during generation. Textual substitution does not reproduce the specification's logical evaluation, and the transform belongs to PostCSS or Lightning CSS.
  • A JavaScript runtime or CSS.customMedia script API.
  • General media query validation for raw atRule strings beyond the names cssforge itself defines.
  • Migrating the existing examples and README examples to named conditions, which can follow separately.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions