v0.16.2 PGN site, web client panel update
test / test (push) Canceled after 0s
test / e2e (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-10-20 01:39:53 -04:00
1 parent 0461020c46
commit a012d07cd8
12 files changed
+84 -45

No files matched your search

+1 -1
View File
@@ -215,7 +215,7 @@ func runPGN(args []string) error {
if err != nil { if err != nil {
return fmt.Errorf("load game %s: %w", id, err) return fmt.Errorf("load game %s: %w", id, err)
} }
pgn, err := replay.BuildPGN(record, moves, *ply) pgn, err := replay.BuildPGN(record, moves, *ply, "")
if err != nil { if err != nil {
return err return err
} }
+12 -2
View File
@@ -10,6 +10,7 @@ import (
"fmt" "fmt"
"log" "log"
"log/slog" "log/slog"
"net/url"
"os" "os"
"os/signal" "os/signal"
"syscall" "syscall"
@@ -62,14 +63,15 @@ func main() {
// Password recovery by email: a transport (SMTP relay, or a directory // Password recovery by email: a transport (SMTP relay, or a directory
// of .eml files for development) and the web client's address, which // of .eml files for development) and the web client's address, which
// the emailed link opens. Without a transport, recovery is off. // the emailed link opens. Without a transport, recovery is off. The
// address's host also names this server in exported PGN (Site).
smtpHost = flag.String("smtp-host", "", "SMTP relay for password-reset email (recovery is off when empty)") smtpHost = flag.String("smtp-host", "", "SMTP relay for password-reset email (recovery is off when empty)")
smtpPort = flag.Int("smtp-port", 25, "SMTP relay port") smtpPort = flag.Int("smtp-port", 25, "SMTP relay port")
smtpUser = flag.String("smtp-user", "", "SMTP user name (optional; the password comes from -smtp-password-file)") smtpUser = flag.String("smtp-user", "", "SMTP user name (optional; the password comes from -smtp-password-file)")
smtpPassFile = flag.String("smtp-password-file", "", "File holding the SMTP password, mode 0600") smtpPassFile = flag.String("smtp-password-file", "", "File holding the SMTP password, mode 0600")
mailFrom = flag.String("mail-from", "", "Sender address of server email, e.g. \"Chess <chess@example.com>\"") mailFrom = flag.String("mail-from", "", "Sender address of server email, e.g. \"Chess <chess@example.com>\"")
mailDir = flag.String("mail-dir", "", "Write email to .eml files in this directory instead of sending (development)") mailDir = flag.String("mail-dir", "", "Write email to .eml files in this directory instead of sending (development)")
publicURL = flag.String("public-url", "", "Web client address that emailed links open, e.g. https://example.com/chess/web-client/") publicURL = flag.String("public-url", "", "Web client address: emailed links open it, and its host is the Site of exported games, e.g. https://example.com/chess/web-client/")
// Web UI server flags // Web UI server flags
serve = flag.Bool("serve", false, "Enable web UI server") serve = flag.Bool("serve", false, "Enable web UI server")
@@ -178,6 +180,14 @@ func main() {
} }
svc.SetIntegrityMode(integrity) svc.SetIntegrityMode(integrity)
if *publicURL != "" {
u, err := url.Parse(*publicURL)
if err != nil || u.Scheme == "" || u.Host == "" {
log.Fatalf("Invalid -public-url %q: must be absolute (https://host/chess/web-client/)", *publicURL)
}
svc.SetSite(u.Hostname())
}
// Password recovery needs mail and the client's public address. // Password recovery needs mail and the client's public address.
sender, err := mail.Config{ sender, err := mail.Config{
SMTPHost: *smtpHost, SMTPPort: *smtpPort, SMTPUser: *smtpUser, SMTPPasswordFile: *smtpPassFile, SMTPHost: *smtpHost, SMTPPort: *smtpPort, SMTPUser: *smtpUser, SMTPPasswordFile: *smtpPassFile,
+10 -6
View File
@@ -18,8 +18,9 @@
# Default: ${chessd_home}/jwt.key # Default: ${chessd_home}/jwt.key
# chessd_trusted_proxies: Reverse-proxy address(es) whose X-Real-IP header is # chessd_trusted_proxies: Reverse-proxy address(es) whose X-Real-IP header is
# trusted as the client address. Default: none # trusted as the client address. Default: none
# chessd_public_url: The web client's public address, which emailed links # chessd_public_url: The web client's public address: emailed links open
# open. Default: none # it, and its host is the Site of exported PGN.
# Default: none
# chessd_smtp_host: SMTP relay for password-reset email (the mail jail). # chessd_smtp_host: SMTP relay for password-reset email (the mail jail).
# Unset leaves password recovery off. Default: none # Unset leaves password recovery off. Default: none
# chessd_smtp_port: Relay port. Default: 25 # chessd_smtp_port: Relay port. Default: 25
@@ -33,8 +34,8 @@
# Add the following to /etc/rc.conf to enable: # Add the following to /etc/rc.conf to enable:
# chessd_enable="YES" # chessd_enable="YES"
# chessd_trusted_proxies="<address nginx connects from>" # chessd_trusted_proxies="<address nginx connects from>"
# and, for password recovery by email, once the mail jail relays for this one:
# chessd_public_url="https://<site>/chess/web-client/" # chessd_public_url="https://<site>/chess/web-client/"
# and, for password recovery by email, once the mail jail relays for this one:
# chessd_smtp_host="<mail jail address>" # chessd_smtp_host="<mail jail address>"
# chessd_mail_from="Chess <chess@<site>>" # chessd_mail_from="Chess <chess@<site>>"
@@ -105,11 +106,14 @@ chessd_start()
if [ -n "${chessd_trusted_proxies}" ]; then if [ -n "${chessd_trusted_proxies}" ]; then
set -- "$@" -trusted-proxies "${chessd_trusted_proxies}" set -- "$@" -trusted-proxies "${chessd_trusted_proxies}"
fi fi
# Password recovery by email: relay, sender, and the client address the if [ -n "${chessd_public_url}" ]; then
# link opens. The server refuses a relay without the other two. set -- "$@" -public-url "${chessd_public_url}"
fi
# Password recovery by email: relay and sender; the link opens the public
# URL. The server refuses a relay without both and the URL.
if [ -n "${chessd_smtp_host}" ]; then if [ -n "${chessd_smtp_host}" ]; then
set -- "$@" -smtp-host "${chessd_smtp_host}" -smtp-port "${chessd_smtp_port}" \ set -- "$@" -smtp-host "${chessd_smtp_host}" -smtp-port "${chessd_smtp_port}" \
-mail-from "${chessd_mail_from}" -public-url "${chessd_public_url}" -mail-from "${chessd_mail_from}"
if [ -n "${chessd_smtp_user}" ]; then if [ -n "${chessd_smtp_user}" ]; then
set -- "$@" -smtp-user "${chessd_smtp_user}" set -- "$@" -smtp-user "${chessd_smtp_user}"
secret="${chessd_smtp_password_file:-${chessd_home}/smtp.secret}" secret="${chessd_smtp_password_file:-${chessd_home}/smtp.secret}"
+3 -2
View File
@@ -343,11 +343,12 @@ Same visibility, `ETag`, and error responses as the history. `ply` (optional,
0 up to the number of stored plies) exports only the first N plies; the 0 up to the number of stored plies) exports only the first N plies; the
result is then `*`, the `Termination` tag is left out, and the filename ends result is then `*`, the `Termination` tag is left out, and the filename ends
`-ply<N>.pgn`. A `fetch` of the URL returns the text, so clients can copy it `-ply<N>.pgn`. A `fetch` of the URL returns the text, so clients can copy it
as well as download it. as well as download it. `Site` is the host of the server's `-public-url` (`?`
without one); an imported game keeps its own.
``` ```
[Event "Casual game"] [Event "Casual game"]
[Site "?"] [Site "example.com"]
[Date "2026.09.27"] [Date "2026.09.27"]
[Round "-"] [Round "-"]
[White "alice"] [White "alice"]
+1 -1
View File
@@ -120,7 +120,7 @@ file is kept next to it with a timestamp).
| `CHESSD_DSN` | `"host=<socket dir> dbname=chess"` | Database over the socket as the OS account | | `CHESSD_DSN` | `"host=<socket dir> dbname=chess"` | Database over the socket as the OS account |
| `CHESSD_JWT_KEY` | `/var/lib/chessd/jwt.key` | Stable signing key | | `CHESSD_JWT_KEY` | `/var/lib/chessd/jwt.key` | Stable signing key |
| `CHESSD_TRUSTED_PROXIES` | `TRUSTED_PROXIES` | Comma-separated proxy addresses | | `CHESSD_TRUSTED_PROXIES` | `TRUSTED_PROXIES` | Comma-separated proxy addresses |
| `CHESSD_FLAGS` | empty | Other server flags, e.g. `-max-users 500 -db-cleanup report`, or password recovery by email: `-smtp-host <relay> -mail-from "Chess <chess@example.com>" -public-url https://example.com/chess/web-client/` (see [deployment.md](deployment.md#password-recovery-by-email); the sandboxed unit can read a password file only under `/var/lib/chessd`) | | `CHESSD_FLAGS` | empty | Other server flags, e.g. `-max-users 500 -db-cleanup report -public-url https://example.com/chess/web-client/` (the URL's host is the `Site` of exported PGN), or password recovery by email, which also needs `-public-url`: `-smtp-host <relay> -mail-from "Chess <chess@example.com>"` (see [deployment.md](deployment.md#password-recovery-by-email); the sandboxed unit can read a password file only under `/var/lib/chessd`) |
Server flags worth knowing: `-max-users` (registration cap, default 100), Server flags worth knowing: `-max-users` (registration cap, default 100),
`-anonymous-game-ttl` (default `24h`), `-finished-game-ttl` (default `1h`), `-anonymous-game-ttl` (default `24h`), `-finished-game-ttl` (default `1h`),
+2 -2
View File
@@ -154,7 +154,7 @@ as `postgres`, list them with `\dt chess.*` and name them as `chess.games`.
| `chessd_dsn` | `postgres:///chess?host=/tmp` | Database `chess` over the socket as the OS user | | `chessd_dsn` | `postgres:///chess?host=/tmp` | Database `chess` over the socket as the OS user |
| `chessd_jwt_key` | `${chessd_home}/jwt.key` | JWT signing key; created (0600) on first start if missing | | `chessd_jwt_key` | `${chessd_home}/jwt.key` | JWT signing key; created (0600) on first start if missing |
| `chessd_trusted_proxies` | empty | Proxy address(es) whose `X-Real-IP` is trusted | | `chessd_trusted_proxies` | empty | Proxy address(es) whose `X-Real-IP` is trusted |
| `chessd_public_url` | empty | The web client's public address, which emailed reset links open (`https://<site>/chess/web-client/`) | | `chessd_public_url` | empty | The web client's public address (`https://<site>/chess/web-client/`): emailed reset links open it, and its host is the `Site` of exported PGN |
| `chessd_smtp_host`, `chessd_smtp_port` | empty, `25` | SMTP relay for password-reset email; unset leaves recovery off | | `chessd_smtp_host`, `chessd_smtp_port` | empty, `25` | SMTP relay for password-reset email; unset leaves recovery off |
| `chessd_smtp_user`, `chessd_smtp_password_file` | empty, `${chessd_home}/smtp.secret` if present | Relay credentials, when the relay authenticates this host; the file is mode 0600 | | `chessd_smtp_user`, `chessd_smtp_password_file` | empty, `${chessd_home}/smtp.secret` if present | Relay credentials, when the relay authenticates this host; the file is mode 0600 |
| `chessd_mail_from` | empty | Sender address, e.g. `Chess <chess@<site>>` | | `chessd_mail_from` | empty | Sender address, e.g. `Chess <chess@<site>>` |
@@ -182,7 +182,7 @@ Server flags:
| `-smtp-user`, `-smtp-password-file` | | Relay credentials, optional; the file is mode 0600 and only sent over TLS | | `-smtp-user`, `-smtp-password-file` | | Relay credentials, optional; the file is mode 0600 and only sent over TLS |
| `-mail-from` | | Sender address of server email | | `-mail-from` | | Sender address of server email |
| `-mail-dir` | | Development: write email to `.eml` files here instead of a relay | | `-mail-dir` | | Development: write email to `.eml` files here instead of a relay |
| `-public-url` | | The web client's address that emailed links open; required with a transport | | `-public-url` | | The web client's address: emailed links open it, and its host is the `Site` of exported PGN; required with a transport |
Administrative CLI commands run as `chess` so peer authentication selects the Administrative CLI commands run as `chess` so peer authentication selects the
right role; root may use `su -m` although the account has no shell: right role; root may use `su -m` although the account has no shell:
+1 -1
View File
@@ -40,7 +40,7 @@ go build ./cmd/chess-client-cli
- `-web-api-url`: Browser-visible API origin for the embedded web client, set on its script element as `data-api`; useful when its public origin differs from the listen address - `-web-api-url`: Browser-visible API origin for the embedded web client, set on its script element as `data-api`; useful when its public origin differs from the listen address
- `-smtp-host`, `-smtp-port`, `-smtp-user`, `-smtp-password-file`, `-mail-from`: SMTP relay for password-reset email; without a transport, recovery is off - `-smtp-host`, `-smtp-port`, `-smtp-user`, `-smtp-password-file`, `-mail-from`: SMTP relay for password-reset email; without a transport, recovery is off
- `-mail-dir`: Development: write email to `.eml` files in this directory instead of sending - `-mail-dir`: Development: write email to `.eml` files in this directory instead of sending
- `-public-url`: The web client's address that emailed links open (required with a transport) - `-public-url`: The web client's address: emailed links open it, and its host is the `Site` of exported PGN (required with a transport)
### Modes ### Modes
```bash ```bash
+4 -3
View File
@@ -52,8 +52,9 @@ type PGN struct {
// BuildPGN exports a stored game. ply < 0 exports every move; otherwise the // BuildPGN exports a stored game. ply < 0 exports every move; otherwise the
// first ply moves, with result "*" and no termination when that stops short // first ply moves, with result "*" and no termination when that stops short
// of the end of the line. // of the end of the line. site is the Site tag of a game played on this
func BuildPGN(record *storage.GameRecord, moves []storage.MoveRecord, ply int) (*PGN, error) { // server (PGN's "?" when empty); an imported game keeps its own.
func BuildPGN(record *storage.GameRecord, moves []storage.MoveRecord, ply int, site string) (*PGN, error) {
if ply > len(moves) { if ply > len(moves) {
return nil, fmt.Errorf("%w: game has %d plies", ErrPlyOutOfRange, len(moves)) return nil, fmt.Errorf("%w: game has %d plies", ErrPlyOutOfRange, len(moves))
} }
@@ -75,7 +76,7 @@ func BuildPGN(record *storage.GameRecord, moves []storage.MoveRecord, ply int) (
} }
} }
start := record.StartTimeUTC.UTC() start := record.StartTimeUTC.UTC()
event, site, round := "Casual game", "", "" event, round := "Casual game", ""
if record.ImportedBy != "" { if record.ImportedBy != "" {
event, site, round = record.Event, record.Site, record.Round event, site, round = record.Event, record.Site, record.Round
if event == "" { if event == "" {
+25 -5
View File
@@ -129,7 +129,7 @@ func TestBuildPGN(t *testing.T) {
rec := record(chess.StartFEN, "white_wins", "resignation") rec := record(chess.StartFEN, "white_wins", "resignation")
rec.BlackType, rec.BlackLevel, rec.WhiteType, rec.WhiteName = 2, 4, 1, "alice" rec.BlackType, rec.BlackLevel, rec.WhiteType, rec.WhiteName = 2, 4, 1, "alice"
pgn, err := BuildPGN(rec, moves, -1) pgn, err := BuildPGN(rec, moves, -1, "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -143,25 +143,45 @@ func TestBuildPGN(t *testing.T) {
t.Errorf("filename %q", pgn.Filename) t.Errorf("filename %q", pgn.Filename)
} }
partial, err := BuildPGN(rec, moves, 1) partial, err := BuildPGN(rec, moves, 1, "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if !strings.HasSuffix(partial.Text, "\n\n1. e4 *\n") || partial.Filename != "chess-20260928-68007fd1-ply1.pgn" { if !strings.HasSuffix(partial.Text, "\n\n1. e4 *\n") || partial.Filename != "chess-20260928-68007fd1-ply1.pgn" {
t.Errorf("partial %q:\n%s", partial.Filename, partial.Text) t.Errorf("partial %q:\n%s", partial.Filename, partial.Text)
} }
if _, err := BuildPGN(rec, moves, 3); err == nil { if _, err := BuildPGN(rec, moves, 3, ""); err == nil {
t.Error("ply beyond the line accepted") t.Error("ply beyond the line accepted")
} }
} }
// A game played here names the server's site; an imported game keeps its own.
func TestBuildPGNSite(t *testing.T) {
moves := stored(t, chess.StartFEN, "e2e4")
rec := record(chess.StartFEN, "", "")
for _, tc := range []struct{ importedBy, recordSite, site, want string }{
{"", "", "lixen.com", "lixen.com"},
{"", "", "", "?"},
{"alice", "Linares ESP", "lixen.com", "Linares ESP"},
} {
rec.ImportedBy, rec.Site = tc.importedBy, tc.recordSite
pgn, err := BuildPGN(rec, moves, -1, tc.site)
if err != nil {
t.Fatal(err)
}
if want := "[Site \"" + tc.want + "\"]\n"; !strings.Contains(pgn.Text, want) {
t.Errorf("site %q, imported by %q: PGN lacks %q:\n%s", tc.site, tc.importedBy, want, pgn.Text)
}
}
}
// A resignation taken back by an undo is noted before the live result. // A resignation taken back by an undo is noted before the live result.
func TestBuildPGNNotesContinuedConcession(t *testing.T) { func TestBuildPGNNotesContinuedConcession(t *testing.T) {
moves := stored(t, chess.StartFEN, "e2e4", "e7e5", "d2d4") moves := stored(t, chess.StartFEN, "e2e4", "e7e5", "d2d4")
rec := record(chess.StartFEN, "", "") rec := record(chess.StartFEN, "", "")
rec.ConcessionResult, rec.ConcessionTermination, rec.ConcessionPly = "black_wins", "resignation", 2 rec.ConcessionResult, rec.ConcessionTermination, rec.ConcessionPly = "black_wins", "resignation", 2
pgn, err := BuildPGN(rec, moves, -1) pgn, err := BuildPGN(rec, moves, -1, "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -171,7 +191,7 @@ func TestBuildPGNNotesContinuedConcession(t *testing.T) {
// The concession that is still the result needs no note. // The concession that is still the result needs no note.
rec.Result, rec.Termination, rec.ConcessionPly = "black_wins", "resignation", 3 rec.Result, rec.Termination, rec.ConcessionPly = "black_wins", "resignation", 3
if pgn, _ = BuildPGN(rec, moves, -1); strings.Contains(pgn.Text, "continued") { if pgn, _ = BuildPGN(rec, moves, -1, ""); strings.Contains(pgn.Text, "continued") {
t.Errorf("standing resignation noted as continued:\n%s", pgn.Text) t.Errorf("standing resignation noted as continued:\n%s", pgn.Text)
} }
} }
+1 -1
View File
@@ -79,7 +79,7 @@ func (s *Service) GetGamePGN(gameID string, ply int) (*replay.PGN, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
pgn, err := replay.BuildPGN(record, moves, ply) pgn, err := replay.BuildPGN(record, moves, ply, s.site)
if errors.Is(err, replay.ErrNotation) { if errors.Is(err, replay.ErrNotation) {
slog.Warn("stored game cannot be exported as PGN", "game_id", gameID, "error", err) slog.Warn("stored game cannot be exported as PGN", "game_id", gameID, "error", err)
} }
+7
View File
@@ -43,6 +43,7 @@ type Service struct {
integrityCursor string // last game ID the integrity sweep verified; cleanup goroutine only integrityCursor string // last game ID the integrity sweep verified; cleanup goroutine only
mail *mailer // password recovery; nil when no transport is configured mail *mailer // password recovery; nil when no transport is configured
site string // Site tag of exported games played here; empty is PGN's "?"
} }
// New creates a service with optional storage. jwtSecret signs session tokens // New creates a service with optional storage. jwtSecret signs session tokens
@@ -93,6 +94,12 @@ func (s *Service) SetAnonymousGameTTL(ttl time.Duration) {
s.mu.Unlock() s.mu.Unlock()
} }
// SetSite names this server in the Site tag of the games it exports, from
// the web client's public address; call it before serving.
func (s *Service) SetSite(site string) {
s.site = site
}
// SetMaxUsers caps the number of accounts public registration may create; // SetMaxUsers caps the number of accounts public registration may create;
// zero removes the cap. Accounts created through the CLI are not limited. // zero removes the cap. Accounts created through the CLI are not limited.
func (s *Service) SetMaxUsers(limit int) { func (s *Service) SetMaxUsers(limit int) {
@@ -1,12 +1,8 @@
/* Chess web client. Dark theme matching lixen.com. The board colours and /* Chess web client. Dark theme matching lixen.com. The board colours and
piece glyph treatment are fixed (see pieceMap in app.js); everything else is piece glyph treatment are fixed (see pieceMap in app.js); everything else is
open. Styles live here: the host's CSP has style-src 'unsafe-inline', but open. State is classes and data attributes, never inline style. Two layouts
state is expressed through classes and data attributes, never inline style. share the markup: board beside the panel from 700px up, board over the
panel below; the host's embed frame switches its height at that width. */
Two layouts share the markup: board beside the panel from 979px up, and
board over the panel below (the host's embed frame switches its height at
the same width). Both must fit the host's frame: 30rem tall side by side,
46rem (capped at 80svh) stacked. */
*, *::before, *::after { box-sizing: border-box; } *, *::before, *::after { box-sizing: border-box; }
@@ -45,16 +41,14 @@
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace; --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
/* Side-by-side sizes: the board edge includes its coordinate margin. The /* Side by side, the board (its edge holds the coordinates) takes the
panel takes a quarter of the width between 260px and 420px; the board height under the top bar, or the width the panel's minimum leaves, and
takes everything else the window leaves beside it and under the top the panel takes the rest of the row. 3.5rem and 2rem are the app's
bar, so the margins give way first, the board second, and the panel padding and gaps (8px each side, a gap of up to 2rem) plus 8px of slack;
is never pushed out of view. 3.5rem and 2rem are the app's padding and the host's embed sizes its frame to this formula. */
gaps (8px each side, a gap of up to 2rem beside the board) plus 8px of
slack; the host's embed sizes its frame to this formula. */
--topbar-height: 2.5rem; --topbar-height: 2.5rem;
--panel-width: clamp(260px, 24vw, 420px); --panel-min: 260px;
--board-edge: clamp(280px, min(100vw - var(--panel-width) - 3.5rem, 100vh - var(--topbar-height) - 2rem), 840px); --board-edge: clamp(280px, min(100vw - var(--panel-min) - 3.5rem, 100vh - var(--topbar-height) - 2rem), 840px);
--board-pad: clamp(14px, 1.04vw, 20px); --board-pad: clamp(14px, 1.04vw, 20px);
} }
@@ -399,9 +393,12 @@ body[data-mode="replay"] .square, .square.inert { cursor: default; }
/* --- Panel ----------------------------------------------------------- */ /* --- Panel ----------------------------------------------------------- */
/* Up to 48rem: a full window on a large screen centres the pair rather than
stretching the controls across it. */
.panel { .panel {
flex: none; flex: 1 1 0;
width: var(--panel-width); min-width: var(--panel-min);
max-width: 48rem;
height: var(--board-edge); height: var(--board-edge);
background: var(--surface); background: var(--surface);
border: 1px solid var(--line); border: 1px solid var(--line);
@@ -1104,9 +1101,6 @@ input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outlin
than the controls dropping below the fold. */ than the controls dropping below the fold. */
--board-edge: max(240px, min(100vw, calc(100svh - 15.75rem))); --board-edge: max(240px, min(100vw, calc(100svh - 15.75rem)));
--board-pad: 14px; --board-pad: 14px;
--panel-width: 100%;
--panel-height: auto;
--topbar-height: 2.5rem;
} }
body { overflow-y: auto; overflow-x: hidden; } body { overflow-y: auto; overflow-x: hidden; }
@@ -1154,6 +1148,8 @@ input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outlin
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0;
max-width: none;
min-height: 0; min-height: 0;
height: auto; height: auto;
border-radius: 0; border-radius: 0;