10 Stylesheets
Tearnote edited this page 2026-09-07 09:28:16 +01:00

Playnote > Theming > Stylesheets

In Playnote theming, stylesheets are used to control the appearance and layout behavior of widgets. They can also contain reusable constants, such as sizes and colors, and definitions of shapes.

Stylesheets are written in PlaynoteCSS. While superficially resembling CSS, the language was designed from scratch for higher simplicity, modularity, and performance. A few parts may seem more verbose, but they are so to enable eager validation - all possible errors are reported while loading a theme, rather than when the relevant declaration is used. Likewise, non-critical errors such as unknown properties are reported as warnings rather than silently ignored.

A PlaynoteCSS file is a series of blocks. There are several block types, and some of them can be nested. A block has the following structure:

@type(target) name:trigger {
    /* declarations, or nested blocks */
}

It looks complicated, but the majority of these components are optional. In order:

  • @type: Can be one of @style, @deco, @const, @shape, or @prim. In many cases the correct type is implied, and it can be skipped.
  • (target): Currently valid only for @deco blocks; otherwise it is skipped.
  • name: The name of the thing being created or styled. This is the only mandatory component.
  • :trigger: A condition for the block to activate; it can be skipped for the default trigger.

Anything in between /* and */ is a comment. This is currently the only comment syntax. Comments cannot be nested.

Combined with inheritance, a complete stylesheet can be quite small. This example defines a reusable color, then applies it to every text widget:

@const palette {
    foreground: rgb(1 1 1);
}

text {
    size: 12;
    color: palette.foreground;
}

The @const block creates the palette.foreground value. The text block is a style whose declarations are applied when each text widget is created. The rest of this page explains the available block types, selectors, triggers, and property values in detail.

Let's begin with the simplest and most common block type.

@style blocks

A @style block applies a set of widget properties to a matching widget when its trigger is activated. The simplest example:

text {
    size: 12;
}

This block should be self-explanatory - all text widgets will have the size of 12 units. @style is such a common type of block that the @style component can be skipped to reduce visual noise. The block below has identical meaning:

@style text {
    size: 12;
}

Selectors

The text part of the block above is the simplest selector: it matches any widget of a specific type. Beyond that, it's possible to narrow down the match by including any number of class names:

text.song-title {};
text.stats.judgment-count {};

.song-title, .stats, and .judgment-count are classes; only widgets having all the classes in the selector will be matched by the block. Unlike in CSS, a widget's classes are immutable; they are assigned at creation time, and cannot change.

Even with classes present, the widget type is mandatory:

/* Will not load */
.song-title {}

A widget can be matched via its parent type:

/* "song-info" is a child of "text" */
text {} /* "song-info" is matched */

Important

Unlike in CSS, there is no support for hierarchical selectors, like "any x that is a child of y".

Specificity and cascade

If a widget is matched by multiple blocks and these blocks contain the same declaration, which declaration is applied depends on the blocks' precedence.

Primary criteria:

  1. If a widget type is matched exactly, the declaration from that block is used.
  2. Otherwise, if a widget is matched via a parent type, the declaration from that block is used.

Secondary criteria:

If multiple blocks match, the selectors with more unique classes have precedence.

Tertiary criteria:

If multiple blocks still match, the block declared later in the file has precedence.

As an example, these blocks all match, but with increasing priority:

/* "song-info" is a child of "text", and has classes ".one" and ".two" */
text {}
text.one {}
text.one.two {}
song-info {}
song-info.two {}
song-info.one.one.one {}
song-info.two.one {}
song-info.one.two {}

The cascade is resolved per-declaration:

text {
    color: rgb(1 1 1); /* white */
    size: 12;
}

text.alert {
    color: rgb(1 0 0); /* red */
}

/* A "text" widget with ".alert" class will be red, with size 12. */

Triggers

Every @style widget has a trigger. If not explicitly mentioned, the trigger defaults to :create, which fires before the widget appears on the screen. These two blocks are equivalent:

text {}
text:create {}

Besides :create, every widget has the :focus and :unfocus triggers. These are fired when the player gains and loses control over the widget, respectively:

knob {
    color: rgb(0.6 0.6 0.6); /* grey */
}

knob:focus {
    color: rgb(1 1 1); /* focused knob becomes white */
}

knob:unfocus {
    color: rgb(0.6 0.6 0.6); /* then it's back to grey */
}

Important

Unlike CSS, triggers are not states but events. Changes made by :focus are not automatically undone when focus is lost; that is the job of :unfocus.

To make the job of undoing changes less cumbersome, a value placeholder default can be used. The above example can be rewritten as:

knob {
    color: rgb(0.6 0.6 0.6);
}

knob:focus {
    color: rgb(1 1 1);
}

knob:unfocus {
    color: default;
}

When default is encountered, it is replaced by the first value found for the same property when walking up the cascade:

/* "stepped-knob" is a child of "knob" */

/* 4: Considered, and "color" found. "default" is replaced by its value. */
knob {
    color: rgb(0.6 0.6 0.6);
}

/* 3: Considered, but doesn't contain a "color". Keep going. */
stepped-knob {
    scale: 120%;
}

/* 2: A sibling is not considered. */
stepped-knob:focus {
    color: rgb(1 1 1);
}

stepped-knob:unfocus {
    color: default; /* 1: "default" encountered. Process starts here. */
}

Nesting

To save on typing and visually group related blocks together, @style blocks can be nested:

text {
    &:focus {}    /* text:focus */
    &.one {       /* text.one */
        &.two {}  /* text.one.two */
    }
}

All nested @style blocks must start with &, which is a placeholder replaced with the selector of the block's parent.

@const blocks

Often, the same color or measure is used in many places across a theme. To make it easy to keep your theme pleasing, consistent, and organized, a @const block can be used to define a reusable value. Scripts can retrieve constant values from the stylesheet as well. Such a constant value is referred to via namespace.name syntax, where namespace is the name of the @const block, and name is the property:

@const palette {
    white: rgb(1 1 1);
    black: rgb(0 0 0);
}

text.light-mode {
    color: palette.black;
}

text.dark-mode {
    color: palette.white;
}

Constants can reference other constants:

@const palette {
    red: rgb(1 0 0);
}

@const role {
    alert: palette.red;
}

text.alert {
    color: role.alert;
}

Order of declaration is insignificant:

@const foo {
    foo: bar.bar;
}

@const bar {
    bar: 42;
}

Be careful not to form a loop!

/* fails to load */
@const foo {
    foo: bar.bar;
}

@const bar {
    bar: foo.foo;
}

@deco blocks

Unlike in CSS, widgets don't have properties that add extra visual elements, like backgrounds, borders, or gradients. In Playnote, any such element is a decorator - a piece of visual flair that draws itself behind a widget, adapting to its size.

A @deco block attaches a decorator to the widget. It must be nested within a @style block, and the parent block (currently) must use the :create trigger:

text.black-on-white {
    color: rgb(0 0 0);
    @deco solid { /* a solid rectangle around the widget area */
        color: rgb(1 1 1);
    }
}

The name of the @deco block is the decorator to use. The solid decorator, among others, can be made available to use by inheriting the default theme. Custom decorators can be created via scripting. Each decorator has a default list of properties you can set on it, and can expand it by declaring its own.

A widget can have multiple decorators:

text {
    color: rgb(0 0 0);
    @deco solid { /* a background color */
        color: rgb(1 1 1);
    }
    @deco outline { /* an outline around the widget */
        color: rgb(0.2 0.2 0.2);
    }
}

Decorators are bound to attachment points. If not specified, it defaults to (self), which every widget has available - it's relative to the widget's area. Some widgets have extra attachment points, which decorate other spots within the widget; these are noted with the relevant widget schemas:

/*
"scrolling-list" is a widget that functions like the classic song wheel:
list contents move up and down, while the selection stays in place.
*/

scrolling-list {
    @deco(self) solid { /* same as "@deco solid" */
        color: rgb(0 0 0); 
    }
    @deco(selection) outline { /* the area of the "frame" that covers the selected list entry */
        color: rgb(1 1 1);
    }
}

@shape and @prim blocks

In Playnote, all graphics are made out of vector shapes. In theming parlance, a single elementary shape (such as a circle, rectangle, or line) is called a primitive, while a shape is a named collection of primitives. These shapes, once defined in the stylesheet, can be used from scripts or by the game itself. The available primitive kinds and their properties are outlined in the reference.

A @shape block names a shape, and contains @prim blocks which define the primitives that the shape is made out of. In certain cases, @prim blocks may be nested within other @prim blocks. While @prim is internally a distinct block type, the type is always optional, so you typically won't see it in any stylesheet.

The following shape forms a red "X" by crossing two lines:

@shape x-mark {
    line {
        color: palette.red;
        start: -18 -18;
        end: 18 18;
        width: 8;
    }
    line {
        color: palette.red;
        start: -18 18;
        end: 18 -18;
        width: 8;
    }
}

Groups

The group primitive combines its contents into one shape. This makes the contents behave as one silhouette for effects such as glow, outline, and transparency:

@shape glowing-pair {
    group {
        circle {
            position: -12 0;
            radius: 16;
            color: rgb(1 1 1);
            glow-width: 4;
            glow-color: rgb(1 1 1 / 0.5);
        }
        circle {
            position: 12 0;
            radius: 16;
            color: rgb(1 1 1);
            glow-width: 4;
            glow-color: rgb(1 1 1 / 0.5);
        }
    }
}

Nested groups are flattened.

Hooks

Instead of a value, any primitive property can be set to a hook. Starting with a $, a hooked property can be set by a script to any value at runtime:

@shape note {
    rect {
        size: 36 8;
        color: $color;
    }
}

As a result, the shape note now has a $color hook, which modifies the color of its rectangle.

Property values

Widget, decorator, primitive, and constant properties use the shared property types documented in the reference. This page focuses on how those values behave in stylesheet declarations: animation, unit-aware arithmetic, and reserved values.

Animation

Every type of property except for Integer, Bool, Duration and Enum can be animated. An animation is a series of keyframes connected by easing functions. The complete syntax looks like this:

size:
    from 16,
    to 24 / 500ms linear,
    to 16 / 500ms linear,
    loop;

This animation will pulse the size of the widget up and down, forever. The components of the animation are split with commas (,). Let's break them down.

from 16 here is the initial value. As soon as the animation starts, the value is set to this number. from is optional; if missing, the animation will start from whatever is the value right now. This is useful to avoid jerky movement when one animation is interrupting another.

The to components are keyframes. The value after to will be smoothly reached in the amount of time after the / slash. The duration (which is in fact a Duration) can be followed by the easing function; if missing, the default is linear. See https://easings.net/ for the list of available functions.

The last component is the repeat type. loop will repeat forever; it can also be set to repeat n (where n is an Integer number of repeats), or be skipped entirely. If not repeating, or the last repeat has ended, the value will rest at the final to.

Caution

A repeat snaps from the final to value to the initial value immediately. Make them the same if you want to avoid a sudden jerk.

Because every component is optional, most animations look quite compact. It's typical to skip the initial value to ensure a smooth experience:

text {
    size: 16;
    &:focus {
        size: to 32 / 200ms ease-out-quad;
    }
    &:unfocus {
        size: to default / 400ms ease-out-quad;
    }
}

In fact, scalar values that we've been using this entire time, like 0.5, are in fact syntax sugar for from 0.5 (with no to keyframes).

Animations are compatible with multi-dimensional types, such as sizes and colors:

position: from 0, to 200 100 / 1s; /* note the shorthand use */
color: from rgb(1 0 0),
    to rgb(0 1 0) / 200ms,
    to rgb(0 0 1) / 200ms,
    to rgb(1 0 0) / 200ms,
    repeat 5;

Animations can reference constants:

@const anim {
    quick: 150ms;
    big: 120%;
}

widget.item:pulse {
    scale: from anim.big, to default / anim.quick ease-out-quart;
}

In fact, a constant can itself be an animation:

@const fades {
    out: to 0% / 2s;
}

text:poof {
    opacity: fades.out;
}

calc()

The calc() function allows for basic unit-aware arithmetic. While it is less useful than in CSS due to the lack of relative (percentage) values, it can nevertheless help with the reuse of constants across the theme.

Available operands are: +, -, *, /, and % (modulo). Order of operations is standard, with parentheses () available to enforce.

size: calc(8 * 3.0);
size: calc(sizes.normal / 2);

Inside of calc(), a simplified type system is in effect, in which values with the same suffixes are equivalent. It cannot operate on multi-dimensional values (aside from colors), or produce animations, but it can provide the values for their components:

position: 0 calc(std.offset + 4);
position: calc(std.offset * 2 - 0.1); /* shorthand */
size: to 12 / calc(std.duration * 1.2);

calc() has unit awareness, and only allows for operations that result in units that exist in the type system:

calc(2s + 3s)      /* valid, 5s */
calc(2s + 3)       /* error, unit mismatch */
calc(2s * 3s)      /* error, no "seconds squared" (acceleration) unit */
calc(2s * 1.5)     /* valid, 3s */
calc(90deg + 1rad) /* valid, same underlying type */

Similarly, the returned value needs to be able to be coerced to the destination type:

size: calc(2s * 2);         /* error, "size" is not a duration */
scale: calc(360deg / 1rad); /* valid, angle units cancel out in the division, and "scale" receives a ratio */

Colors can be multiplied/divided by a scalar, or added/subtracted together. These operations work componentwise on the internal linear-RGB channels, not on the sRGB values used when writing rgb(). They affect the RGB components only. In color-color operations, the result has the alpha value of the left operand.

color: calc(rgb(1 0 0) / 2);
color: calc(palette.red * 1.2);
color: calc(palette.main + palette.highlight);
color: rgb(palette.background / 10%);

While it may seem arcane, you should find that all arithmetic that makes sense will be accepted and produce the expected result.

Reserved values

  • default: Replaced at load time with the nearest value of the same property when walking up the cascade. See above.
  • true, false: Bool values.
  • inf: Replaced by a very large number, guaranteed to reach well off-screen for any screen-unit type.