Theme colours

Everything is generated from the $colors map — a family name plus a seed colour. Each family gives you a numbered scale (--jf-color-primary-500 …), semantic role aliases, paired text colours, a button / chip / callout / progress / table variant, a .bg-* surface, and a .text-* class (its 500).

Add a family — $colors: ("brand": #0b7) — and all of that appears for it. Contrast pairs are WCAG-checked at build time.

Semantic role aliases

Alongside the numbered scale, every family in $colors emits five semantic aliases so you can reference a step by intent instead of guessing a number:

AliasDefault shadeUse for
--jf-color-{family}500The colour itself.
--jf-color-{family}-subtle50Faint tinted backgrounds.
--jf-color-{family}-muted300Borders, low-emphasis fills, visited links.
--jf-color-{family}-emphasis700Hover & active states, heading tone.
--jf-color-{family}-strong900Text on a tint, strongest headings.

On primary:

primary
-subtle
-muted
-emphasis
-strong

Each alias is a live var() reference to the numbered token, so a runtime override of a shade flows through to its aliases. Retune which shade a role points at — for the whole framework — with the $color-role-shades map; from SCSS, reference an alias with get-color-role($family, $role) (or role-shade($role) for the raw number).

    
      .promo {
        border: 1px solid get-color-role(primary, muted);
        background: get-color-role(primary, subtle);
      }
      .promo h3 { color: get-color-role(primary, strong); }
    
  

Background modifiers

A .bg-{family} class is generated for every family in $colors.bg-primary, .bg-neutral, … each painting that family at its 500 value. Put one on any element to theme it as a surface; don't set the tokens by hand.

.bg-primary
.bg-secondary
.bg-neutral
.bg-success
.bg-warning
.bg-error

Three neutral surfaces sit alongside the families and flip with the theme:

  • .bg-base — the page canvas itself. Put it on an element inside a .bg-* section to reset it back to the body surface (background, text, headings and the muted / subtle tiers).
  • .bg-soft — a little elevation off the canvas, not emphasised. Literal white in light (a gentle lift against the warm off-white body), a raised dark tint in dark. This is what a panel, callout, chip or modal reaches for when it wants to read as slightly raised without taking a theme colour.
  • .bg-inverse — a full reverse of the page: the opposite theme's canvas, so it reads as a deliberate high-contrast block (dark in light mode, light in dark mode), with its text and headings following.
.bg-base
.bg-soft
.bg-inverse

Each surface is a background token plus its paired text / heading tokens: --jf-color-bg + --jf-color-text-base / --jf-color-headings-base for the canvas (the plain --jf-color-text / -headings alias the -base pair); --jf-color-bg-soft + -text-soft / -headings-soft; and the --jf-color-*-inverse set. Set the soft pair per project with $soft-background-color / $dark-soft-background-color.

For a specific shade or a one-off colour, add an entry to the $background-colors map — key is the class suffix, value is a get-color() reference or a raw CSS colour. Reusing a family name as the key overrides its auto class.

    
      $background-colors: (
        "primary-tint": get-color(primary, 100),
        "brand":        #412d1f,
      );
      // -> .bg-primary-tint, .bg-brand
    
  

Each modifier sets the background and picks text and heading colours that stay readable on it. The lighter .text-muted and .text-subtle shades re-mix to suit the new background too, and anything nested inside — headings, cards, callouts, muted text — picks all of this up on its own:

Heading

Muted text

Subtle text

Heading

Muted text

Subtle text

Heading

Muted text

Subtle text

Headings use your normal heading colour ($color-headings-preferred, see Variables) where it stays readable, switch to white on dark backgrounds, and only fall back to a plainer colour when neither works. A modifier built from a palette shade reuses colours the framework has already worked out for that shade, so it follows any later palette override; a one-off colour has its contrast fixed at build time.

Surfaces nest — each one re-establishes its own text, heading and muted / subtle tiers for everything inside it, however deep:

.bg-primary
.bg-base — resets back to the page canvas
.bg-secondary
.bg-soft

Links don't change with the surface

.bg-* deliberately leaves --jf-link-color alone — a link keeps whatever colour is already in scope (the :root role aliases, which flip with the theme) rather than something re-picked for the new background. On a colour surface where that default doesn't read well, override --jf-link-color yourself, scoped to the surface, and run it through the same contrast linter the framework itself uses so it's WCAG-checked at build time too:


      .bg-primary {
        --jf-link-color: #{get-color-raw(primary, 900)};
        @include contrast-lint(
          ("link on .bg-primary" get-color-raw(primary, 900) get-color-raw(primary, 500)),
          ".bg-primary link override"
        );
      }
    

Text modifiers

Where a .bg-* class themes a whole surface, these colour text alone. All are !important.

Text colour utility classes
ClassColourFlips in dark?
.text-{family}That family's 500 — one class per family in $colors.No — it's the numbered primitive, which is the same in both modes.
.text-muted--jf-color-text-muted — secondary content, captions.Yes — re-mixes against the current surface.
.text-subtle--jf-color-text-subtle — hints, placeholder-level text.Yes — as above, mixed further.
.text-soft--jf-color-text-soft — the text colour paired with .bg-soft. For text sitting on a soft panel you've painted yourself.Yes.

.text-primary · .text-secondary · .text-neutral · .text-success · .text-warning · .text-error · .text-muted · .text-subtle

Check the contrast yourself

.text-{family} sets a colour with no knowledge of what's behind it, so it's the one part of the colour system the build-time contrast linter can't score. A mid-scale 500 that reads fine on the page canvas may not clear 4.5:1 on a .bg-* section or once dark mode flips the surface underneath it. For text that has to track its background, reach for a role alias (get-color-role($family, strong)) or let the surface supply the colour.

Dark mode

Dark mode is a class on <html>, not light-dark(). .jf-dark or .jf-light forces a choice and wins over everything else; failing that, data-default-theme sets the site's default; failing that, the OS prefers-color-scheme decides.

Only the semantic tokens and the role aliases above are repointed in dark — the numbered primitives (--jf-color-primary-500 …) and their build-time --jf-color-on-* companions are untouched. So a .bg-primary section and a .button.error look the same in both modes, while surfaces, body text, borders and links flip. Which numbered shade each role points at once dark is set by $dark-color-role-shades (backgrounds move toward the dark end, accent text toward the light end).

Wiring it up

Add the colour-scheme hint and a pre-paint script to your <head> so a stored preference applies before the first paint (no flash):

    
      <meta name="color-scheme" content="light dark">
      <script>
        (function () {
          try {
            var t = localStorage.getItem("jf-theme");
            if (t === "dark" || t === "light")
              document.documentElement.classList.add("jf-" + t);
          } catch (e) {}
        })();
      </script>
    
  

Jellyfish's theme.js (bundled) keeps the class in sync after load, follows the OS while no explicit choice is stored, and exposes window.JellyfishTheme:

    
      JellyfishTheme.get();            // 'dark' | 'light' (resolved)
      JellyfishTheme.set('dark');      // force + persist
      JellyfishTheme.set('system');    // clear the stored choice, follow the OS
      JellyfishTheme.toggle();         // flip between dark and light
    
  

Any [data-theme-toggle] element toggles; [data-theme-set="dark|light|system"] sets a specific mode. A jfThemeChange event fires on <html> with detail.theme. Set $dark-mode-enabled: false to drop the dark block from the compiled CSS.

Non-token overrides

Reach for a semantic token first — that's what flips automatically. For the rest (an invert() filter on a logo, swapping a background image, anything a token can't express — useful once Jellyfish is compiled into a WordPress or Drupal theme alongside markup it doesn't own), nest @include in-dark-mode / @include in-light-mode inside the selector that needs it. Each resolves the same three-way precedence as the :root block, scoped to that selector:


    .wp-block-logo {
      @include in-dark-mode {
        filter: invert(1);
      }
    }
  

With $dark-mode-enabled: false, in-dark-mode compiles to nothing and in-light-mode's content applies unconditionally — light is the only mode.