Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/architecture/branding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Branding

Authenticated users choose one navigation identity in Settings > Appearance:
the original colour logo, the monochrome logo, an editable text wordmark, a
custom image, or a visually hidden logo. The shared
`users/components/brand_logo.html` template is the single renderer used by the
authenticated shell, public shell, and sign-in layout.

Hidden branding keeps a labelled home link for keyboard and screen-reader
users. Text is escaped by Django and limited to 32 characters.

Custom images accept PNG, JPEG, and WebP uploads up to 2 MB. They are decoded,
resized within 256 by 64 pixels, and re-encoded as metadata-free WebP before
storage. SVG is intentionally rejected. The resulting data URL is capped at
40 KB so a user preference cannot become an unbounded page payload.

The built-in sidebar mark keeps its original 158 by 48 pixel display size and
does not shrink as a flex item. Custom marks share the same 48 pixel height and
fit within the sidebar width.
58 changes: 48 additions & 10 deletions docs/architecture/theming.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,30 +6,58 @@ neither failure is visible in the theme the author has open.

## How a theme is selected

`user.theme` holds one of `light`, `dark` or `system`. `base.html` writes the
value onto the root element:
`user.theme` holds a key from `users.appearance.THEME_PRESETS`. The collection
includes the system, light, dark and custom states; Catppuccin Mocha, Dracula,
Nord and Gruvbox classics; OLED, Glass Cinema and Plex-inspired modern themes;
plus Projector and Video Store. `base.html` writes every explicit choice onto
the root element:

```html
<html class="{% if user.theme == 'light' %}light{% elif user.theme == 'dark' %}dark{% endif %}">
<html class="{% if user.theme != 'system' %}{{ user.theme }}{% endif %}">
```

`system` writes no class, which lets the operating system preference decide.

There are therefore six states, not two: three values of `user.theme` crossed
with a light or dark operating system preference. Any change to colour must hold
in all six.
Only `system` follows the operating system preference. Presets and the custom
palette are explicit states. Any token change must be checked in every state.

## Tokens

`src/static/css/input.css` declares every `--color-*` token in four blocks:
`src/static/css/input.css` declares the base tokens in four blocks:

| Block | Applies to |
| --- | --- |
| `:root` | the dark defaults |
| `@media (prefers-color-scheme: light) { :root:not(.dark) }` | `system` on a light host |
| `@media (prefers-color-scheme: light) { :root:not(.[every explicit theme]) }` | `system` on a light host |
| `html.light` | an explicit light choice |
| `html.dark` | an explicit dark choice |

The light media selector must exclude every explicit theme class. Otherwise a
light operating system overrides equally specific preset tokens that appear
earlier in the stylesheet. `test_system_light_tokens_exclude_every_explicit_theme`
pins that contract to the preset registry.

Preset classes override the tokens they intentionally change and inherit the
remaining dark defaults. The classic palettes follow their official colour
systems: [Catppuccin](https://python.catppuccin.com/docs/catppuccin/palette.html),
[Dracula](https://github.com/dracula/dracula-theme),
[Nord](https://www.nordtheme.com/) and
[Gruvbox](https://github.com/morhetz/gruvbox). `html.glass` adds a fixed translucent cinema treatment.
`html.custom` inherits the dark defaults; `base.html` adds the six validated
colours plus bounded radius, blur and surface-opacity values as inline variables.
`users.appearance` is the allowlist and validation boundary for those values.

Every explicit preset also owns its shape and motion through
`--theme-radius`, `--motion-duration`, `--motion-distance`, and
`--motion-ease`. Components consume those tokens rather than inventing local
timings or radii, so changing a preset changes the whole interface coherently.
Glass is deliberately soft and fluid, OLED is crisp, and the classic themes
remain restrained.

The global `prefers-reduced-motion: reduce` block is a required safety net. It
must keep navigation and controls usable while reducing animations and smooth
scrolling to effectively instantaneous changes.

`html.dark` repeats the `:root` values. That repetition is redundant, since
`:root` already carries the dark values and the light media query is guarded by
`:not(.dark)`. It is kept only so the four blocks read symmetrically. If you add
Expand Down Expand Up @@ -83,10 +111,20 @@ concatenated parts to avoid exactly that.

## Persisting a theme change

The header toggle flips the root class immediately and posts `theme=<value>`
alone to the preferences view. That view therefore reads every field with a
The header toggle gets every explicit class from `THEME_PRESETS`, removes them,
applies `light` or `dark` immediately, and posts `theme=<value>` alone to the preferences view. That view
therefore reads every field with a
presence check rather than a fallback: a field that defaulted when absent would
be reset on every toggle. `users.tests.views.test_theme_toggle` pins this.

Settings > Appearance owns preset selection, the custom palette, and detail
page composition. It validates the complete payload before saving any part.
Values passed to Django's `json_script` must remain Python dictionaries; the
filter performs the single required serialization before Alpine parses them.

The compact sun/moon switcher is rendered only for the basic `system`, `light`,
and `dark` themes. Presets and custom palettes are changed only from Appearance;
otherwise a single click would silently replace the user's selected design.

`PATCH /api/v1/user/preferences/` follows the same rule and ignores any field
the body omits. It does not currently accept `theme`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Generated by Django 5.2.16 on 2026-08-28 07:35

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('app', '0167_collectionfield_collectionfieldgroup_and_more'),
]

operations = [
migrations.RemoveConstraint(
model_name='item',
name='app_item_source_valid',
),
migrations.AlterField(
model_name='deletedmedia',
name='source',
field=models.CharField(choices=[('tmdb', 'The Movie Database'), ('tvdb', 'TheTVDB'), ('mal', 'MyAnimeList'), ('mangaupdates', 'MangaUpdates'), ('igdb', 'Internet Game Database'), ('imdb', 'IMDb'), ('openlibrary', 'Open Library'), ('hardcover', 'Hardcover'), ('googlebooks', 'Google Books'), ('comicvine', 'Comic Vine'), ('bgg', 'BoardGameGeek'), ('musicbrainz', 'MusicBrainz'), ('pocketcasts', 'Pocket Casts'), ('gpodder', 'GPodder'), ('audiobookshelf', 'Audiobookshelf'), ('storyteller', 'Storyteller'), ('manual', 'Manual')], max_length=20),
),
migrations.AlterField(
model_name='item',
name='source',
field=models.CharField(choices=[('tmdb', 'The Movie Database'), ('tvdb', 'TheTVDB'), ('mal', 'MyAnimeList'), ('mangaupdates', 'MangaUpdates'), ('igdb', 'Internet Game Database'), ('imdb', 'IMDb'), ('openlibrary', 'Open Library'), ('hardcover', 'Hardcover'), ('googlebooks', 'Google Books'), ('comicvine', 'Comic Vine'), ('bgg', 'BoardGameGeek'), ('musicbrainz', 'MusicBrainz'), ('pocketcasts', 'Pocket Casts'), ('gpodder', 'GPodder'), ('audiobookshelf', 'Audiobookshelf'), ('storyteller', 'Storyteller'), ('manual', 'Manual')], max_length=20),
),
migrations.AlterField(
model_name='itemproviderlink',
name='provider',
field=models.CharField(choices=[('tmdb', 'The Movie Database'), ('tvdb', 'TheTVDB'), ('mal', 'MyAnimeList'), ('mangaupdates', 'MangaUpdates'), ('igdb', 'Internet Game Database'), ('imdb', 'IMDb'), ('openlibrary', 'Open Library'), ('hardcover', 'Hardcover'), ('googlebooks', 'Google Books'), ('comicvine', 'Comic Vine'), ('bgg', 'BoardGameGeek'), ('musicbrainz', 'MusicBrainz'), ('pocketcasts', 'Pocket Casts'), ('gpodder', 'GPodder'), ('audiobookshelf', 'Audiobookshelf'), ('storyteller', 'Storyteller'), ('manual', 'Manual')], max_length=20),
),
migrations.AlterField(
model_name='metadataproviderpreference',
name='provider',
field=models.CharField(choices=[('tmdb', 'The Movie Database'), ('tvdb', 'TheTVDB'), ('mal', 'MyAnimeList'), ('mangaupdates', 'MangaUpdates'), ('igdb', 'Internet Game Database'), ('imdb', 'IMDb'), ('openlibrary', 'Open Library'), ('hardcover', 'Hardcover'), ('googlebooks', 'Google Books'), ('comicvine', 'Comic Vine'), ('bgg', 'BoardGameGeek'), ('musicbrainz', 'MusicBrainz'), ('pocketcasts', 'Pocket Casts'), ('gpodder', 'GPodder'), ('audiobookshelf', 'Audiobookshelf'), ('storyteller', 'Storyteller'), ('manual', 'Manual')], max_length=20),
),
migrations.AlterField(
model_name='person',
name='source',
field=models.CharField(choices=[('tmdb', 'The Movie Database'), ('tvdb', 'TheTVDB'), ('mal', 'MyAnimeList'), ('mangaupdates', 'MangaUpdates'), ('igdb', 'Internet Game Database'), ('imdb', 'IMDb'), ('openlibrary', 'Open Library'), ('hardcover', 'Hardcover'), ('googlebooks', 'Google Books'), ('comicvine', 'Comic Vine'), ('bgg', 'BoardGameGeek'), ('musicbrainz', 'MusicBrainz'), ('pocketcasts', 'Pocket Casts'), ('gpodder', 'GPodder'), ('audiobookshelf', 'Audiobookshelf'), ('storyteller', 'Storyteller'), ('manual', 'Manual')], default='tmdb', max_length=20),
),
migrations.AlterField(
model_name='podcastshow',
name='source',
field=models.CharField(choices=[('tmdb', 'The Movie Database'), ('tvdb', 'TheTVDB'), ('mal', 'MyAnimeList'), ('mangaupdates', 'MangaUpdates'), ('igdb', 'Internet Game Database'), ('imdb', 'IMDb'), ('openlibrary', 'Open Library'), ('hardcover', 'Hardcover'), ('googlebooks', 'Google Books'), ('comicvine', 'Comic Vine'), ('bgg', 'BoardGameGeek'), ('musicbrainz', 'MusicBrainz'), ('pocketcasts', 'Pocket Casts'), ('gpodder', 'GPodder'), ('audiobookshelf', 'Audiobookshelf'), ('storyteller', 'Storyteller'), ('manual', 'Manual')], default='pocketcasts', help_text='Podcast provider source for this show', max_length=20),
),
migrations.AlterField(
model_name='studio',
name='source',
field=models.CharField(choices=[('tmdb', 'The Movie Database'), ('tvdb', 'TheTVDB'), ('mal', 'MyAnimeList'), ('mangaupdates', 'MangaUpdates'), ('igdb', 'Internet Game Database'), ('imdb', 'IMDb'), ('openlibrary', 'Open Library'), ('hardcover', 'Hardcover'), ('googlebooks', 'Google Books'), ('comicvine', 'Comic Vine'), ('bgg', 'BoardGameGeek'), ('musicbrainz', 'MusicBrainz'), ('pocketcasts', 'Pocket Casts'), ('gpodder', 'GPodder'), ('audiobookshelf', 'Audiobookshelf'), ('storyteller', 'Storyteller'), ('manual', 'Manual')], default='tmdb', max_length=20),
),
migrations.AddConstraint(
model_name='item',
constraint=models.CheckConstraint(condition=models.Q(('source__in', ['tmdb', 'tvdb', 'mal', 'mangaupdates', 'igdb', 'imdb', 'openlibrary', 'hardcover', 'googlebooks', 'comicvine', 'bgg', 'musicbrainz', 'pocketcasts', 'gpodder', 'audiobookshelf', 'storyteller', 'manual'])), name='app_item_source_valid'),
),
]
Loading