# Theme guide for agents

Synced from [STYLING-SKILL.md](https://github.com/better-lyrics/better-lyrics/blob/master/STYLING-SKILL.md) in the Better Lyrics repository when this site is built.

Essential reference for creating custom themes. For deep dives, see [STYLING.md](https://github.com/better-lyrics/better-lyrics/blob/master/STYLING.md).

## Quick Reference: CSS Variables

### Colors

```css
:root {
  --blyrics-text-color: color(display-p3 1 1 1 / 1);
  --blyrics-highlight-color: color(display-p3 1 1 1 / 0.5);
  --blyrics-active-opacity: 1;
  --blyrics-inactive-opacity: 0.3;
  --blyrics-translated-opacity: 0.6;
}
```

### Typography

Leave font-family overrides unset for language-aware CJK defaults. If adding a font, declare `--blyrics-font-family: "My Font", var(--noto-sans-universal), sans-serif` on `.blyrics--line, .blyrics--translated, .blyrics--romanized` so the fallback resolves in each element’s language.

```css
:root {
  --blyrics-font-size: 3rem;
  --blyrics-font-weight: 700;
  --blyrics-line-height: 1.333;
  --blyrics-translated-font-size: 2rem;
  --blyrics-translated-font-weight: 600;
  --blyrics-translated-font-family: var(--blyrics-font-family);
}
```

### Animation

```css
:root {
  --blyrics-animate-line-scale: 1;
  --blyrics-animate-word-wobble: 1;
  --blyrics-animate-highlight-swipe: 1;
  --blyrics-animate-highlight-glow: 1;
  --blyrics-animate-highlight-fade: 1;
  --blyrics-animate-scroll: 1;
  --blyrics-animate-instrumental: 1;
  --blyrics-loader-transition-duration: 0.6s;
  --blyrics-loader-transition-easing: cubic-bezier(0.22, 1, 0.36, 1);
  --blyrics-scale-transition-duration: 0.166s;
  --blyrics-line-enter-transform-from: scale(var(--blyrics-scale));
  --blyrics-line-enter-transform-to: scale(var(--blyrics-active-scale));
  --blyrics-line-exit-transform-from: scale(var(--blyrics-active-scale));
  --blyrics-line-exit-transform-to: scale(var(--blyrics-scale));
  --blyrics-line-enter-easing: ease;
  --blyrics-line-exit-easing: ease;
  --blyrics-lyric-highlight-fade-in-duration: 0.33s;
  --blyrics-lyric-highlight-fade-out-duration: 0.5s;
  --blyrics-lyric-highlight-fade-in-easing: ease;
  --blyrics-lyric-highlight-fade-out-easing: ease;
  --blyrics-highlight-swipe-easing: linear;
  --blyrics-highlight-swipe-start-from: -0.2;
  --blyrics-highlight-swipe-end-from: -0.1;
  --blyrics-highlight-swipe-start-to: 1.4;
  --blyrics-highlight-swipe-end-to: 1.5;
  --blyrics-highlight-glow-radius-from: 0.8rem;
  --blyrics-highlight-glow-radius-to: 0;
  --blyrics-highlight-glow-duration-ratio: 1.2;
  --blyrics-highlight-glow-min-duration: 1.2s;
  --blyrics-highlight-glow-easing: ease;
  --blyrics-wobble-duration: 1s;
  --blyrics-word-wobble-transform-from: scaleX(1);
  --blyrics-word-wobble-transform-peak: translateX(0.05em) scaleX(1.025);
  --blyrics-word-wobble-transform-settle: translateX(0) scaleX(1);
  --blyrics-word-wobble-transform-to: scaleX(1);
  --blyrics-word-wobble-peak-offset: 0.125;
  --blyrics-word-wobble-settle-offset: 0.75;
  --blyrics-word-wobble-easing: ease;
  --blyrics-word-wobble-peak-easing: ease-in-out;
  --blyrics-word-wobble-end-easing: ease-out;
  /* Letter wave (experimental, on by default; disable with blyrics-letter-wave = false) */
  --blyrics-letter-wave-transform: translateY(-0.05em);
  --blyrics-letter-wave-settle: translateY(-0.02em);
  --blyrics-letter-wave-emphasis-scale: 1.11;
  --blyrics-letter-wave-duration: 0.9s;
  --blyrics-letter-wave-rise-easing: ease-in-out;
  --blyrics-letter-wave-fall-easing: ease-out;
  --blyrics-instrumental-fill-fade-duration: 150ms;
  --blyrics-instrumental-fill-fade-easing: ease;
  --blyrics-instrumental-fill-transform-from: translateY(78%);
  --blyrics-instrumental-fill-transform-to: translateY(-4%);
  --blyrics-instrumental-fill-easing: linear;
  --blyrics-instrumental-wave-transform-from: scaleY(1.2);
  --blyrics-instrumental-wave-transform-to: scaleY(0.0001);
  --blyrics-instrumental-wave-easing: ease-in;
  --blyrics-instrumental-wave-path-high: path("M -4 3 Q 1 2 5 3 Q 10 4 14 3 Q 18 2 22 3 Q 26 4 30 3 L 30 4 L -4 4 Z");
  --blyrics-instrumental-wave-path-low: path("M -4 3 Q 1 4 5 3 Q 10 2 14 3 Q 18 4 22 3 Q 26 2 30 3 L 30 4 L -4 4 Z");
  --blyrics-instrumental-wave-oscillation-duration: 1.25s;
  --blyrics-instrumental-wave-oscillation-easing: ease-in-out;
  --blyrics-timing-offset: 0.115s;
  --blyrics-richsync-timing-offset: 0.150s;
  --blyrics-scroll-timing-offset: 0.5s;
  --blyrics-lyric-scroll-timing-function: cubic-bezier(0.86, 0, 0.2, 1);
}
```

### Layout

```css
:root {
  --blyrics-padding: 2rem;
  --blyrics-margin: 2rem;
  --blyrics-border-radius: 1000rem;
  --blyrics-padding-top: 0px;                    /* calculated automatically */
  --blyrics-padding-bottom: 0px;                 /* calculated automatically */
  --blyrics-panel-size: 50%;                     /* lyrics container width (audio mode) */
  --blyrics-video-panel-size: 30%;               /* lyrics container width (video mode) */
  --blyrics-fullscreen-panel-size: 66%;          /* lyrics container width (fullscreen audio) */
  --blyrics-fullscreen-video-panel-size: 25%;    /* lyrics container width (fullscreen video) */
}
```

### Effects

```css
:root {
  --blyrics-scale: 0.95;
  --blyrics-active-scale: 1;
  --blyrics-blur-amount: 30px;
  --blyrics-background-blur: 100px;
  --blyrics-background-saturate: 2;
}
```

`--blyrics-blur-amount` is the player bar’s frost. On the player page it is skipped while `--blyrics-background-blur` is exactly its 100px default (a style container query in `general.css`): over that background the frost changes no pixel, and Chromium would otherwise re-blur the whole bar in every frame the lyrics scroll near it. A theme that sharpens the background gets the frost back automatically.

### Footer

```css
:root {
  --blyrics-footer-bg-color: hsla(0, 0%, 100%, 0.1);
  --blyrics-footer-border-color: hsla(0, 0%, 100%, 0.1);
  --blyrics-footer-text-color: #aaa;
  --blyrics-footer-link-color: #fff;
  --blyrics-footer-font-family: Roboto, Arial, sans-serif;
  --blyrics-footer-font-size: 14px;
}
```

### Songwriter Credits

```css
:root {
  --blyrics-credits-label: "Written by"; /* localized by the extension */
  --blyrics-credits-font-size: max(0.4em, 12px);
  --blyrics-credits-opacity: 0.2; /* during the song */
  --blyrics-credits-focused-opacity: 0.85; /* after the last line ends */
}
```

`p.blyrics-credits` follows the last line. It is not a `div`, so `.blyrics-container > div` line rules skip it; style it through `.blyrics-credits`. Hide with `display: none` or the `blyrics-hide-credits` knob.

## Configuration Knobs

Comment-based parameters that control JS behavior. Place anywhere in your theme:

```css
/*
blyrics-disable-richsync = true;
blyrics-line-synced-animation-delay = 50;
blyrics-target-scroll-pos-ratio = 0.37;
blyrics-swipe-lead-ratio = 0.1;
blyrics-swipe-duration-ratio = 1.6;
blyrics-line-scroll-duration = 750ms;
blyrics-line-scroll-below-duration = calc(750ms + log(var(--blyrics-line-scroll-abs-relative-index) + 1, 2.71828) * 80ms + var(--blyrics-line-scroll-abs-relative-index) * 20ms);
blyrics-line-scroll-above-duration = calc(750ms + log(var(--blyrics-line-scroll-abs-relative-index) + 1) * 10ms);
*/
```

| Knob | Default | Description |
| --- | --- | --- |
| `blyrics-disable-richsync` | `false` | Render richsynced lyrics through the line-synced path instead, including line-synced fade-in |
| `blyrics-line-synced-animation-delay` | `50` | Per-word delay for synced lyrics (ms) |
| `blyrics-lyric-ending-threshold-s` | `0.5` | Seconds before line ends to consider it complete |
| `blyrics-early-scroll-consider-s` | `0.54` | Independent future lookahead for scroll grouping (s) |
| `blyrics-debug-renderer` | `false` | Enable debug overlay |
| `blyrics-debug-animation-timing` | `false` | Log WAAPI lyric animation timing samples, learned offsets, and timing cleanup events |
| `blyrics-target-scroll-pos-ratio` | `0.37` | Lyric position (0=top, 0.5=center, 1=bottom) |
| `blyrics-swipe-lead-ratio` | `0.1` | Rich-sync swipe lead as a fraction of word duration |
| `blyrics-swipe-duration-ratio` | `1.6` | Rich-sync swipe duration as a multiple of word duration |
| `blyrics-long-word-threshold` | `1500` | Duration (ms) above which `data-long-word` is set |
| `blyrics-letter-wave` | `true` | Experimental, on by default. Split words into per-letter spans and float each letter as it is sung, over the word wobble. Tune with `--blyrics-letter-wave-*`; long words also get a per-letter scale swell. Disable with `blyrics-letter-wave = false` |
| `blyrics-hide-instrumental-only` | `false` | Treat “[Instrumental Only]” as no lyrics (enables fullscreen effect) |
| `blyrics-hide-credits` | `false` | Never build the songwriter credits line |
| `blyrics-passive-scroll-enabled` | `true` | Unsynced auto-scroll: enable/disable entirely (overrides user setting) |
| `blyrics-passive-scroll-seconds-per-line` | `3.5` | Unsynced auto-scroll: seconds per line (scroll speed) |
| `blyrics-passive-scroll-bottom-pause-s` | `1.5` | Unsynced auto-scroll: pause at bottom (s) |
| `blyrics-passive-scroll-reset-duration-s` | `0.6` | Unsynced auto-scroll: scroll-back-to-top duration (s) |
| `blyrics-passive-scroll-top-pause-s` | `0.8` | Unsynced auto-scroll: pause at top (s) |
| `blyrics-line-scroll-duration` | `750ms` | Per-line scroll animation duration |
| `blyrics-line-scroll-{above,active,below}-duration` | side-specific tail defaults | Side-specific scroll duration; `above`/`below` swap on upward scrolls. Defaults use uncapped relative-index formulas so farther trailing lines settle progressively later. |
| `blyrics-line-scroll-timing-function` | `var(--blyrics-lyric-scroll-timing-function)` | Shared line-scroll easing |
| `blyrics-line-scroll-{start,end}-easing` | timing / `linear` | Keyframe easing |
| `blyrics-line-scroll-{above,active,below}-{start,end}-easing` | shared keyframe easing | Side-specific keyframe easing; `above`/`below` swap on upward scrolls |
| `blyrics-line-scroll-translate-y-{start,end}` | delta / `0px` | Shared Y offsets |
| `blyrics-line-scroll-{above,active,below}-translate-y-{start,end}` | shared offset | Side-specific Y offsets; `above`/`below` swap on upward scrolls |

**Scroll timing**: `blyrics-early-scroll-consider-s` defaults to `0.54` seconds independently of animation duration. Lookahead only affects the target when another lyric triggers a scroll; entering the window alone does not scroll. Lines included in a committed scroll cannot trigger again at their own start. Seeking, resuming autoscroll and relayout may still reposition the view. There is no scroll gate or queue; `blyrics-queue-scroll-ms` is ignored and no timing equation needs balancing. `--blyrics-lyric-scroll-duration`, its `--blyrics-lyric-transition-duration` alias and the container transform transition have been removed. Use the line-scroll duration knobs instead; replace explicit references to the removed variables with a duration or your own custom property. Missing, invalid or nonpositive line durations fall back to an internal `750ms`. Themes that relied on automatically derived lookahead should set it explicitly.

**PiP scroll position**: knobs are shared by the panel, fullscreen and PiP. To give PiP its own position, set `--blyrics-target-scroll-pos-ratio` under `.blyrics-pip-shell`: `.blyrics-pip-shell .blyrics-container { --blyrics-target-scroll-pos-ratio: 0.37; }`. It overrides `blyrics-target-scroll-pos-ratio` wherever it resolves. Plain number, clamped to `0` to `1`, `%` not converted, non-numbers fall back to the knob. Read once per theme apply, and panel and fullscreen share one view, so a fullscreen-only selector won’t switch on toggle.

**PiP scroll timing**: PiP defaults `--blyrics-scroll-timing-offset` to `0s` (at zero specificity), so a line moves only once the next one starts. A `:root` value doesn’t reach it; override under `.blyrics-pip-shell .blyrics-container`.

**PiP lyrics swap**: a new set of lyrics (new song, or another source from the dock) fades the old lines out and rises the on-screen lines in one after another. A source switch in the same song holds the old lyrics, dimmed, with `.blyrics-pip-scroller[data-held]` (default `opacity: 0.35`) until the next ones land; the loader shows only after 250ms. The fading copy is `.blyrics-pip-lyrics__exit`. The rise ends at the theme’s own line opacity and adds to its `filter`, so dimmed or blurred lines don’t snap. Reduced motion turns both into a short fade. PiP only. `.blyrics-pip-lyrics[data-revealing]` while lines rise. Tunable on `.blyrics-pip-lyrics`, read once per swap: `--blyrics-pip-lyrics-hold-delay` (250ms), `-exit-duration` (200ms), `-reveal-duration` (500ms), `-reveal-delay` (120ms), `-reveal-stagger` (40ms), `-reveal-lines` (all on screen, `0` = no rise), `-reveal-distance` (12px), `-reveal-blur` (3px), `-reveal-easing` (`cubic-bezier(0.22, 1, 0.36, 1)`); all prefixed `--blyrics-pip-lyrics`.

## Dynamic Properties

Properties set by JS at runtime on individual elements:

| Property | Set On | Description |
| --- | --- | --- |
| `--blyrics-duration` | `.blyrics--word`, `.blyrics--instrumental` | Duration of current element (ms) |
| `--blyrics-line-scroll-relative-index` | visible animated lyric lines | Active line is `0`, below lines are positive, above lines are negative |
| `--blyrics-line-scroll-abs-relative-index` | visible animated lyric lines | Absolute relative index |
| `--blyrics-line-scroll-side` | visible animated lyric lines | Direction-aware side: `above`, `active`, or `below`; `above`/`below` swap on upward scrolls |
| `--blyrics-line-scroll-delta-px` | visible animated lyric lines | Signed scroll delta |
| `--blyrics-line-scroll-distance-px` | visible animated lyric lines | Absolute scroll distance |
| `--blyrics-padding-top` | `:root` | Calculated top spacer for scroll positioning |
| `--blyrics-padding-bottom` | `:root` | Calculated bottom spacer for scroll positioning |

Lyric timing is driven by `element.animate()`.

## DOM Structure

```
.blyrics-container [data-sync] [data-loader-visible] [data-no-lyrics]
├── .blyrics--line (div) [data-agent] [data-time] [data-duration] [data-line-number]
│   ├── .blyrics-line-main (div)
│   │   ├── .blyrics-bidi-run.blyrics-highlight-run (span, aria-hidden overlay)
│   │   │   └── .blyrics-word-group (span)
│   │   │       └── .blyrics--word.blyrics-word-highlight (span) [data-content] [data-time] [data-duration] [data-long-word] [data-word-state]
│   │   └── .blyrics-bidi-run (span)
│   │       └── .blyrics-word-group (span)
│   │           └── .blyrics--word (span) [data-content] [data-time] [data-duration] [data-long-word] [data-word-state]
│   ├── .blyrics-background-line (div, only when primary background vocals are present)
│   │   ├── .blyrics-bidi-run.blyrics-highlight-run (span, aria-hidden overlay)
│   │   │   └── .blyrics-word-group.blyrics-background-lyric
│   │   └── .blyrics-bidi-run (span)
│   │       └── .blyrics-word-group.blyrics-background-lyric
│   ├── .blyrics--romanized.blyrics-content-line
│   └── .blyrics--translated.blyrics-content-line
├── .blyrics--instrumental.blyrics--line [data-instrumental="true"]
│   └── .blyrics--instrumental-icon (svg)
│       ├── .blyrics--instrumental-bg (path)
│       ├── .blyrics--instrumental-fill (path)
│       └── .blyrics--wave-clip/.blyrics--wave-rect/.blyrics--wave-path
└── .blyrics-footer
```

### Container Data Attributes

| Attribute | Values | Description |
| --- | --- | --- |
| `data-sync` | `"richsync"`, `"synced"`, `"none"` | Sync type |
| `data-loader-visible` | `"true"`, `"false"`, or absent | Loader visibility |
| `data-no-lyrics` | `"true"` or absent | No lyrics available |
| `data-credits-focused` | present or absent | Songwriter credits hold the scroll focus |

### Word Data Attributes

| Attribute | Description |
| --- | --- |
| `data-content` | Word text |
| `data-time` | Start time in seconds |
| `data-duration` | Duration in seconds |
| `data-long-word` | `"true"` or absent - present when duration exceeds threshold |
| `data-word-state` | `"upcoming"`, `"active"`, or `"past"` - whether the word is not yet reached, being sung, or already sung. Written on both layers, only on change |

### Loader Attributes

| Attribute | Description |
| --- | --- |
| `[active]` | Loader is visible |
| `[small-loader]` | Compact loader (still searching) |
| `[no-sync-available]` | Synced lyrics not found |

## Selectors Reference

| Selector | Purpose |
| --- | --- |
| `.blyrics-container` | Main lyrics wrapper |
| `.blyrics--line` | Lyric line (div) |
| `.blyrics-line-main` | Main lyric text row |
| `.blyrics-background-line` | Primary background vocal row |
| `.blyrics-bidi-run` | Inline text-flow wrapper for native browser bidi ordering |
| `.blyrics-highlight-run` | `aria-hidden` layer over a text row that holds the word overlays |
| `.blyrics-bidi-sensitive` | Applied to rows containing RTL script; makes word wrappers inline-flow for correct bidi wrapping |
| `.blyrics-word-group` | Word/syllable group; `inline-block` for LTR-only rows and `display: contents` inside `.blyrics-bidi-sensitive` |
| `.blyrics--word` | Word span |
| `.blyrics-word-highlight` | Real active-color overlay; every timed word has one |
| `.blyrics-line-synced-word` | Zero-duration line-synced word; fades in without rich-sync swipe |
| `.blyrics--active` | Line is selected for scrolling. Runs on the scroll clock, which leads audio by `--blyrics-scroll-timing-offset` (0.5s default) |
| `.blyrics--animating` | Line’s animations are live. Runs on the audio clock, so it survives until the line’s last word actually finishes |
| `.blyrics--paused` | Playback is paused; on the line and on each word. Use it to freeze theme-authored CSS animations |
| `.blyrics-user-scrolling` | User is scrolling manually |
| `.blyrics-rtl` | RTL language support |
| `.blyrics--translated` | Translation text |
| `.blyrics--romanized` | Romanization text |
| `.blyrics--error` | Error message |
| `.blyrics--instrumental` | Instrumental break |
| `[data-agent="v1"]` | Primary voice (left) |
| `[data-agent="v2"]`, `[data-agent="v3"]` | Secondary/tertiary voice (right) |
| `[data-agent="v1000"]` | Duet/chorus (centered) |
| `[data-long-word]` | Long sustained word |
| `[data-word-state="upcoming"]` | Word not yet reached |
| `[data-word-state="active"]` | Word being sung |
| `[data-word-state="past"]` | Word already sung |

## Animation System

Every timed word has a real `.blyrics-word-highlight` overlay with `background-clip: text`. A row’s overlays sit in `.blyrics-highlight-run`, an `aria-hidden` copy laid over the visible text so both share one layout and wrap the same way:

```css
.blyrics--word.blyrics-word-highlight {
  color: transparent;
  background-image: linear-gradient(90deg, var(--blyrics-lyric-active-color) ..., transparent ...);
  background-clip: text;
}
```

Timing uses the Web Animations API:

-   Rich-sync swipe starts at `wordStart - blyrics-swipe-lead-ratio * wordDuration`
-   Rich-sync swipe duration is `blyrics-swipe-duration-ratio * wordDuration`
-   Rich-sync word-timed highlights become visible instantly at `wordStart`; they do not use the highlight fade-in duration
-   Line-synced `.blyrics-line-synced-word` skips swipe and fades in at word time
-   `--blyrics-animate-highlight-swipe: 0` keeps rich-sync timing but makes each word fully highlighted instantly instead of fading like line-synced lyrics
-   Glow lasts `max(wordDuration * --blyrics-highlight-glow-duration-ratio, --blyrics-highlight-glow-min-duration)`
-   Lyric line scale and scroll smoothing also use `element.animate()`
-   Scroll smoothing uses per-line `translate`, not container `transform`; JS automatically animates lines visible in the previous or current viewport and provides relative index, absolute relative index, signed scroll delta, and absolute scroll distance as CSS variables
-   Visible lyric lines receive inline `will-change: transform, translate` before scroll animations start
-   Per-line scroll effects can overlap with additive Web Animations (`composite: "add"`); new lyric groups scroll immediately while earlier animations continue; line durations control visual motion independently of lookahead
-   The instrumental wave morphs between `--blyrics-instrumental-wave-path-high` and `--blyrics-instrumental-wave-path-low`; both must use the same path commands in the same order with the same argument counts, or the ripple snaps at the halfway point instead of morphing
-   Visual keyframe values, effect enable flags, durations, and easing are CSS variables; JS still owns scheduling, pause/resume, seeking, and cancellation
-   `prefers-reduced-motion: reduce` keeps smooth scroll enabled but disables side-specific line-scroll differential effects

### Keyframes

| Animation | Description |
| --- | --- |
| `blyrics-spin` | Loader rotation |
| `blyrics-shimmer` | Loading text shimmer |

## Unison Submitter Card and Floating Dock

Injected on the YouTube Music page when the Unison provider is active. Themeable through your custom CSS like the rest of this surface.

### New Variables

```css
:root {
  --blyrics-vote-hover-color: hsla(0, 0%, 100%, 0.2);
  --blyrics-small-border-radius: 1rem;
  --blyrics-fullscreen-bottom-dock-shift: -24px; /* Y-shift for bottom dock in fullscreen */
}
```

### Footer Card DOM

```
.blyrics-footer__unison
└── .blyrics-footer__container.blyrics-footer__unison-card
    ├── .blyrics-footer__unison-author (only when there is a submitter)
    │   ├── .blyrics-footer__unison-author-row
    │   │   ├── strong.blyrics-footer__author-name
    │   │   └── span.blyrics-footer__trust-tier [data-tier]
    │   └── .blyrics-footer__unison-author-label
    ├── .blyrics-footer__unison-divider (only when submitter is present)
    └── .blyrics-footer__unison-actions-block
        ├── .blyrics-footer__unison-actions
        │   ├── button.blyrics-footer__vote (upvote)
        │   ├── button.blyrics-footer__vote (downvote)
        │   └── button.blyrics-footer__vote (report)
        └── .blyrics-footer__unison-score-line
```

| Selector | Purpose |
| --- | --- |
| `.blyrics-footer__unison` | Outer wrapper, full-width inside footer |
| `.blyrics-footer__unison-card` | Translucent card, hover lighten, opens unison page on click |
| `.blyrics-footer__unison-divider` | 1px vertical separator between submitter and actions |
| `.blyrics-footer__unison-author` | Submitter column |
| `.blyrics-footer__unison-author-row` | Author name + tier pill row |
| `.blyrics-footer__author-name` | Submitter handle (deterministic pet name from public key) |
| `.blyrics-footer__unison-author-label` | ”submitted this” subtext |
| `.blyrics-footer__unison-actions-block` | Right column with buttons + score line |
| `.blyrics-footer__unison-actions` | Row of three vote/report buttons |
| `.blyrics-footer__unison-score-line` | ”+12 score · 12 votes” line |
| `.blyrics-footer__vote` | Base button (30px square in card, 32px in dock) |
| `.blyrics-footer__vote--active` | Active vote (SVG `fill-opacity` becomes 1) |
| `.blyrics-footer__trust-tier` | Tier pill, color via `[data-tier]` |

### Trust Tier Colors

| Selector | Color family |
| --- | --- |
| `.blyrics-footer__trust-tier[data-tier="new"]` | Blue |
| `.blyrics-footer__trust-tier[data-tier="trusted"]` | Green |
| `.blyrics-footer__trust-tier[data-tier="veteran"]` | Purple |
| `.blyrics-footer__trust-tier[data-tier="expert"]` | Gold |

### Floating Dock

```
.blyrics-unison-dock [data-position]
└── .blyrics-unison-dock__inner
    └── .blyrics-footer__vote (×3)
```

Mounted inside `#side-panel`. `pointer-events: none` on the wrapper, `auto` on `__inner`.

| Position value | Anchor |
| --- | --- |
| `top-left` | `top: 64px; left: 0` |
| `top-center` | `top: 64px; left: 50%` (translate -50%) |
| `top-right` | `top: 64px; left: 100%` (translate -100%) |
| `bottom-left` | `top: calc(100% - 64px); left: 0` |
| `bottom-center` | `top: calc(100% - 64px); left: 50%` |
| `bottom-right` | `top: calc(100% - 64px); left: 100%` |

| Modifier | When applied |
| --- | --- |
| `.blyrics-unison-dock--hidden` | Footer card is in viewport (avoids duplicate controls) |
| `.blyrics-unison-dock--idle-hidden` | Player is idle in fullscreen |

Animates `transform`, `opacity`, `filter: blur(8px)` over 320ms.

### Layout Adjustments

```css
/* dock shifts down 72px when autoscroll resume button is visible */
#side-panel:has(.autoscroll-resume-button:not([autoscroll-hidden="true"])) .blyrics-unison-dock[data-position="top-center"] {
  --dock-y-shift: 72px;
}

/* hide dock when side panel shows non-lyrics content */
#side-panel:has(#tab-renderer:not([page-type="MUSIC_PAGE_TYPE_TRACK_LYRICS"])) .blyrics-unison-dock { ... }

/* in fullscreen: top-anchored dock hidden, bottom-anchored pinned above player bar */
#layout[player-fullscreened]:not([blyrics-dfs]) .blyrics-unison-dock[data-position^="top-"] { ... }
#layout[player-fullscreened]:not([blyrics-dfs]) .blyrics-unison-dock[data-position^="bottom-"] {
  --dock-y-shift: var(--blyrics-fullscreen-bottom-dock-shift, -24px);
}
```

Override `--blyrics-fullscreen-bottom-dock-shift` to tune the lift.

## Fullscreen Player Controls

Injected under the album art in fullscreen (audio) mode: song info, a seekable progress bar, transport, like/dislike, and a quick-actions trigger. The progress bar and the press animations are shared with the floating window. Hidden outside fullscreen, during ads, and while the fullscreen-controls setting is off.

### DOM Structure

```
#blyrics-fs-column                          (fullscreen only, width tracks the player element)
├── #blyrics-fs-info-row .blyrics-fs-info-row
│   ├── #blyrics-song-info
│   │   ├── p#blyrics-title
│   │   └── p#blyrics-artist
│   │       ├── a.blyrics-song-link          (one per artist, links to its channel)
│   │       └── span#blyrics-album
│   │           └── a.blyrics-song-link      (album, links to the album page)
│   └── .blyrics-fs-info-actions
│       ├── .blyrics-volume                          (volume pill)
│       └── button.blyrics-fs-disc.blyrics-fs-more   (quick-actions trigger)
└── #blyrics-fs-controls
    ├── .blyrics-progress                    (see Progress Bar)
    └── .blyrics-fs-transport-slot
        ├── button.blyrics-fs-disc.blyrics-fs-like
        ├── .blyrics-fs-transport-mid
        │   ├── button.blyrics-fs-disc       (previous)
        │   ├── button.blyrics-fs-disc.blyrics-fs-play
        │   └── button.blyrics-fs-disc       (next)
        └── button.blyrics-fs-disc.blyrics-fs-dislike
```

### Selectors

| Selector | Purpose |
| --- | --- |
| `#blyrics-fs-column` | Column under the album art holding the info row and the controls strip |
| `#blyrics-fs-info-row` / `.blyrics-fs-info-row` | Song info plus the quick-actions trigger |
| `#blyrics-song-info` | Title/artist/album block, also present outside fullscreen |
| `.blyrics-song-link` | Artist and album links inside the info block; `color: inherit`, underline on hover |
| `.blyrics-fs-info-actions` | Wrapper for the volume pill and the quick-actions trigger |
| `#blyrics-fs-controls` | Controls strip: progress bar above the transport row |
| `.blyrics-fs-disc` | Round control button base, shared by transport, rating, and more |
| `.blyrics-fs-more` | Quick-actions trigger |
| `.blyrics-fs-play` | Play/pause button, sized up from the rest |
| `.blyrics-fs-like` / `.blyrics-fs-dislike` | Rating buttons |
| `.blyrics-fs-transport-slot` / `.blyrics-fs-transport-mid` | Transport row layout |
| `.blyrics-fs-rate-icon` | Holds the two rating icon layers |
| `.blyrics-fs-rate-layer--outline` / `--fill` | Outline shows by default, fill shows when the rating is active |

### State Attributes

| Attribute | When applied |
| --- | --- |
| `.blyrics-fs-disc[data-active]` | Like or dislike is the current rating; the fill layer shows |
| `.blyrics-fs-disc[data-menu-open]` | The quick-actions menu is open; keeps the trigger’s hover background |
| `#blyrics-fs-controls[data-ad]` | An ad is playing; the controls strip is hidden |
| `html[blyrics-no-fs-controls]` | Fullscreen-controls setting is off; hides `#blyrics-fs-controls` and `.blyrics-fs-info-actions` |
| `html[blyrics-fs-controls-pinned]` | Fullscreen-controls setting is On; the controls stay visible while the player bar shows |

### Progress Bar

Shared with the floating window, which scopes it under `.blyrics-pip-progress`.

| Selector | Purpose |
| --- | --- |
| `.blyrics-progress` | Wrapper; owns `--blyrics-progress-dot-shadow` |
| `.blyrics-progress__bar` | Seek track; carries `.dragging` while scrubbing |
| `.blyrics-progress__fill-clip` / `.blyrics-progress__fill` | Elapsed fill, scaled through `transform` |
| `.blyrics-progress__knob` / `.blyrics-progress__knob-dot` | Draggable handle |
| `.blyrics-progress__times` | Row holding the elapsed and end times |
| `.blyrics-progress__elapsed` | Elapsed time |
| `.blyrics-progress__end[data-mode]` | End time; `total` shows the duration, `remaining` shows the time left, click toggles |
| `.blyrics-volume[data-open][data-muted][data-level]` | Volume pill; `data-level` is `high`, `low` or `muted` |
| `.blyrics-volume__button` / `.blyrics-volume__icon` | Speaker button and its icon; parts `[data-part]`: `wave-low`, `wave-high`, `mute` |
| `.blyrics-volume__bar` / `__fill` / `__knob` | Volume slider |

Variables on `.blyrics-volume`: `--blyrics-volume-width`, `--blyrics-volume-rest-opacity`, `--blyrics-volume-knob-size`. Better Lyrics sets `--blyrics-volume-level` at runtime, so treat it as read only.

### Press Feedback and Like Burst

Applied briefly on interaction, shared by fullscreen and the floating window.

| Class / Keyframe | Purpose |
| --- | --- |
| `.blyrics-control-pop` | Scale pop on press (`blyrics-fs-control-pop`, `blyrics-pip-control-pop`) |
| `.blyrics-control-nudge-prev` / `-next` | Previous and next icon nudge |
| `.blyrics-control-ripple-layer` / `.blyrics-control-ripple` (`--go`) | Click ripple |
| `.blyrics-fs-rate-fire` | Runs the like pop and iris (`blyrics-fs-rate-pop`, `blyrics-fs-rate-iris`) |
| `.blyrics-fs-rate-burst` / `-ray` / `-dot` (`--go`) | Rays and dots that fly out on a like (`blyrics-fs-rate-ray`, `blyrics-fs-rate-dot`) |

## Karaoke Subtitles

With the “Karaoke subtitles” option on (the default) and a music video in fullscreen, the video fills the screen and synced lines show over it one at a time on a blurred plate. The side panel and `#blyrics-fs-column` stay hidden the whole time karaoke owns the fullscreen video, even while lyrics load or when there are none or only unsynced ones. Lines keep the theme’s look; the stage only decides where they go.

### DOM Structure

```
#blyrics-karaoke                                    (fixed overlay on body)
├── .blyrics-karaoke__stage
│   ├── .blyrics-karaoke__plate.blyrics-karaoke-surface    (two plates, swapped when the line moves far)
│   ├── .blyrics-karaoke__plate.blyrics-karaoke-surface
│   └── .blyrics-karaoke__mount
│       └── .blyrics-container[data-layout="stage"]
│           ├── .blyrics--line
│           └── .blyrics-credits                    (end card)
│               └── .blyrics-karaoke-source         (provider name and sync icon)
└── .blyrics-karaoke__title                         (intro title card)
    └── .blyrics-karaoke__card.blyrics-karaoke-surface
        ├── p.blyrics-karaoke__card-title
        ├── p.blyrics-karaoke__card-artist
        └── p.blyrics-karaoke__card-credit
```

### State Attributes

| Attribute | When applied |
| --- | --- |
| `ytmusic-app-layout[blyrics-karaoke]` / `#player-page[blyrics-karaoke]` | Karaoke owns the fullscreen video layout |
| `#blyrics-karaoke[data-bar]` | Player bar is showing; the stage lifts above it |
| `#blyrics-karaoke[data-title-card]` | Intro title card is showing; the stage is hidden |
| `#blyrics-karaoke[data-card]` | `title` or `end` while that card shows; absent for sung lines |
| `.blyrics-karaoke__plate[data-plate]` | Plate is shown behind the current line |
| `.blyrics-karaoke__plate[data-end-card]` | Plate is behind the end card (credits and source); kept while it fades out |
| `data-stage-role` on a line | `current`, `previous`, `queued` or `gone` |
| `data-stage-visible` on a line | Line is on screen; otherwise `visibility: hidden` |
| `.blyrics-container[data-stage-duet]` | Sung lines use both sides: `v1` left, `v2`/`v3` right, `v1000` centred. A one-singer song stays centred |

### Theme Hooks

| Hook | Notes |
| --- | --- |
| `.blyrics-karaoke-surface` | Backdrop shared by both plates and the title card |
| `--blyrics-karaoke-video-scale` | Unitless, default `1`; e.g. `0.94` shrinks the video and overlay relative to the screen |
| `--blyrics-karaoke-plate-padding-block` / `-inline` | Defaults `0.18em` / `0.55em`; padding of the plate around a sung line. Any unit; `em` follows the lyric size |
| `--blyrics-karaoke-card-padding-block` / `-inline` | Defaults `0.36em` / `0.8em`; padding of the plate around the end card |
| `--blyrics-karaoke-plate-radius` | Default `0.46em`; corner radius of both plates and the title card. The line clip follows it |
| `--blyrics-font-size` | Sets the karaoke lyric size, as elsewhere |
| `--blyrics-scale` / `--blyrics-active-scale` | Forced to `1` on `#blyrics-karaoke .blyrics-container`; no line scaling in karaoke |
| `--blyrics-stage-opacity` | Set by the engine; stage line `opacity` reads it with `!important` |
| `ytmusic-player-page[video-mode][blyrics-karaoke] :is(#player, .html5-video-player)` | Background is `transparent !important`, so bars around a non-matching video show the theme or album art backdrop; set `#000 !important` to restore black |

Never set `opacity` on stage lines. It is overridden, so a theme cannot show a hidden line or hold a leaving one.

```css
:root {
  --blyrics-karaoke-video-scale: 0.94;
}

#blyrics-karaoke .blyrics-karaoke-surface {
  border-radius: 0.3em;
  background: rgb(0 0 0 / 70%);
  backdrop-filter: blur(12px);
}
```

## Floating Window Music Video

With “Show music video” on and YouTube Music in video mode, the floating window plays the video in the art slot. With karaoke subtitles also on and synced lyrics, it shows a full-window karaoke stage over the video instead. Ads are never mirrored: the artwork and lyrics fade out and a countdown intermission shows.

### DOM Structure

```
.blyrics-pip-shell                                  ([data-video], [data-layout], [data-instant])
├── .blyrics-pip-art-col
│   └── .blyrics-pip-artwork                        ([data-video-face])
│       ├── .blyrics-pip-artwork__card
│       │   └── .blyrics-pip-artwork__face           (front and back)
│       │       └── video.blyrics-pip-artwork__music-video
│       └── .blyrics-pip-artwork__controls
│           ├── .blyrics-pip-artwork__chrome
│           │   ├── .blyrics-pip-mode-toggle
│           │   │   └── button.blyrics-pip-mode-toggle__option   (song, video)
│           │   └── button.blyrics-pip-artwork__control.blyrics-pip-artwork__control--close
│           ├── button.blyrics-pip-artwork__control.blyrics-pip-artwork__control--like   ([data-active] when liked)
│           └── .blyrics-volume                     (same pill as fullscreen)
├── .blyrics-pip-stage
│   ├── video.blyrics-pip-stage__video
│   └── #blyrics-karaoke                            (same tree as fullscreen karaoke)
└── .blyrics-pip-intermission
    ├── .blyrics-pip-intermission__row
    │   ├── .blyrics-pip-intermission__mark
    │   └── p.blyrics-pip-intermission__label
    ├── .blyrics-pip-intermission__count
    │   └── .blyrics-pip-intermission__glyph        ([data-digit] on digits)
    ├── .blyrics-pip-intermission__bar
    │   └── .blyrics-pip-intermission__fill         (drains through transform: scaleX)
    └── .blyrics-pip-intermission__next
        ├── .blyrics-pip-intermission__thumb
        └── span                                    ("Then <b>song</b>")
```

### State Attributes

| Attribute | When applied |
| --- | --- |
| `.blyrics-pip-shell[data-video="on"]` | Video is playing in the window |
| `.blyrics-pip-shell[data-video="ad"]` | Ad is playing; intermission shows, art column and lyrics hidden |
| `.blyrics-pip-shell[data-video="off"]` | Option off, song mode, or no video track yet |
| `.blyrics-pip-shell[data-layout="stage"]` | Karaoke stage over the video: option on, video mode, karaoke on, synced lyrics |
| `.blyrics-pip-shell[data-stage-card]` | `title` or `end` while a karaoke card shows in the stage; `--blyrics-pip-card-height` (read-only) holds its height. The transport stays above the card and the header hides during the title card; at 340px wide or less (not tall windows) the mode pill hides too |
| `.blyrics-pip-shell[data-instant]` | Song/video switch in progress; transitions forced off until it settles |
| `.blyrics-pip-artwork[data-video-face]` | Visible artwork face holds the video |
| `.blyrics-pip-mode-toggle[hidden]` | Track has no song/video switch; also hidden in windows under 130px tall |
| `.blyrics-pip-mode-toggle__option[aria-pressed="true"]` | Current mode |
| `.blyrics-pip-intermission__count[hidden]`, `__bar[hidden]`, `__next[hidden]` | No ad time or next title to show |

### Theme Hooks

| Hook | Notes |
| --- | --- |
| `--blyrics-pip-video-aspect` | Read-only. Video’s `W / H`, set inline on `.blyrics-pip-shell`; unset until known, rules fall back to `16 / 9` |
| Plate variables in the window | The window sets them on `.blyrics-pip-shell[data-layout="stage"]`: radius `0.75rem`, line padding `0.5rem` / `0.75rem`, end card padding `0.5rem` / `1rem`. Override them there in `rem` or `px`; the window reads them there to keep the plate clear of the progress row and transport |
| `--blyrics-pip-karaoke-text-scale` | Read-only. Unitless, set inline on `.blyrics-pip-shell` on resize; stage `--blyrics-font-size` and `--blyrics-translated-font-size` are multiplied by it. The transport uses it too, so overriding moves text but not controls |
| `--blyrics-pip-control-tint` | Default `rgb(0 0 0 / 28%)`, on `.blyrics-pip-artwork__controls`; background of control discs and the mode pill |
| `--blyrics-pip-control-backdrop` | Default `blur(8px)`, on `.blyrics-pip-artwork__controls`; `backdrop-filter` of control discs and the mode pill |
| `--blyrics-pip-control-size` | Default `36px` (`44px` in tall windows), on `.blyrics-pip-artwork`; previous and next button size, play and pause is 1.25 times larger. Does not change with window size: the artwork grows so the pill, close button and transport fit inside it at its aspect ratio, and short windows hide the progress row. Normal layout only; the stage sizes its own transport |
| `#blyrics-karaoke` in the window | Same plate, title card, end card classes and attributes as fullscreen; prefix `.blyrics-pip-shell` to target the window only |

Internal, do not set: `--blyrics-pip-stage-width`, `-stage-height`, `-progress-inset`, `-progress-height`, `-plate-gap`, `-plate-overhang`, `-plate-top`, `-frame-bottom-gap`, `-header-bottom`, `-transport-margin`, `-transport-y`, `-karaoke-font-size`, `-karaoke-translated-font-size`. They derive from each other and place the transport between the header and the plate. Also internal, normal layout: `--blyrics-pip-artwork-aspect`, `-artwork-min-height`, `-artwork-min-width`, `-chrome-height`, `-transport-edge`; they size the artwork minimum.

Tall windows (`max-aspect-ratio: 4/5`) put the video at the top and the lines in the band under it, with no plate background.

```css
.blyrics-pip-shell #blyrics-karaoke .blyrics-karaoke-surface {
  background: rgb(0 0 0 / 55%);
}

.blyrics-pip-artwork__controls {
  --blyrics-pip-control-tint: rgb(255 255 255 / 12%);
}

.blyrics-pip-intermission__fill {
  background: var(--blyrics-lyric-active-color);
}
```

## Theme Patterns

### 1\. Disable Default Animations

```css
:root {
  --blyrics-animate-line-scale: 0;
  --blyrics-animate-word-wobble: 0;
  --blyrics-animate-highlight-swipe: 0;
  --blyrics-animate-highlight-glow: 0;
  --blyrics-animate-scroll: 0;
  --blyrics-animate-instrumental: 0;
  --blyrics-scale: 1;
  --blyrics-active-scale: 1;
}
```

### 2\. Opacity-Based Active State

```css
.blyrics-container > div {
  opacity: 0.35;
  transform: none !important;
  transition: opacity 0.4s ease-out !important;
}
.blyrics-container > div.blyrics--active {
  opacity: 1;
}
```

### 3\. Blur Inactive Lines

```css
.blyrics-container > div {
  opacity: 0.2;
  filter: blur(6px);
  transition: opacity 0.7s, filter 0.7s, transform 1.66s;
}
.blyrics-container > div.blyrics--active:not(:empty) {
  opacity: 1;
  filter: blur(0px);
}
.blyrics-user-scrolling > div {
  opacity: 1 !important;
  filter: blur(0px) !important;
}
.blyrics-container[data-sync="none"] > div {
  opacity: 1;
  filter: none;
}
```

### 4\. Duration-Based Timing

```css
.blyrics-container > div {
  transition: filter calc(var(--blyrics-duration) / 2),
              opacity calc(var(--blyrics-duration) / 2);
}
```

### 5\. Custom Font Import

```css
@import url('https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@200..800&display=swap');
.blyrics-container {
  font-family: 'Bricolage Grotesque', var(--noto-sans-universal), sans-serif;
}
```

### 6\. Theme Variables

```css
:root {
  --my-theme-bg: #1a1a1a;
  --my-theme-text: #e0e0e0;
  --my-theme-accent: #d4a5a5;
}
```

### 7\. Background Customization

```css
ytmusic-player-page:before {
  background: linear-gradient(to right, rgba(26,26,26,0.75), rgba(26,26,26,0.75)),
              var(--blyrics-background-img);
  filter: blur(50px) saturate(0.8);
}
```

### 8\. Glassmorphism

```css
#side-panel {
  backdrop-filter: blur(20px) !important;
  background-color: rgba(0, 0, 0, 0.25) !important;
  box-shadow: 0 20px 60px rgba(0,0,0,0.4), 0 0 25px rgba(255,255,255,0.12) inset !important;
}
```

### 9\. Animated Background

```css
ytmusic-player-page::before {
  filter: blur(70px) saturate(3) brightness(70%);
  animation: slowRotate 15s linear infinite;
}
@keyframes slowRotate {
  from { transform: scale(1.7) rotate(0deg); }
  to { transform: scale(1.7) rotate(360deg); }
}
```

### 10\. Underline Active Line

```css
.blyrics-container > div::after {
  content: '';
  position: absolute;
  left: 50%; bottom: 10px;
  height: 2px; width: 50%;
  transform: translateX(-50%) scaleX(0);
  background: linear-gradient(90deg, transparent, hsla(0,0%,100%,0.4), transparent);
  transition: transform 0.5s cubic-bezier(0.86, 0, 0.07, 1);
}
.blyrics-container > div.blyrics--active::after {
  transform: translateX(-50%) scaleX(1);
}
```

### 11\. User Scroll State

```css
.blyrics-user-scrolling > div {
  opacity: 1 !important;
  filter: blur(0px) !important;
}
.blyrics-container:not(:has(.blyrics--active)) > div {
  opacity: 1;
  filter: none;
}
```

### 12\. Modern Color Spaces

```css
:root {
  --blyrics-lyric-inactive-color: oklch(1 0 0 / 0.35);
  --blyrics-lyric-active-color: oklch(1 0 0 / 1);
}
```

### 13\. Instrumental Customization

```css
.blyrics--instrumental-icon {
  width: 4rem;
  height: 4rem;
}
.blyrics--instrumental-bg {
  fill: rgba(255, 255, 255, 0.3);
}
.blyrics--instrumental-fill {
  fill: rgba(255, 255, 255, 1);
}
```

### 14\. Long Word Glow

Target sustained notes for special effects:

```css
/* Set threshold in knobs */
/* blyrics-long-word-threshold = 1500; */

.blyrics-word-highlight[data-long-word] {
  --blyrics-glow-color: color(display-p3 1 0.8 0.3 / 1);
}
```

`--blyrics-glow-color` resolves per word, so different long words can glow different colors. Tune the blur with `--blyrics-highlight-glow-radius-from` / `--blyrics-highlight-glow-radius-to` while keeping per-word color. Overriding the full `--blyrics-highlight-glow-filter-from` / `--blyrics-highlight-glow-filter-to` instead resolves the color once at the container, so every word shares one glow color.

### 15\. Pause Behavior

Playback pause is handled by pausing the active Web Animations API animations in JavaScript. The engine also puts `.blyrics--paused` on the line and on each of its words, so theme-authored CSS animations and transitions can freeze in step:

```css
.blyrics--paused .my-shimmer {
  animation-play-state: paused;
}
```

### 16\. Per-Letter Wave (Experimental)

On by default. Each letter of a word floats in turn as it is sung, on top of the word wobble. Disable it with `blyrics-letter-wave = false`, or tune it:

```css
.blyrics-container {
  --blyrics-letter-wave-transform: translateY(-0.05em); /* crest lift */
  --blyrics-letter-wave-settle: translateY(-0.02em); /* rest it eases back to */
  --blyrics-letter-wave-emphasis-scale: 1.11; /* long-word letter swell, 1 turns it off */
}
```

It layers on the word wobble rather than replacing it, so the default `scaleX` pop stays. To make the letters carry all the motion instead, set the word wobble to identity (`--blyrics-word-wobble-transform-*: translateY(0)`). It follows `--blyrics-animate-word-wobble`, so reduced motion turns it off. The split multiplies the DOM per character and reruns the karaoke sweep per letter, so a theme that does not want the cost turns it off with `blyrics-letter-wave = false`.

### 17\. Per-Word State (Karaoke)

`data-word-state` marks whether each word is `upcoming`, `active`, or `past`. It is written on both the base `.blyrics--word` and its highlight overlay, and only when a word’s state changes, so a theme can flip whole words as they are sung with no per-frame cost:

```css
.blyrics--word {
  color: var(--blyrics-lyric-inactive-color);
  transition: color 180ms ease;
}

.blyrics--word[data-word-state="active"],
.blyrics--word[data-word-state="past"] {
  color: var(--blyrics-lyric-active-color);
}
```

A line-synced word, which has no duration of its own, is `active` from its start until the next word begins. To make this the whole reveal instead of layering over the swept overlay, stand the overlay down with `.blyrics-word-highlight { display: none; }`.

## Best Practices

1.  **Use CSS variables** over raw values
2.  **Use display-p3 or oklch** for wider color gamut
3.  **Include `var(--noto-sans-universal)`** in font stacks for i18n
4.  **Test both modes** - audio-only and video
5.  **Test fullscreen** and responsive breakpoints (936px, 615px)
6.  **Handle `data-sync="none"`** - smooth transition when sync loads
7.  **Exclude translation/romanization** - `:not(.blyrics--translated):not(.blyrics--romanized)`
8.  **Handle user scroll** - `.blyrics-user-scrolling`
9.  **Prefer structural selectors** such as `.blyrics-line-main`, `.blyrics-word-group`, and data attributes; use `.blyrics--active` only for current-line affordances
10.  **Pick the right clock** - `.blyrics--active` for anything tied to scrolling, `.blyrics--animating` for anything that must hold while a line is still being sung. Dimming past lines off `.blyrics--active` alone will dim them roughly half a second early
11.  **Respect RTL inline flow** - do not force `.blyrics-bidi-sensitive .blyrics-word-group` or `.blyrics-bidi-sensitive .blyrics--word` back to `inline-block`; word wobble transforms should be treated as unavailable on RTL-sensitive rows

## Do NOT Modify

-   `--noto-sans-universal` - International font fallback chain
-   `--blyrics-gradient-stops` - Complex fullscreen gradient
-   Core animation variables such as `--lyric-transition-amount-start` and `--lyric-transition-amount-end` unless you are replacing the karaoke highlight effect
-   Core DOM structure expectations
-   YouTube Music element selectors (unless intentional)

## Files Reference

| File | Purpose |
| --- | --- |
| `lyrics.css` | Core lyrics styling and animations |
| `ytmusic.css` | YouTube Music layout modifications |
| `themesong.css` | ThemeSong extension compatibility |
| `disablestylizedanimations.css` | Disables animations when toggled |

## Existing Themes

Reference in [`public/css/themes/`](https://github.com/better-lyrics/better-lyrics/tree/master/public/css/themes):

| Theme | Style |
| --- | --- |
| `Default.css` | Minimal starting point |
| `Minimal.css` | Opacity-based, no animations |
| `Spotlight.css` | Blur effect on inactive lines |
| `Luxurious Glass.css` | Glassmorphism, animated background |
| `Dynamic Background.css` | Extensive YouTube Music UI customization |
| `Apple Music.css` | Apple Music-inspired styling |
| `Harmony Glow.css` | Glow effects |
| `Pastel.css` | Soft pastel colors |

Source: https://docs.betterlyrics.org/theming/agents
