diff --git a/README.md b/README.md index 470266b..95868fe 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Invite-only, no public registration. Built for about ten friends. | [CONTEXT.md](CONTEXT.md) | The glossary — every domain term, in English and Finnish | | [docs/spec.md](docs/spec.md) | What the app does: rules, pipeline, routes, API contract, schema | | [docs/decisions.md](docs/decisions.md) | Why it is that way. Append-only | +| [docs/theme.md](docs/theme.md) | The visual language: tokens, type, and what differs from the theme handoff | | [docs/later.md](docs/later.md) | Deliberately not in v1, with the reasoning kept | ## Branches diff --git a/auth.go b/auth.go index e4ecaf2..01d582c 100644 --- a/auth.go +++ b/auth.go @@ -160,7 +160,7 @@ type authForm struct { } func (a *app) loginPage(w http.ResponseWriter, r *http.Request) { - a.render(w, r, http.StatusOK, "login.html", page{Title: "Kirjaudu", Data: authForm{}}) + a.render(w, r, http.StatusOK, "login.html", page{Title: "Kirjaudu", Narrow: true, Data: authForm{}}) } func (a *app) login(w http.ResponseWriter, r *http.Request) { @@ -169,7 +169,7 @@ func (a *app) login(w http.ResponseWriter, r *http.Request) { if a.logins.locked(email) { form.Errors["form"] = "Liian monta yritystä. Yritä hetken kuluttua uudelleen." - a.render(w, r, http.StatusTooManyRequests, "login.html", page{Title: "Kirjaudu", Data: form}) + a.render(w, r, http.StatusTooManyRequests, "login.html", page{Title: "Kirjaudu", Narrow: true, Data: form}) return } @@ -185,12 +185,12 @@ func (a *app) login(w http.ResponseWriter, r *http.Request) { // One message for both cases: a distinct "no such account" tells anyone who asks which // addresses are members. form.Errors["form"] = "Sähköposti tai salasana ei täsmää." - a.render(w, r, http.StatusUnauthorized, "login.html", page{Title: "Kirjaudu", Data: form}) + a.render(w, r, http.StatusUnauthorized, "login.html", page{Title: "Kirjaudu", Narrow: true, Data: form}) return } if banned { form.Errors["form"] = "Tunnus on estetty." - a.render(w, r, http.StatusForbidden, "login.html", page{Title: "Kirjaudu", Data: form}) + a.render(w, r, http.StatusForbidden, "login.html", page{Title: "Kirjaudu", Narrow: true, Data: form}) return } @@ -219,7 +219,7 @@ func (a *app) logout(w http.ResponseWriter, r *http.Request) { func (a *app) registerPage(w http.ResponseWriter, r *http.Request) { a.render(w, r, http.StatusOK, "register.html", - page{Title: "Liity", Data: authForm{Code: r.URL.Query().Get("code")}}) + page{Title: "Liity", Narrow: true, Data: authForm{Code: r.URL.Query().Get("code")}}) } // register spends the invite only when the account is actually created: both statements are in one @@ -248,7 +248,7 @@ func (a *app) register(w http.ResponseWriter, r *http.Request) { form.Errors["code"] = "Kutsukoodi on pakollinen." } if len(form.Errors) > 0 { - a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Data: form}) + a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Narrow: true, Data: form}) return } @@ -273,7 +273,7 @@ func (a *app) register(w http.ResponseWriter, r *http.Request) { form.Code).Scan(&inviteID) if errors.Is(err, pgx.ErrNoRows) { form.Errors["code"] = "Kutsukoodi ei kelpaa." - a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Data: form}) + a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Narrow: true, Data: form}) return } else if err != nil { slog.Error("burn invite", "ctx", "invites", "error", err) @@ -288,7 +288,7 @@ func (a *app) register(w http.ResponseWriter, r *http.Request) { if isUnique(err) { // Rolls back, so the invite is still valid. form.Errors["email"] = "Sähköpostiosoite on jo käytössä." - a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Data: form}) + a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Narrow: true, Data: form}) return } else if err != nil { slog.Error("create user", "ctx", "auth", "error", err) diff --git a/docs/decisions.md b/docs/decisions.md index 93c1175..eafdf2e 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -162,6 +162,13 @@ says so. 40. **`main` is release code, `dev` is development.** Work lands on `dev` and reaches `main` by merge at release, so `main` is always a list of things that shipped. Nightly builds, if any, come off `dev`. +42. **The theme handoff is implemented as CSS custom properties, not a Tailwind config.** Its + palette, spacing, shadows, motion and component shapes are followed as written; the parts that + assumed Tailwind, Pico or cover artwork are adapted rather than dropped, and each adaptation is + listed in [theme.md](./theme.md). Oswald's phantom weight 900 resolved to 700 — loading a weight + you do not have is what made the brand render differently per platform. The custom audio player + stays deferred: `color-scheme: dark` makes the native control fit the palette, which was the + actual complaint. 41. **No password minimum; rate limit logins instead.** A length policy protects against guessing, and guessing is better answered directly: 10 failures per email in 15 minutes, then a 15-minute lockout, cleared by a correct password. The floor was rejected because typing an 8-character diff --git a/docs/later.md b/docs/later.md index 3034793..47c7b72 100644 --- a/docs/later.md +++ b/docs/later.md @@ -130,6 +130,27 @@ Also here: pruning the count-based leaderboards once the queue has drained and t --- +## Review form as a mixer channel + +Idea for the UI polish pass, not now: put the score slider and the review textarea **on one row**, +with the slider **vertical** like a channel fader on a mixing desk. The score stops being a form +field and becomes the instrument the app is actually about, and the two things you do at once — +decide a number, write why — stop being stacked a screenful apart. + +Notes for whoever builds it: + +- A vertical `` is native now: `writing-mode: vertical-lr; direction: rtl` gives + bottom-to-top travel with no JS and no custom widget, so keyboard support and the value stay free. +- Keep the live `` — on a fader it wants to sit at the top of the track, reading like a + channel's gain display. +- The row needs a mobile answer: below ~640px, either keep the fader and shrink the textarea beside + it, or fall back to the current stacked layout. A short vertical fader is worse than a horizontal + one, so measure before choosing. +- Tick marks along the track (1 / 25 / 50 / 75 / 100) replace today's `.scorescale` row, and are + what make it read as equipment rather than decoration. + +--- + ## Filters on the browse list `/songs` is newest-first with no filters. Once there are a couple of hundred songs, "which ones diff --git a/docs/theme.md b/docs/theme.md new file mode 100644 index 0000000..ea77bdf --- /dev/null +++ b/docs/theme.md @@ -0,0 +1,53 @@ +# Theme + +Dark-only. Rock/metal club poster, not SaaS dashboard: near-black surfaces, warm bronze/amber +accents, condensed uppercase display type, one-tone-lighter surfaces instead of borders everywhere. +Restrained motion — 150 ms, one easing curve, no bounce. **There is no light theme and none is +wanted.** + +The tokens themselves live in [`static/style.css`](../static/style.css) as CSS custom properties, and +that file is the source of truth. This page records the decisions behind them and the places the +implementation deliberately differs from the theme handoff it came from. + +## Rules + +- **Nothing outside `:root` invents a value.** No colour, spacing step, radius or duration appears in + a rule unless it is declared as a token first. Six spacing steps (4–32 px), one radius (4 px, plus + 6 px for toasts and a pill), one duration, one curve. +- **Headings step downward in brightness with level** — h1 lightest gold, h3 the primary bronze. +- **Status colours are desaturated on purpose.** A pure red error would break the palette. +- **`color-scheme: dark`** is set on `:root`, which is what keeps the native `