The export dialog gains an accent color — the hover background on links and
tracklist rows, and the player's progress fill — and the rest of the bar loses
its 2010 gold so that choice is the only color in it.
The accent travels as a `:root { --accent }` custom property declared in
index.html, which player.css reads as `var(--accent, #8c764a)`. That keeps the
stylesheet in the verbatim copyfile loop: index.html is still the only rendered
template. `normalize_accent` is the injection gate — the value lands raw inside
a <style> block and string.Template escapes nothing — and `contrast_text` flips
the hover text black or white, since the old page hard-coded white and a pale
accent made it unreadable.
The bar itself is now fixed light gray with black text. Its sprite glyphs are
pale lavender and yellow, drawn for the dark gold bar, so they're recolored
with `filter: brightness(0)` rather than by editing the GIF — which still ships
byte-identical, spinner and all.
Not persisted: the picker opens on #8c764a every time, so an export nobody
touches looks exactly like the mixes already online.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBSM2bFC6UToiEg8BE4dqj
167 lines
6.8 KiB
Python
167 lines
6.8 KiB
Python
"""Round 41: an accent color for the web mix.
|
|
|
|
The export dialog gained one color, and the player bar lost its gold. What is
|
|
load-bearing here:
|
|
|
|
* the accent reaches the page as a `:root` custom property, so `player.css`
|
|
can read it while still being copied byte-for-byte rather than rendered;
|
|
* a color the user never touched still renders `#8c764a` — the mixes already
|
|
online must keep looking the way they do;
|
|
* the value lands raw inside a `<style>` block, so anything that isn't a hex
|
|
color is rejected outright rather than escaped;
|
|
* hover text flips black/white against the accent, because the old page
|
|
hard-coded white and a pale accent made it unreadable.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from lintunes.export import exporter
|
|
from lintunes.export.exporter import (DEFAULT_ACCENT, ExportWorker,
|
|
contrast_text, normalize_accent,
|
|
plan_export)
|
|
from lintunes.models import Track
|
|
|
|
|
|
def _track(tid, name, artist, path):
|
|
return Track(track_id=tid, name=name, artist=artist, location=str(path),
|
|
kind="MPEG audio file", total_time=180_000)
|
|
|
|
|
|
def _audio(tmp_path, filename):
|
|
path = tmp_path / "local" / filename
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(b"x" * 100)
|
|
return path
|
|
|
|
|
|
def _export(tmp_path, **details):
|
|
"""A one-track web mix, with `details` applied to the plan."""
|
|
tracks = [_track(1, "S", "A", _audio(tmp_path, "a.mp3"))]
|
|
plan = plan_export("My Mix", tracks, tmp_path / "out", exporter.WEB)
|
|
for key, value in details.items():
|
|
setattr(plan, key, value)
|
|
worker = ExportWorker(plan)
|
|
failed = []
|
|
worker.failed.connect(failed.append)
|
|
worker._run()
|
|
assert not failed, failed
|
|
return tmp_path / "out" / "My Mix"
|
|
|
|
|
|
class TestNormalizeAccent:
|
|
@pytest.mark.parametrize("value,expected", [
|
|
("#8C764A", "#8c764a"),
|
|
("#abc", "#abc"),
|
|
(" #3584e4 ", "#3584e4"),
|
|
])
|
|
def test_hex_colors_are_accepted_and_lowercased(self, value, expected):
|
|
assert normalize_accent(value) == expected
|
|
|
|
@pytest.mark.parametrize("value", [
|
|
"red", "#xyz", "#12345", "", None, 0x8c764a,
|
|
"#fff; } body { display: none; ", # the injection attempt
|
|
"url(https://example.com/beacon.png)",
|
|
])
|
|
def test_anything_else_falls_back_to_the_default(self, value):
|
|
assert normalize_accent(value) == DEFAULT_ACCENT
|
|
|
|
def test_a_rejected_value_never_reaches_the_page(self, tmp_path):
|
|
dest = _export(tmp_path, accent="#fff; } body { display: none; ")
|
|
html = (dest / "index.html").read_text()
|
|
assert "display: none" not in html
|
|
assert "--accent: #8c764a" in html
|
|
|
|
|
|
class TestContrastText:
|
|
@pytest.mark.parametrize("accent", ["#ffffff", "#f5f5a0", "#7fd1ae"])
|
|
def test_light_accents_get_black_text(self, accent):
|
|
assert contrast_text(accent) == "#000"
|
|
|
|
@pytest.mark.parametrize("accent", ["#000000", "#8c764a", "#3584e4"])
|
|
def test_dark_accents_get_white_text(self, accent):
|
|
assert contrast_text(accent) == "#fff"
|
|
|
|
def test_shorthand_hex_is_expanded_not_misread(self):
|
|
assert contrast_text("#fff") == contrast_text("#ffffff")
|
|
assert contrast_text("#000") == contrast_text("#000000")
|
|
|
|
|
|
class TestAccentInThePage:
|
|
def test_default_is_the_colour_the_old_mixes_used(self, tmp_path):
|
|
html = (_export(tmp_path) / "index.html").read_text()
|
|
assert "--accent: #8c764a" in html
|
|
assert "--accent-text: #fff" in html
|
|
|
|
def test_chosen_accent_is_substituted(self, tmp_path):
|
|
html = (_export(tmp_path, accent="#3584E4") / "index.html").read_text()
|
|
assert "--accent: #3584e4" in html
|
|
|
|
def test_no_placeholder_survives(self, tmp_path):
|
|
html = (_export(tmp_path, accent="#3584e4") / "index.html").read_text()
|
|
for placeholder in ("$accent", "$accent_text", "$title", "$tracklist"):
|
|
assert placeholder not in html
|
|
|
|
def test_hover_rules_read_the_variable_not_a_hard_coded_brown(self, tmp_path):
|
|
html = (_export(tmp_path, accent="#3584e4") / "index.html").read_text()
|
|
assert html.count("background-color: var(--accent)") == 2 # links + rows
|
|
assert html.count("color: var(--accent-text)") == 2
|
|
# The old brown only survives as the variable's value.
|
|
assert html.count("#8c764a") == 0
|
|
|
|
def test_hover_text_flips_with_the_accent(self, tmp_path):
|
|
pale = (_export(tmp_path / "pale", accent="#f5f5a0")
|
|
/ "index.html").read_text()
|
|
assert "--accent-text: #000" in pale
|
|
dark = (_export(tmp_path / "dark", accent="#220d02")
|
|
/ "index.html").read_text()
|
|
assert "--accent-text: #fff" in dark
|
|
|
|
|
|
class TestPlayerBarChrome:
|
|
def test_css_reads_the_variable_so_it_can_ship_verbatim(self, tmp_path):
|
|
dest = _export(tmp_path, accent="#3584e4")
|
|
css = (dest / "player.css").read_text()
|
|
assert "background: var(--accent, #8c764a);" in css
|
|
# Copied, not rendered: identical to the template on disk.
|
|
assert css == (exporter.templates() / "player.css").read_text()
|
|
|
|
def test_bar_is_light_gray_with_black_text(self, tmp_path):
|
|
css = (_export(tmp_path) / "player.css").read_text()
|
|
assert "background: #eee;" in css
|
|
# The old gold survives only in the header comment explaining it.
|
|
assert "background: #c7b563;" not in css
|
|
assert "color: #ddd;" not in css # the old time text
|
|
assert "color: #666666;" not in css # the old elapsed text
|
|
|
|
def test_sprite_glyphs_are_forced_black(self, tmp_path):
|
|
css = (_export(tmp_path) / "player.css").read_text()
|
|
assert "filter: brightness(0);" in css
|
|
|
|
def test_graphics_gif_still_ships_byte_identical(self, tmp_path):
|
|
dest = _export(tmp_path)
|
|
shipped = (dest / "player-graphics.gif").read_bytes()
|
|
assert shipped == (exporter.templates() / "player-graphics.gif").read_bytes()
|
|
assert b"NETSCAPE" in shipped # still the animated spinner
|
|
|
|
|
|
class TestDialog:
|
|
def test_values_carry_the_accent(self, qapp):
|
|
from lintunes.gui.export_dialog import WebMixDialog
|
|
dialog = WebMixDialog("My Mix")
|
|
assert dialog.values()["accent"] == DEFAULT_ACCENT
|
|
dialog._set_accent("#3584E4")
|
|
assert dialog.values()["accent"] == "#3584e4"
|
|
|
|
def test_the_button_shows_the_colour_it_holds(self, qapp):
|
|
from lintunes.gui.export_dialog import WebMixDialog
|
|
dialog = WebMixDialog("My Mix")
|
|
dialog._set_accent("#3584e4")
|
|
assert dialog._accent_button.text() == "#3584e4"
|
|
assert not dialog._accent_button.icon().isNull()
|
|
|
|
def test_a_junk_colour_cannot_be_held(self, qapp):
|
|
from lintunes.gui.export_dialog import WebMixDialog
|
|
dialog = WebMixDialog("My Mix")
|
|
dialog._set_accent("nonsense")
|
|
assert dialog.values()["accent"] == DEFAULT_ACCENT
|