Compare commits

...
13 Commits
Author SHA1 Message Date
ddidderr 8f151e38b4 docs(launcher): add Call to Play specification and design reference
Extend the launcher design specification and prototype reference with the
"Call to Play" multiplayer coordination feature and clean up top-bar chrome.

Key additions and changes:
- Spec & Roadmap: Document Call to Play mechanics in SPEC.md, including Play
  Now / Scheduled call flavors, 15-minute check-in windows, top-bar button
  with active count badge, quick-bar ticker stack above grid, overlay modal,
  and per-call group chat. Update design/README.md.
- Components & Logic: Add calltoplay.jsx and ctp-chat.jsx components for call
  creation, status progression, and chat. Extend data.jsx with a mock peer
  roster (PEERS) and helper functions for peer install count tracking.
- Top-bar Cleanup: Move game-folder configuration from top bar into Settings ->
  Library, freeing top-bar space for the Call to Play action button.
- Styling & Layout: Add CTP quick-bar, ticker, badge, card, and chat styles to
  styles.css, and integrate CTP components into launcher.jsx, components.jsx,
  and SoftLAN Launcher.html.

Test Plan:
- `git diff --cached --check` -- passed (no trailing whitespace or conflict markers)
- `cargo check --workspace` -- passed cleanly
- Manual review of staged diff across 9 design/launcher files -- confirmed clean staging
2026-07-21 20:00:07 +02:00
ddidderr 8d54e6e954 [deps] cargo update
Adding   syn                 v3.0.2
Updating anyhow              v1.0.103 -> v1.0.104
Updating bytemuck            v1.25.1  -> v1.25.2
Updating cc                  v1.2.67  -> v1.3.0
Updating fastrand            v2.4.1   -> v2.5.0
Updating futures-channel     v0.3.32  -> v0.3.33
Updating futures-core        v0.3.32  -> v0.3.33
Updating futures-executor    v0.3.32  -> v0.3.33
Updating futures-io          v0.3.32  -> v0.3.33
Updating futures-macro       v0.3.32  -> v0.3.33
Updating futures-sink        v0.3.32  -> v0.3.33
Updating futures-task        v0.3.32  -> v0.3.33
Updating futures-util        v0.3.32  -> v0.3.33
Updating futures             v0.3.32  -> v0.3.33
Updating glob                v0.3.3   -> v0.3.4
Updating hyper               v1.10.1  -> v1.11.0
Updating libc                v0.2.186 -> v0.2.188
Updating proc-macro2         v1.0.106 -> v1.0.107
Updating quote               v1.0.46  -> v1.0.47
Updating ref-cast-impl       v1.0.25  -> v1.0.26
Updating ref-cast            v1.0.25  -> v1.0.26
Updating serde_core          v1.0.228 -> v1.0.229
Updating serde_derive        v1.0.228 -> v1.0.229
Updating serde_json          v1.0.150 -> v1.0.151
Updating serde_repr          v0.1.20  -> v0.1.21
Updating serde               v1.0.228 -> v1.0.229
Updating tauri-plugin-dialog v2.7.1   -> v2.7.2
Updating tauri-plugin-store  v2.4.3   -> v2.4.4
Updating thiserror-impl      v2.0.18  -> v2.0.19
Updating thiserror           v2.0.18  -> v2.0.19
Updating time-macros         v0.2.31  -> v0.2.32
Updating time                v0.3.53  -> v0.3.54
Updating tokio-util          v0.7.18  -> v0.7.19
Updating tokio               v1.53.0  -> v1.53.1
Updating zerocopy-derive     v0.8.54  -> v0.8.55
Updating zerocopy            v0.8.54  -> v0.8.55
2026-07-21 19:54:03 +02:00
ddidderr fa8bd86281 [deps] cargo update
Updating aws-lc-rs      v1.17.1             -> v1.17.3
Updating aws-lc-sys     v0.42.0             -> v0.43.0
Updating bitflags       v2.13.0             -> v2.13.1
Updating http-body-util v0.1.3              -> v0.1.4
Updating http-body      v1.0.1              -> v1.1.0
Updating mdns-sd        v0.20.1             -> v0.20.2
Updating mio            v1.2.1              -> v1.2.2
Updating regex-automata v0.4.15             -> v0.4.16
Updating regex          v1.13.0             -> v1.13.1
Updating rustls         v0.23.41            -> v0.23.42
Updating simd-adler32   v0.3.9              -> v0.3.10
Updating socket2        v0.6.4              -> v0.6.5
Updating spin           v0.9.8              -> v0.9.9
Updating syn            v2.0.118            -> v2.0.119
Updating tokio-macros   v2.7.0              -> v2.7.1
Updating tokio          v1.52.3             -> v1.53.0
Updating toml_edit      v0.25.12+spec-1.1.0 -> v0.25.13+spec-1.1.0
Updating toml           v1.1.2+spec-1.1.0   -> v1.1.3+spec-1.1.0
Updating toml_writer    v1.1.1+spec-1.1.0   -> v1.1.2+spec-1.1.0
Updating uuid           v1.23.4             -> v1.24.0
Updating winnow         v1.0.3              -> v1.0.4
Updating zmij           v1.0.21             -> v1.0.23
2026-07-18 01:16:56 +02:00
ddidderr 6379e97db4 [deps] cargo update
Removing flume                      v0.11.1
Removing pathdiff                   v0.2.3
Removing utf-8                      v0.7.6
Updating anyhow                     v1.0.102 -> v1.0.103
Updating aws-lc-rs                  v1.17.0  -> v1.17.1
Updating aws-lc-sys                 v0.41.0  -> v0.42.0
Updating bytemuck                   v1.25.0  -> v1.25.1
Updating bytes                      v1.12.0  -> v1.12.1
Updating camino                     v1.2.3   -> v1.2.4
Updating cc                         v1.2.65  -> v1.2.67
Updating chacha20                   v0.10.0  -> v0.10.1
Updating crossbeam-channel          v0.5.15  -> v0.5.16
Updating crossbeam-queue            v0.3.12  -> v0.3.13
Updating crossbeam-utils            v0.8.21  -> v0.8.22
Updating dbus                       v0.9.11  -> v0.9.12
Updating embed-resource             v3.0.9   -> v3.0.11
Updating inotify-sys                v0.1.5   -> v0.1.8
Updating inotify                    v0.11.2  -> v0.11.4
Updating jobserver                  v0.1.34  -> v0.1.35
Updating js-sys                     v0.3.102 -> v0.3.103
Updating libredox                   v0.1.17  -> v0.1.18
Updating log                        v0.4.32  -> v0.4.33
Updating mdns-sd                    v0.20.0  -> v0.20.1
Updating memchr                     v2.8.2   -> v2.8.3
Updating open                       v5.3.5   -> v5.4.0
Updating plist                      v1.9.0   -> v1.10.0
Updating quick-xml                  v0.39.4  -> v0.41.0
Updating quote                      v1.0.45  -> v1.0.46
Updating rand                       v0.10.1  -> v0.10.2
Updating regex-automata             v0.4.14  -> v0.4.15
Updating regex                      v1.12.4  -> v1.13.0
Updating rustc-hash                 v2.1.2   -> v2.1.3
Updating rustls-pki-types           v1.14.1  -> v1.15.0
Updating rustls                     v0.23.40 -> v0.23.41
Updating rustversion                v1.0.22  -> v1.0.23
Updating s2n-codec                  v0.82.0  -> v0.83.0
Updating s2n-quic-core              v0.82.0  -> v0.83.0
Updating s2n-quic-crypto            v0.82.0  -> v0.83.0
Updating s2n-quic-platform          v0.82.0  -> v0.83.0
Updating s2n-quic-rustls            v0.82.0  -> v0.83.0
Updating s2n-quic-tls-default       v0.82.0  -> v0.83.0
Updating s2n-quic-tls               v0.82.0  -> v0.83.0
Updating s2n-quic-transport         v0.82.0  -> v0.83.0
Updating s2n-quic                   v1.82.0  -> v1.83.0
Updating s2n-tls-sys                v0.3.38  -> v0.3.40
Updating s2n-tls                    v0.3.38  -> v0.3.40
Updating socket-pktinfo             v0.3.2   -> v0.4.0
Updating tauri-runtime-wry          v2.11.3  -> v2.11.4
Updating tauri                      v2.11.3  -> v2.11.5
Updating tendril                    v0.5.0   -> v0.5.1
Updating thread_local               v1.1.9   -> v1.1.10
Updating time-macros                v0.2.29  -> v0.2.31
Updating time                       v0.3.49  -> v0.3.53
Updating tinyvec                    v1.11.0  -> v1.12.0
Updating uuid                       v1.23.3  -> v1.23.4
Updating wasm-bindgen-futures       v0.4.75  -> v0.4.76
Updating wasm-bindgen-macro-support v0.2.125 -> v0.2.126
Updating wasm-bindgen-macro         v0.2.125 -> v0.2.126
Updating wasm-bindgen-shared        v0.2.125 -> v0.2.126
Updating wasm-bindgen               v0.2.125 -> v0.2.126
Updating web-sys                    v0.3.102 -> v0.3.103
Updating zerocopy-derive            v0.8.52  -> v0.8.54
Updating zerocopy                   v0.8.52  -> v0.8.54
2026-07-12 17:12:11 +02:00
ddidderr d376983296 fix: terminate unrar sidecars when the launcher closes mid-install
Bug report: unrar.exe kept running after closing the launcher during a
game install. The orphaned process kept extracting in the background and
held file handles on the staging directory.

Root cause (regular install path): run_unrar_sidecar ran the unrar
sidecar via tauri-plugin-shell's Command::output(). That helper spawns
the process on a detached SharedChild OS thread and immediately drops the
CommandChild; there is no Drop impl that kills the process. On app exit
only shutdown_peer_runtime ran, and Windows does not cascade-kill child
processes, so closing the launcher left unrar running.

Fix:
- run_unrar_sidecar now uses .spawn() instead of .output(), keeping a
  killable CommandChild. It registers the child in a new
  LanSpreadState.active_unrar_children registry and an RAII
  UnrarChildGuard deregisters it on every return path. The CommandEvent
  stream is drained to reproduce the exact stdout/stderr (NEWLINE_BYTE
  is b'\n'), status code, success flag, and UnpackLogEntry the old
  .output() produced, so logging behavior is unchanged.
- kill_active_unrar_children() runs in the RunEvent::Exit handler before
  shutdown_peer_runtime, killing every in-progress unrar. Killing first
  also lets the install task unwind so the runtime stops promptly.

Two concurrency hazards were closed in the registry design:
- Children are keyed by a monotonic id, not pid. A pid key let a
  finishing install's guard deregister a different install's child after
  the OS recycled the pid, which could re-orphan a live child.
- A shutting_down latch lives in the registry under the same mutex as
  the kill sweep. The sweep is a one-shot drain, so a child registered
  after it (a task caught between spawn() and registration, or a later
  archive in a multi-archive install -- unpack_archives does not observe
  the shutdown token) would be missed. Registration now checks the latch
  under that mutex and kills the child immediately instead of inserting
  it. Since registration and the sweep serialize on one mutex, every
  interleaving kills the child.

Also hardened the streamed-install sender path (ExternalUnrarStream
Provider) with kill_on_drop(true) on its tokio unrar spawns, so a
dropped or aborted producer task cannot orphan unrar there either.

Known limitation: a hard force-kill or crash of the launcher (e.g. Task
Manager -> End Task) bypasses RunEvent::Exit and is not covered. Making
that bulletproof would require a Windows Job Object with
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; the reported "close the launcher"
case is fully fixed.

Test Plan:
- just clippy (pedantic, -D warnings): clean.
- just fmt: no changes.
- just test: all suites pass (incl. the 20 Tauri-lib unit tests).
- Manual (Windows): start a game install, close the launcher mid-extract,
  confirm no unrar.exe remains in Task Manager. Repeat with two
  concurrent installs and with a multi-archive game.
2026-06-22 07:07:45 +02:00
ddidderr 9d1cc771fc design: implement the actual SoftLAN wordmark 2026-06-21 21:27:54 +02:00
ddidderr 6415a4e517 design: updated design docs 2026-06-21 21:15:09 +02:00
ddidderr d9a6181ae2 design: seamless logo snake implemented 2026-06-21 20:49:30 +02:00
ddidderr 3d9d9cb450 design: seamless logo snake 2026-06-21 20:27:08 +02:00
ddidderr 14b30fe2da design: first impl of logo, still with glitch 2026-06-21 20:16:41 +02:00
ddidderr 2d53848e0c test(peer-cli): harden S1-S47 scenario suite against vacuous and flaky checks
An adversarial audit of the headless peer-to-peer scenario suite
(crates/lanspread-peer-cli/scripts/run_extended_scenarios.py, driven by
`just peer-cli-tests`) found assertions that passed even when the behavior
they claim to test was not happening, plus timing races and doc-vs-code
divergences. A full baseline run (S1-S47) passed beforehand, confirming these
were test-quality gaps, not peer regressions; the baseline output itself
exposed the worst offenders -- e.g. S14 chunk totals {128 MiB, 1 MiB} (a
two-chunk file whose "balanced within one chunk" check can never fail) and
S16/S18 serving the whole ~120 MiB alienswarm.eti from a single source, so
fanout and retry were never exercised.

Test-correctness fixes (a broken behavior could previously pass green):
- S18: the "no download-failed" check was dead -- it reused a LineWaiter
  already advanced past download-finished, so it scanned an empty tail.
  Replaced with assert_no_event_since over the whole window. Switched to a
  4*CHUNK_SIZE sparse archive so both peers get chunks; the test now proves the
  download SURVIVES a mid-download source kill (every byte delivered, survivor
  served part, no download-failed, clean diff). Retry-onto-survivor is the
  mechanism but is not asserted: the kill/serve race against `docker rm -f`
  cannot be forced, so asserting an exact split would be flaky.
- S7: the only check was a diff against byte-identical ggoo fixtures, so it was
  source-agnostic. Added assertions that the download committed exactly once,
  every chunk came from the validated two-peer set, both peers served, and no
  chunk was fetched twice.
- S14: enlarged to 4*CHUNK_SIZE so the balance check can fail under a 3+1
  imbalance; asserts an exact 2+2 split summing to the file size.
- S16: inflated the .eti to 2*CHUNK_SIZE so it fans out across both
  catalog-version peers (the stock 120 MiB fixture is a single chunk).
- S37: validate the throughput rate fields (positive, self-consistent
  mbit/mib == 8.388608, mib_per_s == bytes/duration), not just the byte count.
- S35: assert the source actually advertises the unknown game before checking
  it is filtered, so "absent" means "filtered" and not "never sent".
- S15: cross-check each peer's raw advertised eti_version via list-peers; the
  list-games eti_game_version is synthesized from the catalog and can only ever
  equal the asserted value.
- S2: poll for library convergence and verify the bidirectional exchange
  (bravo sees alpha's 3 games, not just alpha seeing bravo's 4).
- S12/S28: require the gating unit test to appear as "<name> ... ok" so an
  #[ignore]d (un-run) test no longer satisfies the check.
- S24/S25: assert the requested install=false final state.
- S34: assert exactly 21 coherent chunks (20 files + version.ini), 21 distinct
  paths, no duplicates, instead of a >= 21 floor.

Flake fixes:
- S19: force-kill the sole source right after download-begin on a 4*CHUNK_SIZE
  file and accept download-failed or download-peers-gone. The old graceful
  shutdown on a single-chunk file could let the transfer finish first, turning
  the expected failure into a download-finished. A chunk may complete before
  the kill lands, but the full transfer cannot, so the failure is deterministic.
- S26: use a large sparse source so the first operation is reliably still
  active when the duplicate request is issued (TOCTOU on active_operations);
  also assert the active operation == "Downloading".
- S11: drop the "listener address must change" assertion -- it tested the OS
  ephemeral-port allocator and could fail spuriously; keep the same-identity /
  no-duplicate invariant.

Coverage and determinism:
- S27: add handshake::tests::inbound_hello_from_self_is_ignored for the
  protocol-level self guard. The CLI scenario only exercises the CLI
  string-compare guard, which short-circuits before any network call, so the
  peer-crate guard had no test.
- find_fixture_game now iterates sorted(FIXTURES) so the ambiguous cnctw
  (fixture-bravo/multi/solid) resolves deterministically to fixture-bravo.

Reviewed and deliberately left as-is (documented in the run log): S20, S21,
S30, S32/S39/S44 absence checks, S42 IP-order precondition, S45.

PEER_CLI_SCENARIOS.md rows S2, S11, S14, S16, S18, S19, S27 are updated to
match the harness, and a dated run-log entry records the audit, the fixes, the
accepted items, and the live-run evidence.

Test Plan:
- `just peer-cli-tests` (rebuilds the image, runs S1-S47 in Docker): baseline
  passed; post-fix passed; a final run on the exact committed code passed
  47/47. Evidence: S14 {268435456, 268435456} balanced 2+2; S16 .eti split
  across B and C {134217728, 134217728}; S18 all 536870912 bytes delivered with
  no download-failed; S19 deterministic download-failed; S37 ~874 MiB/s.
- `just test` (incl. inbound_hello_from_self_is_ignored), `just clippy`
  (-D warnings, all-targets), and `just fmt` all pass.

Refs: PEER_CLI_SCENARIOS.md scenario matrix and 2026-06-21 run-log entry.
2026-06-21 10:23:45 +02:00
ddidderr 72fa61da65 feat(peer-cli): add just recipe for scenario matrix
Add a peer-cli-tests just recipe as the public entry point for the existing
extended scenario runner. The recipe depends on peer-cli-image, so Docker
uses its normal layer cache to decide whether the image needs rebuilding
before the Python harness runs.

This gives contributors one memorable command for the full S1-S47 matrix
while still allowing selected scenarios as positional arguments. The
underlying runner continues to own container topology and JSONL command/event
handling.

Test Plan:
- just --list
- just --dry-run peer-cli-tests S39 S40
- git diff --check

Refs: PEER_CLI_SCENARIOS.md
2026-06-20 20:45:31 +02:00
ddidderr b73b2b16c3 gitignore: add __pycache__ 2026-06-20 20:32:40 +02:00
56 changed files with 5480 additions and 1041 deletions
+1
View File
@@ -2,3 +2,4 @@
/.lanspread-peer-cli/
/game.db
/thumbnails/
__pycache__/
Generated
+312 -324
View File
@@ -49,9 +49,9 @@ dependencies = [
[[package]]
name = "anyhow"
version = "1.0.102"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "atk"
@@ -99,9 +99,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "aws-lc-rs"
version = "1.17.0"
version = "1.17.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00"
checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1"
dependencies = [
"aws-lc-sys",
"untrusted 0.7.1",
@@ -110,14 +110,15 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
version = "0.41.0"
version = "0.43.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4"
checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
"pkg-config",
]
[[package]]
@@ -155,9 +156,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.13.0"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
dependencies = [
"serde_core",
]
@@ -218,9 +219,9 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytemuck"
version = "1.25.0"
version = "1.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
[[package]]
name = "byteorder"
@@ -230,9 +231,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.12.0"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
dependencies = [
"serde",
]
@@ -243,7 +244,7 @@ version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"cairo-sys-rs",
"glib",
"libc",
@@ -264,9 +265,9 @@ dependencies = [
[[package]]
name = "camino"
version = "1.2.3"
version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ce8d3bd5823c7504d3f579f13e7b2f3da252fcb938c594d5680ee508bf846f"
checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0"
dependencies = [
"serde_core",
]
@@ -291,7 +292,7 @@ dependencies = [
"semver",
"serde",
"serde_json",
"thiserror 2.0.18",
"thiserror 2.0.19",
]
[[package]]
@@ -306,9 +307,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.2.65"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -351,9 +352,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chacha20"
version = "0.10.0"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
@@ -432,7 +433,7 @@ version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"core-foundation",
"core-graphics-types",
"foreign-types",
@@ -445,7 +446,7 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"core-foundation",
"libc",
]
@@ -494,27 +495,27 @@ dependencies = [
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
version = "0.5.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-queue"
version = "0.3.12"
version = "0.3.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115"
checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
[[package]]
name = "crypto-common"
@@ -546,7 +547,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
dependencies = [
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -596,7 +597,7 @@ dependencies = [
"proc-macro2",
"quote",
"strsim",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -607,14 +608,14 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
dependencies = [
"darling_core",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
name = "dbus"
version = "0.9.11"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73"
checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e"
dependencies = [
"libc",
"libdbus-sys",
@@ -648,7 +649,7 @@ dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -688,7 +689,7 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"block2",
"libc",
"objc2",
@@ -702,7 +703,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -725,7 +726,7 @@ checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -811,14 +812,14 @@ dependencies = [
[[package]]
name = "embed-resource"
version = "3.0.9"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb"
checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd"
dependencies = [
"cc",
"memchr",
"rustc_version",
"toml 1.1.2+spec-1.1.0",
"toml 1.1.3+spec-1.1.0",
"vswhom",
"winreg",
]
@@ -888,9 +889,9 @@ dependencies = [
[[package]]
name = "fastrand"
version = "2.4.1"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "fdeflate"
@@ -927,17 +928,6 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "flume"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
dependencies = [
"futures-core",
"futures-sink",
"spin",
]
[[package]]
name = "flume"
version = "0.12.0"
@@ -979,7 +969,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -1014,9 +1004,9 @@ dependencies = [
[[package]]
name = "futures"
version = "0.3.32"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218"
dependencies = [
"futures-channel",
"futures-core",
@@ -1029,9 +1019,9 @@ dependencies = [
[[package]]
name = "futures-channel"
version = "0.3.32"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
dependencies = [
"futures-core",
"futures-sink",
@@ -1039,15 +1029,15 @@ dependencies = [
[[package]]
name = "futures-core"
version = "0.3.32"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
[[package]]
name = "futures-executor"
version = "0.3.32"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458"
dependencies = [
"futures-core",
"futures-task",
@@ -1067,38 +1057,38 @@ dependencies = [
[[package]]
name = "futures-io"
version = "0.3.32"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
[[package]]
name = "futures-macro"
version = "0.3.32"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
name = "futures-sink"
version = "0.3.32"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
[[package]]
name = "futures-task"
version = "0.3.32"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
[[package]]
name = "futures-util"
version = "0.3.32"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
dependencies = [
"futures-channel",
"futures-core",
@@ -1314,7 +1304,7 @@ version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"futures-channel",
"futures-core",
"futures-executor",
@@ -1342,7 +1332,7 @@ dependencies = [
"proc-macro-error",
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -1357,9 +1347,9 @@ dependencies = [
[[package]]
name = "glob"
version = "0.3.3"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
[[package]]
name = "gobject-sys"
@@ -1421,7 +1411,7 @@ dependencies = [
"proc-macro-error",
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -1513,9 +1503,9 @@ dependencies = [
[[package]]
name = "http-body"
version = "1.0.1"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
dependencies = [
"bytes",
"http",
@@ -1523,9 +1513,9 @@ dependencies = [
[[package]]
name = "http-body-util"
version = "0.1.3"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2"
dependencies = [
"bytes",
"futures-core",
@@ -1542,9 +1532,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "hyper"
version = "1.10.1"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72"
dependencies = [
"atomic-waker",
"bytes",
@@ -1776,20 +1766,20 @@ dependencies = [
[[package]]
name = "inotify"
version = "0.11.2"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1"
checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"inotify-sys",
"libc",
]
[[package]]
name = "inotify-sys"
version = "0.1.5"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d"
dependencies = [
"libc",
]
@@ -1898,24 +1888,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
dependencies = [
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
name = "jobserver"
version = "0.1.34"
version = "0.1.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
dependencies = [
"getrandom 0.3.4",
"getrandom 0.4.3",
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.102"
version = "0.3.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31"
checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
dependencies = [
"cfg-if",
"futures-util",
@@ -1950,7 +1940,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"serde",
"unicode-segmentation",
]
@@ -1971,7 +1961,7 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"libc",
]
@@ -2119,9 +2109,9 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.186"
version = "0.2.188"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
checksum = "22053b6a34f84abc97f9129e61334f40174659a1b9bd18c970b83db6a9a6348b"
[[package]]
name = "libdbus-sys"
@@ -2159,9 +2149,9 @@ dependencies = [
[[package]]
name = "libredox"
version = "0.1.17"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652"
dependencies = [
"libc",
]
@@ -2200,9 +2190,9 @@ dependencies = [
[[package]]
name = "log"
version = "0.4.32"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "markup5ever"
@@ -2217,12 +2207,12 @@ dependencies = [
[[package]]
name = "mdns-sd"
version = "0.20.0"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "892f96f6d2ebe1ea641279f986ac52a2a6bac71e8f743bb258315cfe2bd7e88e"
checksum = "f18d8ec9d1869796fb2910d95f4d957072df0b6a22e247a1d760d8b4c805e17a"
dependencies = [
"fastrand",
"flume 0.11.1",
"flume",
"if-addrs",
"log",
"mio",
@@ -2232,9 +2222,9 @@ dependencies = [
[[package]]
name = "memchr"
version = "2.8.2"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "memoffset"
@@ -2272,9 +2262,9 @@ dependencies = [
[[package]]
name = "mio"
version = "1.2.1"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
dependencies = [
"libc",
"log",
@@ -2299,7 +2289,7 @@ dependencies = [
"once_cell",
"png 0.18.1",
"serde",
"thiserror 2.0.18",
"thiserror 2.0.19",
"windows-sys 0.61.2",
]
@@ -2309,7 +2299,7 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"jni-sys 0.3.1",
"log",
"ndk-sys",
@@ -2339,7 +2329,7 @@ version = "8.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"fsevent-sys",
"inotify",
"kqueue",
@@ -2357,7 +2347,7 @@ version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
]
[[package]]
@@ -2423,7 +2413,7 @@ dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -2451,7 +2441,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"block2",
"objc2",
"objc2-core-foundation",
@@ -2464,7 +2454,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"objc2",
"objc2-foundation",
]
@@ -2485,7 +2475,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"dispatch2",
"objc2",
]
@@ -2496,7 +2486,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"dispatch2",
"objc2",
"objc2-core-foundation",
@@ -2529,7 +2519,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
@@ -2556,7 +2546,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"block2",
"libc",
"objc2",
@@ -2569,7 +2559,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"objc2",
"objc2-core-foundation",
]
@@ -2580,7 +2570,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"objc2",
"objc2-core-foundation",
"objc2-foundation",
@@ -2592,7 +2582,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"block2",
"objc2",
"objc2-cloud-kit",
@@ -2623,7 +2613,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"block2",
"objc2",
"objc2-app-kit",
@@ -2639,14 +2629,13 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "open"
version = "5.3.5"
version = "5.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c"
checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5"
dependencies = [
"dunce",
"is-wsl",
"libc",
"pathdiff",
]
[[package]]
@@ -2719,12 +2708,6 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "pathdiff"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -2772,7 +2755,7 @@ dependencies = [
"phf_shared",
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -2798,9 +2781,9 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "plist"
version = "1.9.0"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
dependencies = [
"base64 0.22.1",
"indexmap 2.14.0",
@@ -2828,7 +2811,7 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"crc32fast",
"fdeflate",
"flate2",
@@ -2891,7 +2874,7 @@ version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit 0.25.12+spec-1.1.0",
"toml_edit 0.25.13+spec-1.1.0",
]
[[package]]
@@ -2920,27 +2903,27 @@ dependencies = [
[[package]]
name = "proc-macro2"
version = "1.0.106"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quick-xml"
version = "0.39.4"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [
"memchr",
]
[[package]]
name = "quote"
version = "1.0.45"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
@@ -2972,9 +2955,9 @@ dependencies = [
[[package]]
name = "rand"
version = "0.10.1"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"chacha20",
"getrandom 0.4.3",
@@ -3027,7 +3010,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
]
[[package]]
@@ -3038,34 +3021,34 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror 2.0.18",
"thiserror 2.0.19",
]
[[package]]
name = "ref-cast"
version = "1.0.25"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d"
checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d"
dependencies = [
"ref-cast-impl",
]
[[package]]
name = "ref-cast-impl"
version = "1.0.25"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 3.0.2",
]
[[package]]
name = "regex"
version = "1.12.4"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick",
"memchr",
@@ -3075,9 +3058,9 @@ dependencies = [
[[package]]
name = "regex-automata"
version = "0.4.14"
version = "0.4.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad"
dependencies = [
"aho-corasick",
"memchr",
@@ -3164,9 +3147,9 @@ dependencies = [
[[package]]
name = "rustc-hash"
version = "2.1.2"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]]
name = "rustc_version"
@@ -3183,7 +3166,7 @@ version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"errno",
"libc",
"linux-raw-sys",
@@ -3192,9 +3175,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.40"
version = "0.23.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
dependencies = [
"aws-lc-rs",
"log",
@@ -3207,9 +3190,9 @@ dependencies = [
[[package]]
name = "rustls-pki-types"
version = "1.14.1"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
dependencies = [
"zeroize",
]
@@ -3228,15 +3211,15 @@ dependencies = [
[[package]]
name = "rustversion"
version = "1.0.22"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "s2n-codec"
version = "0.82.0"
version = "0.83.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a650d3f187901f3519ec8a1fe7da3faccc0b2fb40f350eda2c7851fdf2bda0f6"
checksum = "5323db3697b61f4f5161c346573a9089cf6b4e13656991a5e84c18dc91bc17d7"
dependencies = [
"byteorder",
"bytes",
@@ -3245,16 +3228,16 @@ dependencies = [
[[package]]
name = "s2n-quic"
version = "1.82.0"
version = "1.83.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c27c34127facefcd3e5530c4de5739a62cd4a593710b1194dacbd8e884b6be92"
checksum = "54598beca69a3970deaa988edcb2a00f5812433db085f23b08f3ebe40a3bf86d"
dependencies = [
"bytes",
"cfg-if",
"cuckoofilter",
"futures",
"hash_hasher",
"rand 0.10.1",
"rand 0.10.2",
"s2n-codec",
"s2n-quic-core",
"s2n-quic-crypto",
@@ -3269,9 +3252,9 @@ dependencies = [
[[package]]
name = "s2n-quic-core"
version = "0.82.0"
version = "0.83.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79fbc3f06797d985363f74de105d18554b5a272b924b166d73a6564943da1230"
checksum = "1ebf9897515906f85528b3301f6f54d9d7b02827a9f5a2e13fcd5c965161571e"
dependencies = [
"atomic-waker",
"byteorder",
@@ -3291,9 +3274,9 @@ dependencies = [
[[package]]
name = "s2n-quic-crypto"
version = "0.82.0"
version = "0.83.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e58ea5aa39eecc29559d1e1bb4a5d55a747fa7b80cff5a3400c57489510644e3"
checksum = "fc67b81d13e30e2b5d69a154fa312bfe523ec3a95c81a694c15c902a210b80de"
dependencies = [
"aws-lc-rs",
"cfg-if",
@@ -3305,9 +3288,9 @@ dependencies = [
[[package]]
name = "s2n-quic-platform"
version = "0.82.0"
version = "0.83.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4eebb6007139cfffdf3d473d39f01a214032c339432a6293b16b0f7b25343f40"
checksum = "6a488f217c6dec4eac1a0c61d25af5235a2abcd03af31ae2f3bc047901caad80"
dependencies = [
"cfg-if",
"futures",
@@ -3320,9 +3303,9 @@ dependencies = [
[[package]]
name = "s2n-quic-rustls"
version = "0.82.0"
version = "0.83.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb0084afa65eefae2c37d9ab44118a14dfc5bb78dbf997c0f5176f7cf8d2e633"
checksum = "7349312d82172938036b92119f666ba93ce9bed7cbfeedc968115574932398fc"
dependencies = [
"bytes",
"rustls",
@@ -3334,9 +3317,9 @@ dependencies = [
[[package]]
name = "s2n-quic-tls"
version = "0.82.0"
version = "0.83.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91150b25ce824ffea581b449ad04acf9b4aef2fa68a46f667cdc9cc6f7b87823"
checksum = "093191572f12842106d8e37326d1a99dd5d6c96bfcdd14c1718e6b6ae51730cb"
dependencies = [
"bytes",
"errno",
@@ -3349,9 +3332,9 @@ dependencies = [
[[package]]
name = "s2n-quic-tls-default"
version = "0.82.0"
version = "0.83.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1f5ae64863972facee778dc80a24317e613f035296631f267b71f225e569c22"
checksum = "3488ac2d7fab0b4921e412dbc8599419f8d1bfd6f9c4422fdb4ef8b5e4a33c68"
dependencies = [
"s2n-quic-rustls",
"s2n-quic-tls",
@@ -3359,9 +3342,9 @@ dependencies = [
[[package]]
name = "s2n-quic-transport"
version = "0.82.0"
version = "0.83.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b82fca53ce1734cc1d1dca96cc9ceb65ed528f27cb43b7de865215b6cf17908"
checksum = "899b3ffff460a7d4e8b8ed0933aca1b4d309a1e52bfffdd9fb54bd78e3c922b0"
dependencies = [
"bytes",
"futures-channel",
@@ -3377,9 +3360,9 @@ dependencies = [
[[package]]
name = "s2n-tls"
version = "0.3.38"
version = "0.3.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6188b4ff1e1fe554f3ed53ef81c550760006ff8003f08c3539447afcd20b6e8b"
checksum = "db76c77f8c280a581031ad88f8bc09c18581b11d785f530025d135dd22c03b9e"
dependencies = [
"errno",
"hex",
@@ -3390,9 +3373,9 @@ dependencies = [
[[package]]
name = "s2n-tls-sys"
version = "0.3.38"
version = "0.3.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbe6d97b0ca3d01056eb1446912799181f1e1ec6922601f80446f4f0337edb67"
checksum = "ce900e83d6cb0dee6623e6e0c955ce1973ad2994ce76c5ed577e27fa564cb82b"
dependencies = [
"aws-lc-rs",
"cc",
@@ -3456,7 +3439,7 @@ dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -3471,7 +3454,7 @@ version = "0.36.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"cssparser",
"derive_more",
"log",
@@ -3496,9 +3479,9 @@ dependencies = [
[[package]]
name = "serde"
version = "1.0.228"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
@@ -3518,22 +3501,22 @@ dependencies = [
[[package]]
name = "serde_core"
version = "1.0.228"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 3.0.2",
]
[[package]]
@@ -3544,14 +3527,14 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
name = "serde_json"
version = "1.0.150"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
@@ -3562,13 +3545,13 @@ dependencies = [
[[package]]
name = "serde_repr"
version = "0.1.20"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 3.0.2",
]
[[package]]
@@ -3618,7 +3601,7 @@ dependencies = [
"darling",
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -3640,7 +3623,7 @@ checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -3722,9 +3705,9 @@ dependencies = [
[[package]]
name = "simd-adler32"
version = "0.3.9"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "siphasher"
@@ -3746,20 +3729,20 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "socket-pktinfo"
version = "0.3.2"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "927136cc2ae6a1b0e66ac6b1210902b75c3f726db004a73bc18686dcd0dcd22f"
checksum = "3e8e43b4bdce7cff8a4d3f8025ee38fce5ca138fab868ebbf9529c81328fbf9d"
dependencies = [
"libc",
"socket2",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
name = "socket2"
version = "0.6.4"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys 0.61.2",
@@ -3815,9 +3798,9 @@ dependencies = [
[[package]]
name = "spin"
version = "0.9.8"
version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
dependencies = [
"lock_api",
]
@@ -3859,7 +3842,7 @@ dependencies = [
"serde",
"sha2",
"smallvec",
"thiserror 2.0.18",
"thiserror 2.0.19",
"tokio",
"tokio-stream",
"tracing",
@@ -3876,7 +3859,7 @@ dependencies = [
"quote",
"sqlx-core",
"sqlx-macros-core",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -3897,7 +3880,7 @@ dependencies = [
"sha2",
"sqlx-core",
"sqlx-sqlite",
"syn 2.0.118",
"syn 2.0.119",
"tokio",
"url",
]
@@ -3909,7 +3892,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c"
dependencies = [
"atoi",
"flume 0.12.0",
"flume",
"form_urlencoded",
"futures-channel",
"futures-core",
@@ -3921,7 +3904,7 @@ dependencies = [
"percent-encoding",
"serde",
"sqlx-core",
"thiserror 2.0.18",
"thiserror 2.0.19",
"tracing",
"url",
]
@@ -3980,7 +3963,7 @@ dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -4012,9 +3995,20 @@ dependencies = [
[[package]]
name = "syn"
version = "2.0.118"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3"
dependencies = [
"proc-macro2",
"quote",
@@ -4038,7 +4032,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -4060,7 +4054,7 @@ version = "0.35.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"block2",
"core-foundation",
"core-graphics",
@@ -4102,7 +4096,7 @@ checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -4113,9 +4107,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "tauri"
version = "2.11.3"
version = "2.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2616f96cb644bf2c5c456d9de4d5d5100e592d7424c74d8b55c5cb96e359e93"
checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5"
dependencies = [
"anyhow",
"bytes",
@@ -4152,7 +4146,7 @@ dependencies = [
"tauri-runtime",
"tauri-runtime-wry",
"tauri-utils",
"thiserror 2.0.18",
"thiserror 2.0.19",
"tokio",
"tray-icon",
"url",
@@ -4201,9 +4195,9 @@ dependencies = [
"serde",
"serde_json",
"sha2",
"syn 2.0.118",
"syn 2.0.119",
"tauri-utils",
"thiserror 2.0.18",
"thiserror 2.0.19",
"time",
"url",
"uuid",
@@ -4219,7 +4213,7 @@ dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
"tauri-codegen",
"tauri-utils",
]
@@ -4242,9 +4236,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-dialog"
version = "2.7.1"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884"
checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940"
dependencies = [
"log",
"raw-window-handle",
@@ -4254,7 +4248,7 @@ dependencies = [
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.18",
"thiserror 2.0.19",
"url",
]
@@ -4277,8 +4271,8 @@ dependencies = [
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"toml 1.1.2+spec-1.1.0",
"thiserror 2.0.19",
"toml 1.1.3+spec-1.1.0",
"url",
]
@@ -4299,22 +4293,22 @@ dependencies = [
"shared_child",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"thiserror 2.0.19",
"tokio",
]
[[package]]
name = "tauri-plugin-store"
version = "2.4.3"
version = "2.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c72dda16786eb4a3f903e43a17b64d8d78dc0f00fe2aa4b757c28f617a8630b"
checksum = "6708afbe549f176b712066e71648ba8fafba20789453718260c7ca356733cb0c"
dependencies = [
"dunce",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"thiserror 2.0.19",
"tokio",
"tracing",
]
@@ -4337,7 +4331,7 @@ dependencies = [
"serde",
"serde_json",
"tauri-utils",
"thiserror 2.0.18",
"thiserror 2.0.19",
"url",
"webkit2gtk",
"webview2-com",
@@ -4346,9 +4340,9 @@ dependencies = [
[[package]]
name = "tauri-runtime-wry"
version = "2.11.3"
version = "2.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe41e015bf8fc4d6477ff4926a0ef769dc64ff34c7b0038b6f7cacae892acb5c"
checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f"
dependencies = [
"gtk",
"http",
@@ -4400,8 +4394,8 @@ dependencies = [
"serde_json",
"serde_with",
"swift-rs",
"thiserror 2.0.18",
"toml 1.1.2+spec-1.1.0",
"thiserror 2.0.19",
"toml 1.1.3+spec-1.1.0",
"url",
"urlpattern",
"uuid",
@@ -4416,17 +4410,16 @@ checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6"
dependencies = [
"dunce",
"embed-resource",
"toml 1.1.2+spec-1.1.0",
"toml 1.1.3+spec-1.1.0",
]
[[package]]
name = "tendril"
version = "0.5.0"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24"
checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08"
dependencies = [
"new_debug_unreachable",
"utf-8",
]
[[package]]
@@ -4440,11 +4433,11 @@ dependencies = [
[[package]]
name = "thiserror"
version = "2.0.18"
version = "2.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
dependencies = [
"thiserror-impl 2.0.18",
"thiserror-impl 2.0.19",
]
[[package]]
@@ -4455,34 +4448,34 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
version = "2.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 3.0.2",
]
[[package]]
name = "thread_local"
version = "1.1.9"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
dependencies = [
"cfg-if",
]
[[package]]
name = "time"
version = "0.3.49"
version = "0.3.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469"
checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244"
dependencies = [
"deranged",
"libc",
@@ -4502,9 +4495,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "time-macros"
version = "0.2.29"
version = "0.2.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d"
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
dependencies = [
"num-conv",
"time-core",
@@ -4522,9 +4515,9 @@ dependencies = [
[[package]]
name = "tinyvec"
version = "1.11.0"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
dependencies = [
"tinyvec_macros",
]
@@ -4537,9 +4530,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.52.3"
version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
dependencies = [
"bytes",
"libc",
@@ -4554,13 +4547,13 @@ dependencies = [
[[package]]
name = "tokio-macros"
version = "2.7.0"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -4576,14 +4569,15 @@ dependencies = [
[[package]]
name = "tokio-util"
version = "0.7.18"
version = "0.7.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"futures-util",
"libc",
"pin-project-lite",
"tokio",
]
@@ -4617,9 +4611,9 @@ dependencies = [
[[package]]
name = "toml"
version = "1.1.2+spec-1.1.0"
version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c"
dependencies = [
"indexmap 2.14.0",
"serde_core",
@@ -4627,7 +4621,7 @@ dependencies = [
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow 1.0.3",
"winnow 1.0.4",
]
[[package]]
@@ -4683,14 +4677,14 @@ dependencies = [
[[package]]
name = "toml_edit"
version = "0.25.12+spec-1.1.0"
version = "0.25.13+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
dependencies = [
"indexmap 2.14.0",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"winnow 1.0.3",
"winnow 1.0.4",
]
[[package]]
@@ -4699,14 +4693,14 @@ version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [
"winnow 1.0.3",
"winnow 1.0.4",
]
[[package]]
name = "toml_writer"
version = "1.1.1+spec-1.1.0"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
[[package]]
name = "tower"
@@ -4729,7 +4723,7 @@ version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"bytes",
"futures-util",
"http",
@@ -4773,7 +4767,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -4829,7 +4823,7 @@ dependencies = [
"once_cell",
"png 0.18.1",
"serde",
"thiserror 2.0.18",
"thiserror 2.0.19",
"windows-sys 0.61.2",
]
@@ -4941,12 +4935,6 @@ dependencies = [
"url",
]
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -4955,9 +4943,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
version = "1.23.3"
version = "1.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7"
checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
dependencies = [
"getrandom 0.4.3",
"js-sys",
@@ -5051,9 +5039,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.125"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a"
checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
dependencies = [
"cfg-if",
"once_cell",
@@ -5064,9 +5052,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.75"
version = "0.4.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280"
checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -5074,9 +5062,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.125"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d"
checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -5084,22 +5072,22 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.125"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd"
checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.125"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f"
checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
dependencies = [
"unicode-ident",
]
@@ -5119,9 +5107,9 @@ dependencies = [
[[package]]
name = "web-sys"
version = "0.3.102"
version = "0.3.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d"
checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -5205,7 +5193,7 @@ checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -5214,7 +5202,7 @@ version = "0.38.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
dependencies = [
"thiserror 2.0.18",
"thiserror 2.0.19",
"windows 0.61.3",
"windows-core 0.61.2",
]
@@ -5364,7 +5352,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -5375,7 +5363,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -5721,9 +5709,9 @@ checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
[[package]]
name = "winnow"
version = "1.0.3"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
dependencies = [
"memchr",
]
@@ -5783,7 +5771,7 @@ dependencies = [
"sha2",
"soup3",
"tao-macros",
"thiserror 2.0.18",
"thiserror 2.0.19",
"url",
"webkit2gtk",
"webkit2gtk-sys",
@@ -5834,28 +5822,28 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
"synstructure",
]
[[package]]
name = "zerocopy"
version = "0.8.52"
version = "0.8.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.52"
version = "0.8.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -5875,7 +5863,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
"synstructure",
]
@@ -5896,7 +5884,7 @@ checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -5929,11 +5917,11 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
name = "zmij"
version = "1.0.21"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+73 -6
View File
@@ -18,15 +18,15 @@ for deterministic local runs; mDNS/macvlan remains an environment smoke path.
| S8 | Ambiguous metadata rejection | Two peers advertise the same game/version with conflicting file sizes. | Download fails with a `download-failed` event; no committed `version.ini` is left for the target game. |
| S9 | Missing game | Client asks for a game none of its peers can serve. | CLI reports a deterministic command failure and emits `no-peers-have-game`; no local files are created. |
| S10 | Shutdown and goodbye cleanup | Alpha and bravo are connected, then bravo shuts down. | Alpha receives peer loss/removal and remote games from bravo disappear. |
| S11 | Same identity reconnect | Bravo restarts with the same state dir but a new port, then alpha connects to the new address. | Alpha has one bravo peer entry with the updated address, not duplicate identities. |
| S11 | Same identity reconnect | Bravo restarts with the same state dir (the OS assigns an ephemeral listener port that usually, but not necessarily, differs), then alpha connects again. | Alpha has exactly one bravo peer entry reusing the same peer ID, not a duplicate identity, at whatever address bravo now advertises. |
| S12 | Transfer serving gates | A peer has a non-catalog, missing-sentinel, active-operation, or `local/` path request. | The serving peer declines metadata/data; covered by unit tests where timing is too small for a stable CLI race test. |
| S13 | Exact transferred-file equality | Repeat small and large downloads, then compare every transferred regular file against its source with SHA-256 manifests. | Source and receiver manifests match exactly for each transferred file; no extra or missing files appear in the downloaded game root. |
| S14 | Large multi-peer chunked download | `fixture-alpha/alienswarm` contains a renamed RAR `.eti` larger than 100 MB. A second peer downloads it, then a third peer downloads `alienswarm` from both peers. | The third peer's downloaded files match the source by SHA-256; `download-chunk-finished` events show the large `.eti` chunks coming from both peers with byte counts balanced within one chunk. |
| S14 | Large multi-peer chunked download | A source advertises a synthetic catalog game whose `.eti` is a sparse file of `4 * CHUNK_SIZE` (four 128 MiB chunks). A second peer downloads it, then a third peer downloads it from both peers. | The third peer's downloaded files match the source by SHA-256; `download-chunk-finished` shows the `.eti` split across exactly both peers, all four chunks accounted for, and the per-peer byte totals balanced within one `CHUNK_SIZE` (a fair 2+2 split; a 3+1 imbalance would trip the check). |
| S15 | Catalog-version skew | Three peers advertise the same catalog game ID. Peers A and B have stale `version.ini` values; peer C has the catalog's expected version. An empty client connects to all three and downloads the game with `install=false`. | `list-games` shows one row for the game with `peer_count=1` and the catalog `eti_game_version`. The `got-game-files` descriptor set and transfer source are peer C only; no chunks come from A or B. The receiver's `version.ini` and SHA-256 manifest match C exactly. |
| S16 | Catalog-version fanout with stale peers present | Peer A has a stale version of a game. Peers B and C both advertise the catalog version with matching file manifests; use a large file when proving chunk split. | The aggregated row counts only catalog-version ready peers. Large-file chunks may split between B and C; peer A is not listed as downloadable and contributes no manifest vote or file chunks. |
| S16 | Catalog-version fanout with stale peers present | Peer A has a stale version of a game. Peers B and C both advertise the catalog version with matching manifests; the `.eti` is inflated to `2 * CHUNK_SIZE` so it can fan out. | The aggregated row counts only catalog-version ready peers. The `.eti` chunks split across exactly B and C; peer A is not listed as downloadable and contributes no manifest vote or file chunks. |
| S17 | Catalog-version conflict rejection | Peer A has a stale version. Peers B and C both advertise the catalog version, but their file sizes conflict. | Validation considers only the catalog-version peers, so A cannot rescue the majority. The download fails with `download-failed`, and no committed target `version.ini` remains. |
| S18 | Mid-download source drop with redundancy | Client downloads a large shared game from two ready peers, then one source is killed after the download has begun. | Failed chunks are retried against the surviving source; the download finishes, no `download-failed` is emitted, and the receiver's files match the source by diff or SHA-256. |
| S19 | Mid-download sole-source drop | Client downloads a large game from one source, then that source is killed after the download has begun. | The download emits `download-failed`; no committed target `version.ini` remains; any partial payload is not advertised as ready; active operation state clears so a retry is possible. |
| S18 | Mid-download source drop with redundancy | Client downloads a large shared multi-chunk game (sparse `4 * CHUNK_SIZE`, so both peers are assigned `.eti` chunks) from two ready peers, then one source is killed right after the download has begun. | The download survives the source kill: it finishes, no `download-failed` is emitted over the whole download window, every byte is delivered (chunk totals sum to the file size) with the survivor serving part of it, and the receiver's files match the source by diff or SHA-256. (Retry-onto-survivor is the mechanism that makes this possible, exercised when the kill interrupts an unfinished chunk, but it is not asserted because the kill timing cannot be forced; the per-source split is likewise not asserted.) |
| S19 | Mid-download sole-source drop | Client downloads a large multi-chunk game (sparse `4 * CHUNK_SIZE`) from one source, then that source is force-killed immediately after `download-begin`. An individual chunk may complete before the kill lands, but the full multi-chunk download cannot, so the failure is deterministic on a fast LAN. | The download emits a terminal failure (`download-failed`, or `download-peers-gone` when the sole source vanishes) and no `download-finished`; no committed target `version.ini` remains; any partial payload is not advertised as ready; active operation state clears so a retry is possible. |
| S20 | Receiver write failure | Client downloads a large game into a constrained `/games` filesystem. | The download fails deterministically, no committed `version.ini` is advertised, and active operation state clears so the peer can retry later. |
| S21 | Add-game propagation | Two connected peers are running; one peer gains a new catalog game root through a completed download or an external drop. | The other peer receives a library update without reconnecting, and `list-games` shows the new remote game under the existing peer. |
| S22 | Remove-game propagation | Two connected peers are running; one peer loses a previously advertised game root. | The other peer receives a library update without dropping the peer, and `list-games` no longer shows that remote game. |
@@ -34,7 +34,7 @@ for deterministic local runs; mDNS/macvlan remains an environment smoke path.
| S24 | Two clients pull from one source | Two empty clients connect to the same source and download the same large game concurrently. | Both downloads finish, both receivers match the source by diff or SHA-256, and the source remains responsive. |
| S25 | One client downloads two games concurrently | One client connected to a source issues two different `download` commands without waiting for the first to finish. | Both operations may run in parallel; both eventually finish, each game reaches the requested install state, and each transferred root matches its source. |
| S26 | Same-game duplicate download rejection | A client starts downloading a game, then issues a second `download` command for the same game while the first operation is active. | The second request is rejected deterministically as an operation-in-progress condition; the first download is not corrupted and still reaches its documented final state. |
| S27 | Self-connect rejection | A peer sends `connect` to its own advertised listener address. | The command fails cleanly, no self-peer entry is created, and the peer remains responsive. |
| S27 | Self-connect rejection | A peer sends `connect` to its own advertised listener address. | The CLI command fails cleanly (CLI-level guard), no self-peer entry is created, and the peer remains responsive. The protocol-level guard (a hello whose `peer_id` equals the local id is acknowledged but never recorded) is covered by the `handshake::tests::inbound_hello_from_self_is_ignored` unit test, which the CLI string-compare never reaches. |
| S28 | Address change without identity change | A known peer is rediscovered with the same peer ID and a different listener address while its library is still known. | The peer record updates in place to the new address, the existing library stays attached to that peer ID, and no duplicate peer entry appears. This is covered with a deterministic unit-level check until the CLI can rebind a live listener without restart. |
| S29 | Empty-library peer participates | A peer with no games connects into the mesh. | Other peers list it as a peer with zero games; it can receive a download, advertise the new game without restart, and become a source. |
| S30 | 5+ peer mesh aggregation | Five peers advertise partially overlapping catalog games with a mix of unique and shared catalog-version games; a sixth client connects to all five. | The client shows one row per game ID, correct catalog-version ready-source `peer_count`, catalog `eti_game_version`, no duplicates, and no self entries. |
@@ -143,6 +143,73 @@ Use S39-S41 to pin down low-disk streamed installs:
## Run Log
### 2026-06-21 - Test-Suite Integrity Audit And Hardening
- An adversarial review of `run_extended_scenarios.py` found assertions that
passed vacuously, raced, or diverged from the spec. A full baseline run
(S1-S47, rebuilt image) passed beforehand, confirming these were test-quality
gaps, not peer regressions. Baseline evidence of the gaps: S14 chunk totals
were `{134217728, 1048576}` (a 2-chunk file whose "balanced within one chunk"
check can never fail), and S16/S18 each served the whole ~120 MiB
`alienswarm.eti` from a single source, so neither fanout (S16) nor
retry-onto-survivor (S18) was actually exercised.
- Fixes applied to the runner (and the matching rows above):
- S18: replaced the dead `assert_no_event` (it reused a `LineWaiter` already
advanced past `download-finished`, so it scanned an empty tail and could
never fire) with `assert_no_event_since` over the whole download window;
switched to a multi-chunk sparse archive (`4 * CHUNK_SIZE`) so both peers
own `.eti` chunks and the test proves the download survives a mid-download
source kill (retry-onto-survivor is the mechanism, exercised when the kill
interrupts an unfinished chunk, but not asserted since the race can't be
forced).
- S7: added chunk-source, both-sources-served, single-`download-finished`,
and no-duplicate-chunk checks (the byte-identical `ggoo` fixtures made the
old diff-only assertion source-agnostic).
- S14: `4 * CHUNK_SIZE` file so the balance check is meaningful (a 3+1 split
would now exceed one chunk); asserts an exact 2+2 split and full byte total.
- S16: inflated `.eti` to `2 * CHUNK_SIZE` so it fans out across both
catalog-version peers (the stock 120 MiB fixture is a single chunk).
- S19: force-kill right after `download-begin` on a multi-chunk file, accept
`download-failed`/`download-peers-gone`, assert no `download-finished` (the
old graceful shutdown could let a single-chunk transfer finish first).
- S26: large sparse source so the first op is reliably still active, and
asserts the active `operation == "Downloading"` (no scenario checked it).
- S37: validates the throughput rate fields (positive, self-consistent
`mbit_per_s/mib_per_s == 8.388608`, `mib_per_s == bytes/duration`), not just
the byte count.
- S35: asserts the source actually advertises `mystery-game` before checking
it is filtered (distinguishes "filtered" from "never sent").
- S15: cross-checks each peer's raw advertised `eti_version` via list-peers
(the list-games `eti_game_version` is synthesized from the local catalog and
can only ever equal the catalog value).
- S2: polls for library convergence and verifies the bidirectional exchange
(bravo sees alpha's 3 games, not just alpha seeing bravo's 4).
- S11: dropped the "listener address must change" assertion (it tested the OS
ephemeral-port allocator and could fail spuriously).
- S12/S28: require the gating unit test to appear as `<name> ... ok` so an
`#[ignore]`d (un-run) test no longer satisfies the check.
- S24/S25: assert the requested `install=false` final state.
- S34: assert exactly 21 coherent chunks (20 files + version.ini), 21 distinct
paths, no duplicates, instead of a `>= 21` floor.
- S27: added the `handshake::tests::inbound_hello_from_self_is_ignored` unit
test for the protocol-level self guard; the CLI scenario only exercises the
CLI string-compare guard, which short-circuits before any network call.
- Harness: `find_fixture_game` now iterates `sorted(...)`, so the ambiguous
`cnctw` (bravo/multi/solid) resolves deterministically to `fixture-bravo`.
- Accepted as-is (reviewed, deliberately not changed): S20 (disk-full via chunk
`write_all` is equivalent coverage), S21 (inotify across the bind mount is
inherent to the harness), S30 (dup-row/self-peer checks are cheap defensive
guards), S32/S39/S44 absence checks (cheap regression guards against committing
a root sentinel), S42 IP-order precondition (deterministic by container start
order), S45 (the spec already names both terminal events).
- Live runs against the rebuilt `lanspread-peer-cli:dev` image: baseline S1-S47
passed; post-fix S1-S47 passed. Post-fix evidence: S14 `{268435456, 268435456}`
(balanced 2+2); S16 `.eti` split across B and C `{134217728, 134217728}`; S18
all `536870912` bytes delivered despite the source drop (the survivor served
the whole archive in that run); S19 deterministic `download-failed`; S37
`874.24 MiB/s`. Gates: `just test` (incl. the new handshake test),
`just clippy` (`-D warnings`), and `just fmt` all passed.
### 2026-06-20 - Prune Dead Lifecycle Events
- Code under test removed the unconsumed `InstallGameBegin`, `UninstallGameBegin`,
+1
View File
@@ -9,6 +9,7 @@ and writes JSONL events, results, and errors to stdout.
```bash
just peer-cli-build
just peer-cli-image
just peer-cli-tests
just peer-cli-run alpha
```
@@ -448,12 +448,21 @@ class Runner:
alpha = self.peer("s2-alpha", games_dir=FIXTURES / "fixture-alpha", readonly_games=True)
bravo = self.peer("s2-bravo", games_dir=FIXTURES / "fixture-bravo", readonly_games=True)
connect_many(alpha, [bravo])
# Poll for library convergence rather than reading once: wait-peers only
# guarantees peer presence, and the mDNS path can upsert a peer with an
# empty library before its snapshot arrives.
wait_peer_has_game(alpha, bravo.peer_id, "bfbc2")
peers = alpha.list_peers()
if len(peers) != 1 or peers[0]["peer_id"] == alpha.peer_id:
raise ScenarioError(f"bad alpha peers after connect: {peers}")
if peers[0]["game_count"] != 4:
raise ScenarioError(f"expected bravo game_count=4, got {peers}")
return "alpha connected to bravo, saw one non-self peer with four games"
# The handshake is bidirectional: bravo must also record alpha's library
# without a separate connect, proving both directions of the exchange.
bravo_view = wait_peer_has_game(bravo, alpha.peer_id, "alienswarm")
if bravo_view["game_count"] != 3:
raise ScenarioError(f"expected alpha game_count=3 on bravo, got {bravo_view}")
return "alpha<->bravo exchanged libraries: bravo had 4 games on alpha, alpha had 3 on bravo"
def s3_remote_aggregation(self) -> str:
alpha = self.peer("s3-alpha", games_dir=FIXTURES / "fixture-alpha", readonly_games=True)
@@ -529,7 +538,19 @@ class Runner:
client.send({"cmd": "download", "game_id": "ggoo", "install": False})
client.wait_for(event_is("download-finished", "ggoo"), timeout=60, description="finish ggoo", waiter=waiter)
diff_game_dirs(FIXTURES / "fixture-alpha" / "ggoo", client.host_games_dir / "ggoo")
return "shared ggoo downloaded once from duplicate sources and diffed cleanly"
# The fixtures are byte-identical, so the diff alone is source-agnostic.
# Prove the duplicate-source path: download committed exactly once, every
# chunk came from the validated 2-peer set, both peers actually served,
# and nothing was fetched twice.
if count_events(client, "download-finished", "ggoo") != 1:
raise ScenarioError("ggoo did not finish exactly once")
assert_only_chunk_sources(client, "ggoo", {alpha.ready_addr, bravo.ready_addr})
if chunk_sources(client, "ggoo") != {alpha.ready_addr, bravo.ready_addr}:
raise ScenarioError(
f"expected both validated sources to serve ggoo, got {chunk_sources(client, 'ggoo')}"
)
assert_no_duplicate_chunks(client, "ggoo")
return "ggoo downloaded once, served from both validated sources with no duplicate chunks"
def s8_ambiguous_metadata_rejection(self) -> str:
dir_a = self.fixture_root / "s8-a"
@@ -581,13 +602,17 @@ class Runner:
bravo = self.peer("s11-bravo", games_dir=bravo_dir, readonly_games=True)
connect_many(alpha, [bravo])
peers = alpha.list_peers()
# The real invariant is a single peer entry reusing the same identity (no
# duplicate). The listener address is an OS-assigned ephemeral port: it
# almost always differs across restarts, but asserting it MUST change
# tests the kernel's port allocator, not the peer, and can fail spuriously
# if the same port is reused. So we only require the identity to be stable.
if len(peers) != 1:
raise ScenarioError(f"expected one bravo peer after reconnect, got {peers}")
if peers[0]["peer_id"] != first_id:
raise ScenarioError(f"bravo peer id changed: {first_id} -> {peers[0]['peer_id']}")
if peers[0]["addr"] == first_addr:
raise ScenarioError(f"bravo listener address did not change: {first_addr}")
return f"bravo reused peer id {first_id} with new address {peers[0]['addr']}"
changed = "new" if peers[0]["addr"] != first_addr else "same"
return f"bravo reused peer id {first_id} as a single entry at {changed} address {peers[0]['addr']}"
def s12_transfer_serving_gates(self) -> str:
output = run_just_test()
@@ -597,9 +622,9 @@ class Runner:
"file_transfer_dispatch_respects_serve_gates",
"local_relative_paths_are_never_transferable",
]
missing = [name for name in required if name not in output]
missing = [name for name in required if f"{name} ... ok" not in output]
if missing:
raise ScenarioError(f"S12 unit proof missing tests: {missing}")
raise ScenarioError(f"S12 unit proof tests did not run-and-pass: {missing}")
return "just test passed including catalog/sentinel/active/local-path serve gate tests"
def s13_exact_transfer_equality(self) -> str:
@@ -619,28 +644,34 @@ class Runner:
def s14_large_multi_peer_chunking(self) -> str:
game_id = PERF_GAME_ID
source_dir = self.fixture_root / "s14-alpha"
create_large_sparse_game(source_dir / game_id, size=CHUNK_SIZE + 1024 * 1024)
# Four 128 MiB chunks so the balance assertion is meaningful: with two
# peers a fair split is 2+2 chunks (diff 0) and a 3+1 imbalance would
# exceed one CHUNK_SIZE. A 2-chunk file could never trip the check.
file_size = CHUNK_SIZE * 4
create_large_sparse_game(source_dir / game_id, size=file_size)
alpha = self.peer("s14-alpha", games_dir=source_dir)
stage = self.peer("s14-stage")
connect_many(stage, [alpha])
waiter = LineWaiter(len(stage.output))
stage.send({"cmd": "download", "game_id": game_id, "install": False})
stage.wait_for(event_is("download-finished", game_id), timeout=90, description="stage finish", waiter=waiter)
stage.wait_for(event_is("download-finished", game_id), timeout=180, description="stage finish", waiter=waiter)
diff_game_dirs(source_dir / game_id, stage.host_games_dir / game_id)
client = self.peer("s14-client")
connect_many(client, [alpha, stage])
wait_remote_game(client, game_id, peer_count=2, version=PERF_GAME_VERSION)
waiter = LineWaiter(len(client.output))
client.send({"cmd": "download", "game_id": game_id, "install": False})
client.wait_for(event_is("download-finished", game_id), timeout=90, description="client finish", waiter=waiter)
client.wait_for(event_is("download-finished", game_id), timeout=180, description="client finish", waiter=waiter)
diff_game_dirs(source_dir / game_id, client.host_games_dir / game_id)
totals = chunk_totals(client, game_id, f"{game_id}/{game_id}.eti")
if len(totals) < 2:
raise ScenarioError(f"expected chunks from two peers, got {totals}")
if len(totals) != 2:
raise ScenarioError(f"expected .eti chunks from exactly two peers, got {totals}")
if sum(totals.values()) != file_size:
raise ScenarioError(f"chunk bytes {sum(totals.values())} != file size {file_size}: {totals}")
values = list(totals.values())
if max(values) - min(values) > CHUNK_SIZE:
raise ScenarioError(f"chunk totals not balanced within one chunk: {totals}")
return f"{game_id} downloaded from two sources, diff matched, chunk totals={totals}"
return f"{game_id} ({file_size // (1024 * 1024)} MiB) split across two sources, balanced, diff matched: {totals}"
def s15_three_way_version_skew(self) -> str:
specs = [
@@ -656,6 +687,15 @@ class Runner:
client = self.peer("s15-client")
connect_many(client, peers)
wait_remote_game(client, "cnc4", peer_count=1, version=CATALOG_VERSIONS["cnc4"])
# Cross-check the RAW advertised versions via list-peers (not the
# catalog-synthesized list-games field), proving the three peers really
# differ and that only the catalog-version peer is aggregated.
for peer, expected_version in zip(peers, ["20150101", "20160101", CATALOG_VERSIONS["cnc4"]]):
advertised = peer_advertised_version(client, peer.peer_id, "cnc4")
if advertised != expected_version:
raise ScenarioError(
f"{peer.name} advertised cnc4 version {advertised}, expected {expected_version}"
)
waiter = LineWaiter(len(client.output))
client.send({"cmd": "download", "game_id": "cnc4", "install": False})
client.wait_for(event_is("download-finished", "cnc4"), timeout=60, description="cnc4 finish", waiter=waiter)
@@ -673,19 +713,25 @@ class Runner:
for name, version in specs:
game_dir = self.fixture_root / name
copy_game("alienswarm", game_dir, version=version)
# Two 128 MiB chunks so the .eti can actually fan out across the two
# catalog-version peers; the stock 120 MiB fixture is a single chunk
# that can only ever come from one source.
inflate_archive_sparse(game_dir / "alienswarm", "alienswarm", CHUNK_SIZE * 2)
peers.append(self.peer(name, games_dir=game_dir))
client = self.peer("s16-client")
connect_many(client, peers)
wait_remote_game(client, "alienswarm", peer_count=2, version=CATALOG_VERSIONS["alienswarm"])
waiter = LineWaiter(len(client.output))
client.send({"cmd": "download", "game_id": "alienswarm", "install": False})
client.wait_for(event_is("download-finished", "alienswarm"), timeout=90, description="alienswarm finish", waiter=waiter)
client.wait_for(event_is("download-finished", "alienswarm"), timeout=180, description="alienswarm finish", waiter=waiter)
assert_only_chunk_sources(client, "alienswarm", {peers[1].ready_addr, peers[2].ready_addr})
totals = chunk_totals(client, "alienswarm", "alienswarm/alienswarm.eti")
if peers[0].ready_addr in totals:
raise ScenarioError(f"stale peer contributed chunks: {totals}")
if set(totals) != {peers[1].ready_addr, peers[2].ready_addr}:
raise ScenarioError(f"expected .eti to fan out across both B and C, got {totals}")
diff_game_dirs(peers[1].host_games_dir / "alienswarm", client.host_games_dir / "alienswarm")
return f"catalog-version B/C peers served alienswarm while stale A contributed zero; totals={totals}"
return f"catalog-version B/C peers split alienswarm.eti while stale A contributed zero; totals={totals}"
def s17_catalog_conflict_rejection(self) -> str:
specs = [
@@ -711,43 +757,87 @@ class Runner:
return "catalog-version file conflict failed download and left no committed version.ini"
def s18_redundant_source_drop(self) -> str:
game_id = "bf1942"
source_a_dir = self.fixture_root / "s18-a"
source_b_dir = self.fixture_root / "s18-b"
copy_game("alienswarm", source_a_dir)
copy_game("alienswarm", source_b_dir)
# Multi-chunk sparse archive so BOTH peers are assigned .eti chunks; a
# single-chunk file could be served entirely by one peer, so killing the
# other would prove nothing. We verify the download SURVIVES a mid-download
# source kill (every byte still arrives, no download-failed, diff matches).
# Retry-onto-survivor is the mechanism that makes this work and is
# exercised whenever the kill interrupts an unfinished chunk, but the race
# against `docker rm -f` means we cannot deterministically force it, so we
# do not assert it. Sparse zero bytes are identical, so duplicate-source
# majority validation still agrees.
file_size = CHUNK_SIZE * 4
create_large_sparse_game(source_a_dir / game_id, size=file_size)
create_large_sparse_game(source_b_dir / game_id, size=file_size)
source_a = self.peer("s18-a", games_dir=source_a_dir)
source_b = self.peer("s18-b", games_dir=source_b_dir)
client = self.peer("s18-client")
connect_many(client, [source_a, source_b])
wait_remote_game(client, "alienswarm", peer_count=2)
waiter = LineWaiter(len(client.output))
client.send({"cmd": "download", "game_id": "alienswarm", "install": False})
client.wait_for(event_is("download-begin", "alienswarm"), timeout=20, description="download begin", waiter=waiter)
wait_remote_game(client, game_id, peer_count=2)
start = len(client.output)
waiter = LineWaiter(start)
client.send({"cmd": "download", "game_id": game_id, "install": False})
client.wait_for(event_is("download-begin", game_id), timeout=20, description="download begin", waiter=waiter)
source_a.kill()
client.wait_for(event_is("download-finished", "alienswarm"), timeout=90, description="download finish", waiter=waiter)
assert_no_event(client, waiter, "download-failed", "alienswarm")
diff_game_dirs(source_b_dir / "alienswarm", client.host_games_dir / "alienswarm")
totals = chunk_totals(client, "alienswarm", "alienswarm/alienswarm.eti")
if not totals:
raise ScenarioError("S18 did not record chunk evidence")
return f"source killed after begin; download finished; diff matched; chunk bytes={totals}"
client.wait_for(event_is("download-finished", game_id), timeout=180, description="download finish", waiter=waiter)
# Scan the WHOLE download window (start..) for download-failed. The old
# assert_no_event reused a waiter already advanced past download-finished,
# so it only saw the empty tail and could never fire.
assert_no_event_since(client, start, "download-failed", game_id)
diff_game_dirs(source_b_dir / game_id, client.host_games_dir / game_id)
totals = chunk_totals(client, game_id, f"{game_id}/{game_id}.eti")
if source_b.ready_addr not in totals:
raise ScenarioError(f"surviving source served no .eti chunks: {totals}")
if sum(totals.values()) != file_size:
raise ScenarioError(f"download did not deliver the whole archive ({sum(totals.values())} != {file_size}): {totals}")
# We deliberately do NOT assert the exact per-source split. The killed
# source can serve a chunk or two before `docker rm -f` lands (a fast-LAN
# race), so requiring totals == {survivor: file_size} would be flaky. The
# robust proof of redundancy is that a source died mid-download yet every
# byte still arrived (sum == file_size plus the diff), the survivor served
# part of it, and no download-failed was emitted.
survivor_bytes = totals[source_b.ready_addr]
return (
f"source killed after begin; all {file_size} bytes delivered "
f"({survivor_bytes} from the survivor), no download-failed; diff matched; bytes={totals}"
)
def s19_sole_source_drop(self) -> str:
game_id = "bf1942"
source_dir = self.fixture_root / "s19-source"
copy_game("alienswarm", source_dir)
# Multi-chunk sparse archive force-killed right after download-begin. A
# 120 MiB single-chunk file served by a graceful shutdown could finish
# (~0.15s at LAN speed) before the drop landed, flipping the expected
# failure into a download-finished. With four 128 MiB chunks and a
# forceful kill issued right after download-begin, the source dies with
# the bulk of the transfer still outstanding (an individual chunk may
# complete first, but the full download cannot), so a terminal failure is
# deterministic.
create_large_sparse_game(source_dir / game_id, size=CHUNK_SIZE * 4)
source = self.peer("s19-source", games_dir=source_dir)
client = self.peer("s19-client")
connect_many(client, [source])
wait_remote_game(client, "alienswarm", peer_count=1)
waiter = LineWaiter(len(client.output))
client.send({"cmd": "download", "game_id": "alienswarm", "install": False})
client.wait_for(event_is("download-begin", "alienswarm"), timeout=20, description="download begin", waiter=waiter)
source.shutdown()
client.wait_for(event_is("download-failed", "alienswarm"), timeout=90, description="download failed", waiter=waiter)
assert_not_exists(client.host_games_dir / "alienswarm" / "version.ini")
assert_no_active(client, "alienswarm")
assert_local_absent(client, "alienswarm")
return "download-failed emitted; version.ini absent; local ready row absent; active operations empty"
wait_remote_game(client, game_id, peer_count=1)
start = len(client.output)
waiter = LineWaiter(start)
client.send({"cmd": "download", "game_id": game_id, "install": False})
client.wait_for(event_is("download-begin", game_id), timeout=20, description="download begin", waiter=waiter)
# Forceful kill (no Goodbye) drops the connection mid-transfer.
source.kill()
terminal = client.wait_for(
event_name_in({"download-failed", "download-peers-gone"}, game_id),
timeout=120,
description="sole-source drop terminal failure",
waiter=waiter,
)
assert_no_event_since(client, start, "download-finished", game_id)
assert_not_exists(client.host_games_dir / game_id / "version.ini")
assert_no_active(client, game_id)
assert_local_absent(client, game_id)
return f"sole-source forceful drop mid-transfer -> {terminal['event']}; version.ini absent; no ready row; no active op"
def s20_receiver_write_failure(self) -> str:
source_dir = self.fixture_root / "s20-source"
@@ -813,8 +903,12 @@ class Runner:
c2.wait_for(event_is("download-finished", "alienswarm"), timeout=90, description="client-b finish", waiter=waiter2)
diff_game_dirs(FIXTURES / "fixture-alpha" / "alienswarm", c1.host_games_dir / "alienswarm")
diff_game_dirs(FIXTURES / "fixture-alpha" / "alienswarm", c2.host_games_dir / "alienswarm")
source.status()
return "two concurrent clients finished alienswarm; both diffs matched; source status responded"
wait_local_game(c1, "alienswarm", downloaded=True, installed=False)
wait_local_game(c2, "alienswarm", downloaded=True, installed=False)
# Responsiveness: the source still answers and still advertises its games.
if not any(g["id"] == "alienswarm" for g in source.list_games()["local"]):
raise ScenarioError("source no longer advertises alienswarm after serving two clients")
return "two concurrent clients finished alienswarm install=false; both diffs matched; source still responsive"
def s25_two_downloads_one_client(self) -> str:
source = self.peer("s25-bravo", games_dir=FIXTURES / "fixture-bravo", readonly_games=True)
@@ -827,34 +921,49 @@ class Runner:
client.wait_for(event_is("download-finished", "cnctw"), timeout=60, description="cnctw finish", waiter=waiter)
diff_game_dirs(FIXTURES / "fixture-bravo" / "bfbc2", client.host_games_dir / "bfbc2")
diff_game_dirs(FIXTURES / "fixture-bravo" / "cnctw", client.host_games_dir / "cnctw")
return "bfbc2 and cnctw concurrent downloads both finished and diffed cleanly"
wait_local_game(client, "bfbc2", downloaded=True, installed=False)
wait_local_game(client, "cnctw", downloaded=True, installed=False)
return "bfbc2 and cnctw concurrent downloads both finished install=false and diffed cleanly"
def s26_duplicate_download_rejection(self) -> str:
source = self.peer("s26-alpha", games_dir=FIXTURES / "fixture-alpha", readonly_games=True)
game_id = "bf1942"
source_dir = self.fixture_root / "s26-source"
# A large sparse archive keeps the first download active long enough that
# the duplicate request is guaranteed to race against an in-progress
# operation rather than a near-instant 3 MB transfer that may already be
# finished by the time the second command is read.
create_large_sparse_game(source_dir / game_id, size=CHUNK_SIZE * 2)
source = self.peer("s26-source", games_dir=source_dir)
client = self.peer("s26-client")
connect_many(client, [source])
wait_remote_game(client, game_id, peer_count=1)
waiter = LineWaiter(len(client.output))
client.send({"cmd": "download", "game_id": "alienswarm", "install": False})
client.wait_for(
client.send({"cmd": "download", "game_id": game_id, "install": False})
active = client.wait_for(
lambda item: item.get("type") == "event"
and item.get("event") == "active-operations-changed"
and any(
op.get("game_id") == "alienswarm"
op.get("game_id") == game_id
for op in item.get("data", {}).get("active_operations", [])
),
timeout=20,
description="active operation",
waiter=waiter,
)
# Verify the operation kind, not just its presence (the only scenario that
# inspects the active_operations 'operation' field).
op = next(o for o in active["data"]["active_operations"] if o.get("game_id") == game_id)
if op.get("operation") != "Downloading":
raise ScenarioError(f"expected Downloading active operation, got {op}")
err = client.send(
{"cmd": "download", "game_id": "alienswarm", "install": False},
{"cmd": "download", "game_id": game_id, "install": False},
expect_error=True,
)
if "operation already in progress" not in err["error"]:
raise ScenarioError(f"unexpected duplicate error: {err}")
client.wait_for(event_is("download-finished", "alienswarm"), timeout=90, description="first download finish", waiter=waiter)
diff_game_dirs(FIXTURES / "fixture-alpha" / "alienswarm", client.host_games_dir / "alienswarm")
return f"second command errored '{err['error']}'; first download diff matched"
client.wait_for(event_is("download-finished", game_id), timeout=180, description="first download finish", waiter=waiter)
diff_game_dirs(source_dir / game_id, client.host_games_dir / game_id)
return f"duplicate rejected while Downloading active ('{err['error']}'); first download diff matched"
def s27_self_connect_rejection(self) -> str:
alpha = self.peer("s27-alpha")
@@ -870,8 +979,8 @@ class Runner:
def s28_address_change_unit(self) -> str:
output = run_just_test()
test_name = "peer_db::tests::address_update_preserves_peer_identity_and_library"
if test_name not in output:
raise ScenarioError(f"S28 unit proof missing from just test output:\n{output}")
if f"{test_name} ... ok" not in output:
raise ScenarioError(f"S28 unit proof did not run-and-pass in just test output:\n{output}")
return "`just test` passed including address_update_preserves_peer_identity_and_library"
def s29_empty_peer_participates(self) -> str:
@@ -1006,14 +1115,24 @@ class Runner:
and item.get("event") == "download-chunk-finished"
and item.get("data", {}).get("game_id") == "bf1942"
]
if len(chunks) < 21:
raise ScenarioError(f"expected at least 21 file chunks, got {len(chunks)}")
return f"20 small files plus version.ini transferred; diff matched; chunk events={len(chunks)}"
# 20 small files + version.ini, each a single coherent chunk: exactly 21
# chunk events, 21 distinct relative paths, no splits and no duplicates.
if len(chunks) != 21:
raise ScenarioError(f"expected exactly 21 file chunks (20 files + version.ini), got {len(chunks)}")
assert_no_duplicate_chunks(client, "bf1942")
distinct_paths = {item.get("data", {}).get("relative_path") for item in chunks}
if len(distinct_paths) != 21:
raise ScenarioError(f"expected 21 distinct file paths, got {len(distinct_paths)}: {sorted(distinct_paths)}")
return f"20 small files plus version.ini each transferred as one coherent chunk; diff matched; chunk events={len(chunks)}"
def s35_unknown_game_filtered(self) -> str:
source = self.peer("s35-source", fixtures=["mystery-game"])
client = self.peer("s35-client")
connect_many(client, [source])
# Establish the premise: the source really does advertise mystery-game in
# its raw library snapshot. Without this, wait_remote_absent could pass
# vacuously ("absent because never sent" vs "absent because filtered").
wait_peer_has_game(client, source.peer_id, "mystery-game")
wait_remote_absent(client, "mystery-game")
err = client.send({"cmd": "download", "game_id": "mystery-game", "install": False}, expect_error=True)
if "not in the local catalog" not in err["error"]:
@@ -1081,6 +1200,26 @@ class Runner:
f"throughput byte count mismatch: {throughput['bytes']} != {expected_bytes}"
)
# The byte count alone never exercises the rate math. Validate the rate
# fields are positive and mutually consistent so a units/divisor bug
# (MiB vs MB, off-by-1000, zero duration) cannot slip through.
if throughput["duration_ms"] <= 0:
raise ScenarioError(f"throughput duration_ms not positive: {throughput}")
if throughput["mib_per_s"] <= 0 or throughput["mbit_per_s"] <= 0:
raise ScenarioError(f"throughput rates not positive: {throughput}")
derived_mib = (int(throughput["bytes"]) / 1_048_576) / (throughput["duration_ms"] / 1000.0)
if abs(derived_mib - throughput["mib_per_s"]) > max(1.0, derived_mib * 0.02):
raise ScenarioError(
f"mib_per_s {throughput['mib_per_s']} disagrees with bytes/duration {derived_mib}"
)
# mbit_per_s = bytes*8/s/1e6; mib_per_s = bytes/s/1048576 -> ratio is fixed.
ratio = throughput["mbit_per_s"] / throughput["mib_per_s"]
expected_ratio = 1_048_576 * 8 / 1_000_000 # 8.388608
if abs(ratio - expected_ratio) > 0.01:
raise ScenarioError(
f"mbit/mib ratio {ratio} != expected {expected_ratio}: {throughput}"
)
return (
f"{PERF_GAME_ID} {format_bytes(PERF_GAME_SIZE)} single-source download: "
f"{throughput['mib_per_s']:.2f} MiB/s, "
@@ -1692,7 +1831,10 @@ def copy_game(game_id: str, destination_games_dir: Path, *, version: str | None
def find_fixture_game(game_id: str) -> Path:
for fixture_dir in FIXTURES.iterdir():
# Sorted so resolution is deterministic: several fixtures define the same
# game id (e.g. cnctw exists under fixture-bravo, fixture-multi and
# fixture-solid), and downstream assertions expect the fixture-bravo layout.
for fixture_dir in sorted(FIXTURES.iterdir()):
candidate = fixture_dir / game_id
if candidate.exists():
return candidate
@@ -1726,16 +1868,27 @@ def create_many_small_game(root: Path) -> None:
(root / "version.ini").write_text(CATALOG_VERSIONS.get(root.name, "20250101"), encoding="utf-8")
def create_large_sparse_game(root: Path, *, size: int) -> None:
def create_large_sparse_game(root: Path, *, size: int, version: str | None = None) -> None:
if root.exists():
shutil.rmtree(root)
root.mkdir(parents=True)
(root / "version.ini").write_text(PERF_GAME_VERSION, encoding="utf-8")
resolved = version if version is not None else CATALOG_VERSIONS.get(root.name, PERF_GAME_VERSION)
(root / "version.ini").write_text(resolved, encoding="utf-8")
archive = root / f"{root.name}.eti"
with archive.open("wb") as handle:
handle.truncate(size)
def inflate_archive_sparse(game_root: Path, game_id: str, size: int) -> None:
"""Replace a copied game's `.eti` with a sparse file of `size` bytes so the
archive spans multiple 128 MiB download chunks. Sparse zero bytes are
identical across copies, so duplicate-source majority validation still
agrees and a `diff` against any catalog-version source still matches."""
archive = game_root / f"{game_id}.eti"
with archive.open("wb") as handle:
handle.truncate(size)
def sha256_file(path: Path) -> str:
hasher = hashlib.sha256()
with path.open("rb") as handle:
@@ -1977,13 +2130,6 @@ def event_name_in(events: set[str], game_id: str | None = None) -> Callable[[dic
return predicate
def assert_no_event(peer: Peer, waiter: LineWaiter, event: str, game_id: str) -> None:
for item in peer.output[waiter.seen :]:
if item.get("type") == "event" and item.get("event") == event:
if item.get("data", {}).get("game_id") == game_id:
raise ScenarioError(f"unexpected {event} for {game_id}: {item}")
def assert_no_event_since(peer: Peer, start: int, event: str, game_id: str) -> None:
for item in peer.output[start:]:
if item.get("type") == "event" and item.get("event") == event:
@@ -2038,6 +2184,61 @@ def chunk_totals(peer: Peer, game_id: str, relative_path: str) -> dict[str, int]
return totals
def chunk_sources(peer: Peer, game_id: str) -> set[str]:
return {
item["data"]["peer_addr"]
for item in peer.output
if item.get("type") == "event"
and item.get("event") == "download-chunk-finished"
and item.get("data", {}).get("game_id") == game_id
}
def assert_no_duplicate_chunks(peer: Peer, game_id: str) -> None:
seen: set[tuple[str | None, int]] = set()
for item in peer.output:
if item.get("type") != "event" or item.get("event") != "download-chunk-finished":
continue
data = item["data"]
if data.get("game_id") != game_id:
continue
key = (data.get("relative_path"), int(data.get("offset", 0)))
if key in seen:
raise ScenarioError(f"{peer.name} downloaded a duplicate chunk for {game_id}: {key}")
seen.add(key)
def count_events(peer: Peer, event: str, game_id: str) -> int:
return sum(
1
for item in peer.output
if item.get("type") == "event"
and item.get("event") == event
and item.get("data", {}).get("game_id") == game_id
)
def peer_advertised_version(
observer: Peer, peer_id: str | None, game_id: str, timeout: float = 20
) -> str | None:
"""Returns the raw `eti_version` a peer advertises for a game in its library
snapshot (list-peers). Unlike the list-games `remote` rows, this value is NOT
synthesized from the local catalog, so it faithfully reports the source.
Polls until the peer's library snapshot is observed."""
if peer_id is None:
raise ScenarioError("cannot read advertised version without peer_id")
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
for peer in observer.list_peers():
if peer.get("peer_id") != peer_id:
continue
for game in peer.get("games", []):
if game.get("id") == game_id:
return game.get("eti_version")
time.sleep(0.4)
raise ScenarioError(f"{observer.name} does not see {game_id} advertised by {peer_id}")
def diff_game_dirs(source: Path, destination: Path) -> None:
source_manifest = manifest(source)
destination_manifest = manifest(destination)
@@ -373,4 +373,53 @@ mod tests {
assert_eq!(games[0].id, "remote-game");
assert_eq!(games[0].peer_count, 1);
}
#[tokio::test]
async fn inbound_hello_from_self_is_ignored() {
// Protocol-level self-detection: a hello whose peer_id matches the local
// peer id must be acknowledged but never recorded as a peer. The CLI
// harness short-circuits self-connects with a string compare before any
// network call, so this guard (handshake.rs) is only exercised here.
let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new()));
let ctx = Ctx::new(
peer_game_db.clone(),
"local-peer".to_string(),
PathBuf::new(),
PathBuf::new(),
Arc::new(NoopUnpacker),
CancellationToken::new(),
TaskTracker::new(),
Arc::new(RwLock::new(GameCatalog::empty())),
Arc::new(RwLock::new(HashMap::new())),
Arc::new(crate::NoopStreamInstallProvider),
);
*ctx.local_peer_addr.write().await = Some(addr([127, 0, 0, 1], 4000));
let (tx_notify_ui, mut rx_notify_ui) = mpsc::unbounded_channel();
let peer_ctx = ctx.to_peer_ctx(tx_notify_ui);
let self_hello = Hello {
peer_id: "local-peer".to_string(),
proto_ver: PROTOCOL_VERSION,
listen_addr: addr([127, 0, 0, 1], 4000),
library: LibrarySnapshot {
library_rev: 9,
games: vec![summary("self-game")],
},
features: Vec::new(),
};
let ack = accept_inbound_hello(&peer_ctx, None, self_hello)
.await
.expect("self hello should still be acknowledged");
assert_eq!(ack.peer_id, "local-peer");
assert!(
peer_game_db.read().await.peer_snapshots().is_empty(),
"self must never be recorded as a peer"
);
assert!(
rx_notify_ui.try_recv().is_err(),
"self hello must emit no peer discovery events"
);
}
}
@@ -225,6 +225,7 @@ async fn unrar_listing(program: &Path, archive: &Path) -> eyre::Result<RarListin
.arg("lt")
.arg("-cfg-")
.arg(archive)
.kill_on_drop(true)
.output()
.await?;
if !output.status.success() {
@@ -329,6 +330,9 @@ async fn stream_unrar_entries(
.arg(archive)
.stdout(Stdio::piped())
.stderr(Stdio::null())
// Safety net: if this task is dropped before its cancel/error path runs
// (e.g. on shutdown), tokio still kills unrar instead of orphaning it.
.kill_on_drop(true)
.spawn()?;
let result = async {
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/softlan-icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -1,6 +0,0 @@
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 B

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 37 KiB

@@ -3,7 +3,12 @@ use std::{
fs::{self, OpenOptions},
io::{self, Read as _, Seek as _, SeekFrom, Write as _},
path::{Component, Path, PathBuf},
sync::{Arc, Mutex, OnceLock},
sync::{
Arc,
Mutex,
OnceLock,
atomic::{AtomicU64, Ordering},
},
time::{Duration, SystemTime, UNIX_EPOCH},
};
@@ -27,7 +32,10 @@ use lanspread_peer::{
start_peer_with_options,
};
use tauri::{AppHandle, Emitter as _, Manager};
use tauri_plugin_shell::{ShellExt, process::Command};
use tauri_plugin_shell::{
ShellExt,
process::{Command, CommandChild, CommandEvent},
};
use tokio::sync::{
RwLock,
mpsc::{UnboundedReceiver, UnboundedSender},
@@ -92,8 +100,32 @@ struct LanSpreadState {
main_log_sink: OnceLock<MainLogSink>,
active_outbound_transfers: OutboundTransfers,
outbound_transfer_emit: Arc<RwLock<OutboundTransferEmitState>>,
/// Live unrar sidecar processes, so they can be killed if the launcher exits
/// mid-unpack. The shell plugin does not kill spawned children on app exit,
/// and the OS does not cascade-kill child processes, so without this an
/// in-progress unrar keeps running after the launcher closes.
active_unrar_children: Arc<Mutex<UnrarChildRegistry>>,
}
/// Live unrar sidecar children plus a shutdown latch.
///
/// Children are keyed by a monotonic id (not pid) so a finishing install never
/// deregisters another install's child after a pid is recycled. The
/// `shutting_down` latch closes a time-of-check/time-of-use hole: the exit kill
/// sweep drains the map only once, so a child registered *after* the sweep (an
/// install task caught between `spawn()` and registration, or a later archive in
/// a multi-archive install — `unpack_archives` does not observe the shutdown
/// token) would be orphaned. Once the latch is set under this mutex, registration
/// kills the child immediately instead of inserting it where nothing will reap it.
#[derive(Default)]
struct UnrarChildRegistry {
shutting_down: bool,
children: HashMap<u64, CommandChild>,
}
/// Monotonic id source for [`UnrarChildRegistry`] entries.
static UNRAR_CHILD_SEQ: AtomicU64 = AtomicU64::new(0);
#[derive(Clone, Debug, PartialEq, Eq)]
struct InstallSettings {
account_name: String,
@@ -1303,16 +1335,19 @@ async fn run_unrar_sidecar(
paths: &UnrarPaths,
started_at_ms: u64,
) -> eyre::Result<()> {
let out = match sidecar
// Spawn (instead of `.output()`) so we keep a killable handle. The shell
// plugin's `output()` drops the `CommandChild` immediately and only drains
// the event channel, leaving the unrar process orphaned if the launcher
// exits before extraction finishes.
let (mut events, child) = match sidecar
.arg("x") // extract files
.arg(&paths.archive)
.arg("-y") // Assume Yes on all queries
.arg("-o") // Set overwrite mode
.arg(&paths.destination_arg)
.output()
.await
.spawn()
{
Ok(out) => out,
Ok(spawned) => spawned,
Err(err) => {
let stderr = format!("failed to run unrar sidecar: {err}");
record_unpack_failure(
@@ -1327,10 +1362,42 @@ async fn run_unrar_sidecar(
}
};
let stdout = clean_terminal_log(&String::from_utf8_lossy(&out.stdout));
let stderr = clean_terminal_log(&String::from_utf8_lossy(&out.stderr));
let status_code = out.status.code();
let success = out.status.success();
// Register the live child so a launcher exit can kill it, and deregister it
// automatically on every exit path via the RAII guard.
let registry = app_handle
.state::<LanSpreadState>()
.active_unrar_children
.clone();
let child_id = UNRAR_CHILD_SEQ.fetch_add(1, Ordering::Relaxed);
register_unrar_child(&registry, child_id, child);
let _child_guard = UnrarChildGuard { registry, child_id };
let mut stdout_bytes = Vec::new();
let mut stderr_bytes = Vec::new();
let mut status_code = None;
while let Some(event) = events.recv().await {
match event {
CommandEvent::Stdout(line) => {
stdout_bytes.extend(line);
stdout_bytes.push(b'\n');
}
CommandEvent::Stderr(line) => {
stderr_bytes.extend(line);
stderr_bytes.push(b'\n');
}
CommandEvent::Terminated(payload) => {
status_code = payload.code;
}
CommandEvent::Error(err) => {
log::warn!("unrar sidecar event error: {err}");
}
_ => {}
}
}
let stdout = clean_terminal_log(&String::from_utf8_lossy(&stdout_bytes));
let stderr = clean_terminal_log(&String::from_utf8_lossy(&stderr_bytes));
let success = status_code == Some(0);
record_unpack_log(
app_handle,
@@ -1360,6 +1427,79 @@ async fn run_unrar_sidecar(
Ok(())
}
/// Tracks a spawned unrar sidecar so the launcher can kill it on shutdown.
///
/// If shutdown has already begun (or the registry is poisoned), the child is
/// killed immediately instead of inserted, since the exit kill sweep has already
/// run and would never reap a late registration.
fn register_unrar_child(
registry: &Arc<Mutex<UnrarChildRegistry>>,
child_id: u64,
child: CommandChild,
) {
let Ok(mut guard) = registry.lock() else {
// A poisoned registry means we can no longer guarantee the child is
// killed on exit, so kill it now rather than risk orphaning it.
let pid = child.pid();
log::warn!("unrar child registry is poisoned; killing pid {pid} immediately");
if let Err(err) = child.kill() {
log::warn!("Failed to kill untracked unrar child (pid {pid}): {err}");
}
return;
};
if guard.shutting_down {
drop(guard);
let pid = child.pid();
log::info!("Killing unrar child (pid {pid}) that spawned during shutdown");
if let Err(err) = child.kill() {
log::warn!("Failed to kill unrar child (pid {pid}) spawned during shutdown: {err}");
}
return;
}
guard.children.insert(child_id, child);
}
/// Removes an unrar sidecar from the registry when its `run_unrar_sidecar` call
/// returns, regardless of success, error, or early bail.
struct UnrarChildGuard {
registry: Arc<Mutex<UnrarChildRegistry>>,
child_id: u64,
}
impl Drop for UnrarChildGuard {
fn drop(&mut self) {
if let Ok(mut guard) = self.registry.lock() {
guard.children.remove(&self.child_id);
}
}
}
/// Kills every in-progress unrar sidecar and latches the registry into shutdown
/// so any install task that spawns unrar after this point kills it on
/// registration. Called on app exit so a game install that is mid-extraction does
/// not leave `unrar` running after the launcher closes.
fn kill_active_unrar_children(app_handle: &AppHandle) {
let state = app_handle.state::<LanSpreadState>();
let children = {
let Ok(mut guard) = state.active_unrar_children.lock() else {
log::warn!("unrar child registry is poisoned; cannot kill children on shutdown");
return;
};
guard.shutting_down = true;
guard.children.drain().collect::<Vec<_>>()
};
for (_child_id, child) in children {
let pid = child.pid();
match child.kill() {
Ok(()) => log::info!("Killed in-progress unrar child (pid {pid}) on shutdown"),
Err(err) => log::warn!("Failed to kill unrar child (pid {pid}) on shutdown: {err}"),
}
}
}
async fn record_unpack_failure(
app_handle: &AppHandle,
archive: String,
@@ -2249,6 +2389,10 @@ pub fn run() {
.expect("error while building tauri application")
.run(|app_handle, event| {
if matches!(event, tauri::RunEvent::Exit) {
// Kill unrar first: an in-progress extraction would otherwise keep
// running after the launcher closes, and killing it lets the
// install task unwind so the peer runtime can stop promptly.
kill_active_unrar_children(app_handle);
shutdown_peer_runtime(app_handle);
}
});
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

@@ -1,11 +1,18 @@
import { LiveLogo } from './LiveLogo';
interface Props {
accent: string;
peerCount: number;
}
export const Brand = ({ peerCount }: Props) => (
export const Brand = ({ accent, peerCount }: Props) => (
<div className="brand">
<div className="brand-mark">S</div>
<div className="brand-name">SoftLAN</div>
<LiveLogo className="brand-mark" accent={accent} size={28} />
{/* Two-tone wordmark per design/logo/INTEGRATION.md: "Soft" in --t-1,
"LAN" always in the accent. */}
<div className="brand-name">
Soft<span className="brand-name-lan">LAN</span>
</div>
{peerCount > 0 && (
<span
className="brand-peers"
@@ -0,0 +1,280 @@
// The LIVE animated pixel "S" for SoftLAN. At rest it is the baseline pixel S;
// on hover (and at random idle moments) it "comes alive": it slithers like the
// game Snake across the 5×5 grid and reforms, glitches sideways, or splits into
// an RGB datamosh. Exposes play(trick) via ref.
//
// Grid geometry matches the static marks in design/design_reference/logo/assets
// exactly (viewBox 0 0 100 100, cell 17, offset 7.5, gap 2.4, radius 3) so the
// swap between the static and animated S is pixel-identical.
import {
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
type ReactElement,
type Ref,
} from 'react';
// ── shared grid geometry ──
const LCELL = 17;
const LOFF = 7.5;
const LGAP = 2.4;
const LR = 3;
const LSIZE = LCELL - LGAP; // 14.6
// the "S" as an ORDERED walk (tail → head). Each step is grid-adjacent.
// head = (0,4) bottom-left, tail = (4,0) top-right.
const SORD: ReadonlyArray<readonly [number, number]> = [
[4, 0], [3, 0], [2, 0], [1, 0], [0, 0], // top bar (R→L)
[0, 1], // left drop
[0, 2], [1, 2], [2, 2], [3, 2], [4, 2], // middle bar (L→R)
[4, 3], // right drop
[4, 4], [3, 4], [2, 4], [1, 4], [0, 4], // bottom bar (R→L)
];
// boustrophedon sweep of the whole 5×5 board, (0,4) → (4,0) — the Snake "playfield".
const BOUS: ReadonlyArray<readonly [number, number]> = (() => {
const b: Array<[number, number]> = [];
for (let col = 0; col < 5; col++) {
const rows = col % 2 === 0 ? [4, 3, 2, 1, 0] : [0, 1, 2, 3, 4];
rows.forEach(r => b.push([col, r]));
}
return b; // 25 cells, b[0]=(0,4), b[24]=(4,0)
})();
// full head path P: lay the S, sweep the board, re-lay the S.
const PATH: ReadonlyArray<readonly [number, number]> = (() => {
const p: Array<[number, number]> = SORD.map(c => [c[0], c[1]]); // 0..16 (full S)
for (let i = 1; i < BOUS.length; i++) p.push([BOUS[i][0], BOUS[i][1]]); // 17..40
for (let i = 1; i < SORD.length; i++) p.push([SORD[i][0], SORD[i][1]]); // 41..56
return p; // length 57
})();
const I0 = 16;
const I1 = PATH.length - 1; // animate head index 16 → 56
// snake length schedule: full S → short snake → full S
function lenAt(i: number): number {
if (i <= 16) return 17;
if (i <= 26) return Math.round(17 + (7 - 17) * ((i - 16) / 10));
if (i <= 40) return 7;
if (i <= 56) return Math.round(7 + (17 - 7) * ((i - 40) / 16));
return 17;
}
// hex → rgb mixed toward white(t>0)/black(t<0)
function lshade(hex: string, t: number): string {
let h = (hex || '#000').replace('#', '');
if (h.length === 3) h = h.split('').map(x => x + x).join('');
let r = parseInt(h.slice(0, 2), 16);
let g = parseInt(h.slice(2, 4), 16);
let b = parseInt(h.slice(4, 6), 16);
const tgt = t >= 0 ? 255 : 0;
const a = Math.min(1, Math.abs(t));
r = Math.round(r + (tgt - r) * a);
g = Math.round(g + (tgt - g) * a);
b = Math.round(b + (tgt - b) * a);
return `rgb(${r},${g},${b})`;
}
function px(col: number, row: number): { x: number; y: number } {
return { x: LOFF + col * LCELL + LGAP / 2, y: LOFF + row * LCELL + LGAP / 2 };
}
interface CellProps {
col: number;
row: number;
fill: string;
opacity?: number;
dx?: number;
dy?: number;
w?: number;
h?: number;
rx?: number;
}
function Cell({ col, row, fill, opacity = 1, dx = 0, dy = 0, w = LSIZE, h = LSIZE, rx = LR }: CellProps) {
const { x, y } = px(col, row);
return <rect x={x + dx} y={y + dy} width={w} height={h} rx={rx} fill={fill} opacity={opacity} />;
}
// ── renderers ──
function renderStatic(accent: string): ReactElement[] {
return SORD.map(([c, r], i) => <Cell key={i} col={c} row={r} fill={accent} />);
}
function renderSnake(p: number, accent: string): ReactElement[] {
const i = Math.min(I1, Math.max(I0, I0 + Math.round(p * (I1 - I0))));
const L = lenAt(i);
const start = Math.max(0, i - L + 1);
const body = PATH.slice(start, i + 1);
const n = body.length;
// over the final stretch, ease the per-cell gradient/opacity/head-grow toward
// the flat, uniform static S so the hand-off to renderStatic is seamless.
const settle = Math.max(0, Math.min(1, (p - 0.82) / 0.18));
return body.map((cell, j) => {
const t = n > 1 ? j / (n - 1) : 1; // 0 tail → 1 head
const fill = lshade(accent, (t - 0.45) * 0.5 * (1 - settle)); // head brighter, tail darker
const opacity = (0.42 + 0.58 * t) + (1 - (0.42 + 0.58 * t)) * settle;
const isHead = j === n - 1;
const grow = (isHead ? 1.06 : 1) * (1 - settle) + 1 * settle;
const s = LSIZE * grow;
const d = (LSIZE - s) / 2;
return <Cell key={j} col={cell[0]} row={cell[1]} fill={fill} opacity={opacity} dx={d} dy={d} w={s} h={s} />;
});
}
const GLX = [11, -7, 13, -5, 9]; // per-row horizontal kick
function renderGlitch(p: number, accent: string): ReactElement[] {
const burst = Math.max(0, 1 - p) * (0.45 + 0.55 * Math.abs(Math.sin(p * 26)));
return SORD.map(([c, r], i) => {
const dx = GLX[r] * burst;
const drop = burst > 0.55 && Math.sin(i * 12.9 + p * 50) > 0.82; // occasional dropout
const flick = 0.7 + 0.3 * Math.abs(Math.sin(p * 70 + i));
return <Cell key={i} col={c} row={r} fill={accent} opacity={drop ? 0.12 : flick} dx={dx} />;
});
}
function renderRGB(p: number, accent: string): ReactElement[] {
const burst = Math.pow(Math.max(0, 1 - p), 1.25);
const jit = Math.sin(p * 52) * 0.5 + Math.sin(p * 31) * 0.5;
const off = (4.5 + 2.5 * jit) * burst;
const voff = 1.6 * jit * burst;
const layers = [
{ fill: '#ff2d55', dx: -off, dy: -voff, op: 0.8 },
{ fill: '#21e3a8', dx: off, dy: voff, op: 0.8 },
{ fill: accent, dx: 0, dy: 0, op: 1 },
];
return layers.flatMap((ly, li) =>
SORD.map(([c, r], i) => (
<g key={`${li}-${i}`} style={{ mixBlendMode: li < 2 ? 'screen' : 'normal' }}>
<Cell col={c} row={r} fill={ly.fill} opacity={ly.op} dx={ly.dx} dy={ly.dy} />
</g>
))
);
}
export type Trick = 'snake' | 'rgb' | 'glitch';
const RENDER: Record<Trick, (p: number, accent: string) => ReactElement[]> = {
snake: renderSnake,
glitch: renderGlitch,
rgb: renderRGB,
};
const TRICK_DUR: Record<Trick, number> = { snake: 2600, glitch: 1100, rgb: 1350 };
// snake is the long (~2.6s) headline trick, so it's deliberately rare —
// half as likely as either of the short glitch/rgb flickers.
const POOL: ReadonlyArray<Trick> = ['snake', 'rgb', 'rgb', 'glitch', 'glitch'];
function randomTrick(): Trick {
return POOL[Math.floor(Math.random() * POOL.length)];
}
const prefersReducedMotion = (): boolean =>
typeof window !== 'undefined' &&
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
export interface LiveLogoHandle {
/** Trigger a specific trick (ignored if one is already playing). */
play: (trick: Trick) => void;
isPlaying: () => boolean;
}
interface Props {
/** The S color (any CSS color). Wire this to the theme accent. */
accent?: string;
/** Rendered width/height in px (square SVG). */
size?: number;
/** When true, plays a random trick by itself every idleMinidleMax ms. */
idleAuto?: boolean;
idleMin?: number;
idleMax?: number;
className?: string;
/** Accessible label for the brand mark. */
label?: string;
ref?: Ref<LiveLogoHandle>;
}
export function LiveLogo({
accent = '#3b82f6',
size = 140,
idleAuto = true,
idleMin = 5000,
idleMax = 11000,
className,
label = 'SoftLAN',
ref,
}: Props) {
const [anim, setAnim] = useState<{ trick: Trick; p: number } | null>(null);
const [reduce, setReduce] = useState(prefersReducedMotion);
const rafRef = useRef(0);
const playingRef = useRef(false);
const play = useCallback((trick: Trick) => {
if (playingRef.current) return;
playingRef.current = true;
const dur = TRICK_DUR[trick];
const t0 = performance.now();
const step = (now: number) => {
const p = (now - t0) / dur;
if (p >= 1) {
setAnim(null);
playingRef.current = false;
return;
}
setAnim({ trick, p });
rafRef.current = requestAnimationFrame(step);
};
setAnim({ trick, p: 0 });
rafRef.current = requestAnimationFrame(step);
}, []);
useImperativeHandle(ref, () => ({ play, isPlaying: () => playingRef.current }), [play]);
// Track prefers-reduced-motion live so toggling the OS setting takes effect.
useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
const onChange = () => setReduce(mq.matches);
onChange();
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
}, []);
// Idle auto-play on a jittered timer — paused in hidden tabs and when the
// user prefers reduced motion (at rest it stays a clean static S).
useEffect(() => {
if (!idleAuto || reduce) return;
let timer: ReturnType<typeof setTimeout>;
const schedule = () => {
timer = setTimeout(() => {
if (!document.hidden && !playingRef.current) play(randomTrick());
schedule();
}, idleMin + Math.random() * (idleMax - idleMin));
};
schedule();
return () => clearTimeout(timer);
}, [idleAuto, idleMin, idleMax, reduce, play]);
useEffect(() => () => cancelAnimationFrame(rafRef.current), []);
const content = anim ? RENDER[anim.trick](anim.p, accent) : renderStatic(accent);
return (
<svg
viewBox="0 0 100 100"
width={size}
height={size}
fill="none"
role="img"
aria-label={label}
className={className}
style={{ cursor: 'pointer', overflow: 'visible' }}
onMouseEnter={() => { if (!reduce) play(randomTrick()); }}
onClick={() => { if (!reduce) play('snake'); }}
>
{content}
</svg>
);
}
@@ -8,6 +8,7 @@ import { FilterCounts } from '../../lib/gameState';
import { GameFilter, GameSort } from '../../lib/types';
interface Props {
accent: string;
peerCount: number;
filter: GameFilter;
setFilter: (value: GameFilter) => void;
@@ -20,6 +21,7 @@ interface Props {
}
export const TopBar = ({
accent,
peerCount,
filter,
setFilter,
@@ -32,7 +34,7 @@ export const TopBar = ({
}: Props) => (
<header className="topbar">
<div className="topbar-left">
<Brand peerCount={peerCount} />
<Brand accent={accent} peerCount={peerCount} />
<div className="topbar-left-trail">
<SegmentedFilters value={filter} onChange={setFilter} counts={counts} />
</div>
@@ -144,26 +144,25 @@
gap: 10px;
flex-shrink: 0;
}
/* The live pixel-S logo renders its own self-contained SVG; this just reserves
its 28px slot and lets the animation's glitch/datamosh spill past the edges. */
.brand-mark {
display: block;
flex-shrink: 0;
width: 28px;
height: 28px;
border-radius: 7px;
display: grid;
place-items: center;
background: var(--accent);
font-family: var(--font-display);
font-size: 20px;
letter-spacing: 0.02em;
color: white;
box-shadow:
0 6px 20px -6px color-mix(in srgb, var(--accent) 60%, black),
inset 0 1px 0 rgba(255, 255, 255, 0.22);
overflow: visible;
}
.brand-name {
font-family: var(--font-ui);
font-weight: 700;
font-size: 15px;
letter-spacing: -0.005em;
color: var(--t-1);
letter-spacing: -0.01em;
color: var(--t-1); /* "Soft" — #e6edf3 on dark UI */
}
/* "LAN" is always the accent (per the logo wordmark spec). */
.brand-name-lan {
color: var(--accent);
}
/* Peer count chip in brand area */
@@ -144,6 +144,7 @@ export const MainWindow = () => {
return (
<div className={className} style={rootStyle}>
<TopBar
accent={settings.accent}
peerCount={games.totalPeerCount}
filter={settings.filter}
setFilter={(v) => setSetting('filter', v)}
+70 -598
View File
@@ -1,618 +1,90 @@
# Handoff: SoftLAN Launcher redesign
# SoftLAN Launcher — Design Handoff
A modern, gamer-friendly redesign of the SoftLAN local-network game launcher, replacing the current basic UI with a Steam-inspired dark layout that keeps high usability while adding cover art, state-coded actions, a game-detail overlay, and an in-app Settings dialog.
**This folder is the complete, current state of design for the SoftLAN Launcher.**
Everything an implementor needs to build the product is in here — and nothing
that isn't. (The exploration mockups, logo concept boards, and variant studies
live back in the project workspace; they're history, not handoff.)
Target codebase: **Tauri + React** desktop app. The references here are
HTML/React prototypes that communicate the intended look, layout, and behavior —
recreate them inside the codebase using its own patterns. They are not meant to
be shipped as-is.
---
## About the design files
## What's inside
The files in `design_reference/` are **design references created in HTML/React via Babel-in-the-browser** — prototypes built to communicate the intended look, layout, and behavior. They are **not production code to copy directly**.
The target codebase is a **Tauri + React** desktop app. The task is to **recreate these designs inside that codebase**, using its existing patterns (component conventions, state management, routing, IPC to Rust for filesystem / process work). Use the design files for:
- Exact pixel/spacing/color/typography values
- Component composition and interactions
- Copy and microcopy
- Animation easings/durations
But:
- Don't ship the Babel-in-browser setup or import the .jsx files as-is
- Don't keep the `<deck>` / design-canvas wrapping — that's only for presenting variants
- Don't ship the Tweaks panel — it's superseded by the in-app **Settings dialog** (see "Screens" below)
- Re-implement using whatever the codebase uses (Vite + plain JSX, CSS modules / styled-components / tailwind, etc.)
## Fidelity
**High-fidelity.** Final colors, typography, spacing, and interactions are decided. Pixel-fidelity to the mock is the goal — recreate exactly, using the codebase's libraries/patterns. Only deviate where the codebase has its own dictate (e.g. an existing button primitive that's near-identical).
## Layout variants
The HTML mock includes two chrome variants — **A (single-row)** and **B (two-row)** — to choose from. **The user selected A as the primary direction.** Implement A. Variant B is left in the reference for context only.
```
design_handoff_softlan_launcher/
├── README.md ← you are here — start here
├── launcher/ ← THE APP
│ ├── SPEC.md full spec: every screen, component, token, interaction
│ └── design_reference/ the working prototype the spec describes
│ ├── SoftLAN Launcher.html open this to see all screens/variants
│ ├── styles.css
│ ├── data.jsx mock catalog + LAN peer roster + format helpers
│ ├── components.jsx Icon set, cards, modals, Settings dialog
│ ├── ctp-chat.jsx Call-to-Play chat panel + shared helpers
│ ├── calltoplay.jsx Call-to-Play: quick bars, overlay, create form
│ └── launcher.jsx composes chrome + grid + modals + Call to Play
└── logo/ ← THE BRAND MARK
├── INTEGRATION.md how to ship the logo (component + assets + lockup)
├── pixel-live.jsx the live animated "S" + Wordmark + Lockup components
├── demo.html open to see the mark animate + the lockup
└── assets/
├── softlan-mark.svg static S (currentColor)
├── softlan-mark-blue.svg static S (fixed blue)
├── softlan-tile.svg app-icon tile (gradient + white S)
├── softlan-lockup.svg full lockup (icon + wordmark), on dark
├── softlan-lockup-ink.svg full lockup, on light
└── favicon.svg = tile, drop-in favicon
```
---
## Changes since v3
## Two pieces, one product
- **Game-folder button removed from the top bar.** Setting the games directory is a one-time action — it doesn't deserve permanent real estate in the chrome. The button is gone from both top-bar variants, freeing the right zone for the kebab menu alone (variant A) / the storage meter + kebab pair (variant B).
- **Game folder moved into Settings → Library.** Now a row inside the Settings dialog, styled like the other Library rows. Two visual states (set / not-set) carry over from the old button — see "Settings dialog → Library → Game folder" below.
- **Persisted setting renamed.** `gameFolderSet: boolean``gameFolder: string | null`. The actual path is now persisted, not just a "is it configured?" flag. Default is `null` (unset on first run; user must pick a folder before the library scans).
## Changes since v2
- **Top bar layout reorganized.** The single-row top bar is now structured as three visual zones (still one row on wide windows):
- **Left:** brand mark + wordmark.
- **Center (semantically the "search cluster"):** segmented filter pills · search field · sort menu. The **search field is positioned at the geometric center of the window** — filter pills sit immediately to its left, sort menu immediately to its right.
- **Right:** kebab menu (game-folder configuration has moved into Settings — see v3 changes).
- Below ~1100 px of launcher width (container query), the three zones collapse into a single left-to-right flowing row (no wrap, no centering). Implement via container query on the launcher root; viewport media query is acceptable if your codebase doesn't use container queries yet.
- See "Top bar (variant A)" below for the full spec and rationale.
## Changes since v1
- **Settings → Profile section** added at the top of the dialog with two new persisted preferences: **Username** (text input) and **Language** (segmented `English` / `Deutsch`). See "Settings dialog" below for shape + persistence keys.
- **Start Server** action added to the **game detail overlay**, next to **Play**, for installed games that support a dedicated server. Driven by a new `canHostServer: true` flag on the game record. See "Detail overlay → Actions row" and "Game data shape" for the full spec.
- Grid cards are **unchanged** — Start Server only ever appears in the detail overlay.
---
## Screens / views
### 1. Main library (variant A — primary)
The default screen. A grid of game cards over a dark, gradient-tinted background.
**Layout (top-to-bottom):**
1. **Top bar** — single row, sticky, full width, 64px tall, semi-transparent dark with backdrop-blur. Background `rgba(10,14,19,0.65)` + `backdrop-filter: blur(20px) saturate(140%)`. Border-bottom `1px solid rgba(255,255,255,0.06)`. Padding `14px 24px`. **Layout:** a 3-column CSS grid — `grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr)` with `column-gap: 16px` — putting the search field in the middle (auto-sized) column so it sits at the **geometric center of the window** regardless of how wide the side groups are. The side columns are each `display: flex; justify-content: space-between` so their contents pin to the outer edge on one end and hug the search on the other.
- **Left zone (col 1, flex space-between):**
- **Brand** (pinned far-left) — 28×28 px rounded square in `--accent` (default `#3b82f6`) with the letter "S" in Bebas Neue 20 px white. Next to it, the wordmark "SoftLAN" in 15 px / 700 weight `--t-1` `#e6edf3`.
- **Segmented filter pills** (pinned right, hugging the search field) — pill-shaped container (`background var(--bg-2) #131b25`, `1px solid rgba(255,255,255,0.06)`, `border-radius: 999px`, `padding: 4px`). Three buttons:
- `All Games` · count chip
- `Local` · count chip
- `Installed` · count chip
Active button has an animated pill thumb (background `var(--accent)`, transitions `left` and `width` with `cubic-bezier(.4,1.2,.5,1)` over 220 ms), text becomes white, count-chip background goes `rgba(0,0,0,0.25)`. Inactive: text `var(--t-2) #9aa6b4`, count-chip background `rgba(255,255,255,0.08)`.
`Local` = installed *or* downloaded-but-not-yet-installed. `Installed` = installed only. `All Games` = everything available on the network.
The filter is grouped semantically with the search — it scopes what the user is searching, so it belongs at the search field's left shoulder.
- **Center zone (col 2, search alone):**
- **Search field** — 36 px tall, `flex: 0 1 360px` (caps at 360 px wide so it can't elbow into the side zones). `background var(--bg-2)`, `1px solid var(--bd-1)`, `border-radius: 8px`, padding `0 12px`. Leading magnifying-glass icon (14×14, `currentColor`) and a trailing "/" kbd hint (`background rgba(255,255,255,0.06)`, `border-radius: 4px`, font `11px ui-monospace`). On focus: border `color-mix(in srgb, var(--accent) 60%, var(--bd-2))`, background `var(--bg-1)`, ring `box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent)`. The `/` key shortcut should focus the search.
- **Right zone (col 3, flex space-between with two sub-groups):**
- **Sort menu** (pinned left, hugging search) — 36 px button, same surface style as search. Label `Sort: <bold value>` plus 13 px sort-bars icon and 11 px chevron. Click reveals dropdown menu below. Options: `Name (AZ)`, `Size (largest)`, `Recently Played`, `Status`. This is the only thing on the *left* side of the right zone — it's part of the search cluster, so it hugs the search.
- **Kebab menu** (`⋮`, pinned far-right) — 36×36 button with same surface as search. Menu items: `Settings` (opens Settings dialog), `Refresh library`, separator, `Unpack logs`, `About SoftLAN`. This is the only "app-level" control left in the top bar; the game-folder picker has moved into Settings.
**Narrow-window fallback** (container width < 1100 px): the grid is replaced by a single `display: flex; flex-wrap: nowrap; gap: 16px` row. All items align left-to-right in source order (brand → filter → search → sort → kebab). The search field becomes `flex: 1 1 auto` so it absorbs remaining slack. The geometric centering is abandoned at narrow widths because there isn't enough horizontal slack for it to read cleanly. Implement via container query (`@container launcher (max-width: 1100px)`) on the launcher root; a viewport media query is an acceptable fallback if you're not using container queries yet.
2. **Results bar** — 18px top padding inside the scroll wrapper, 24px horizontal. Flex row with space-between:
- Left: `Showing <strong>N</strong> of M games` in 12.5px `var(--t-2)` (strong is `var(--t-1)`).
- Right: compact **storage meter** — 200px min-width, 4px-tall horizontal bar with two stacked segments (`installed` and `local`), plus a 11px text row underneath: `<sq> 78 GB installed <sq> 41 GB local 384 GB free`. Squares are 8×8px rounded 2px, colored `var(--accent)` and `color-mix(var(--accent), 55%)`.
3. **Grid** — CSS grid with `repeat(auto-fill, minmax(188px, 1fr))` at default density, 16px gap, 24px horizontal padding, 32px bottom padding. Scrolls vertically.
- Density: `compact` → min 148, gap 12. `normal` → min 188, gap 16. `large` → min 244, gap 20.
**Game card** (see "Game card" below for full anatomy).
---
### 2. Game detail overlay
Opens when the user **clicks anywhere on a game card except the action button**. Modal over a scrim. Closes on scrim click, Esc key, or the close button. Should also work via keyboard nav (Enter on focused card).
**Scrim:** absolutely positioned over the launcher, `inset: 0`, `z-index: 100`, `background: rgba(4,7,11,0.7)`, `backdrop-filter: blur(8px)`, fade-in 180ms. Padding 32px, content centered.
**Modal panel:** `min(880px, 100%)` wide, `background: linear-gradient(180deg, var(--bg-2) 0%, var(--bg-1) 100%)`, `1px solid var(--bd-2)`, `border-radius: 14px`, drop shadow `0 30px 80px -10px rgba(0,0,0,0.7)`. Scales in from 0.96 with 250ms `cubic-bezier(.3,1.3,.4,1)`.
**Modal structure (top-to-bottom):**
1. **Hero banner**`aspect-ratio: 16/7`. Full-bleed cover art rendered as a banner (same gradient + accent treatment as the small cards, scaled up). Bottom-fade gradient `linear-gradient(180deg, transparent 40%, var(--bg-2) 100%)` so text reads.
- **State chip** in the top-left of the hero (same chip style as on cards — see Game Card).
- **Close button** top-right: 32×32 square, `background rgba(8,12,16,0.7)`, `1px solid var(--bd-2)`, `border-radius: 8px`, `backdrop-filter: blur(8px)`, X icon.
- **Title overlay** in bottom-left at `left: 28px, right: 28px, bottom: 22px`:
- Tags row — small uppercase pills (`background rgba(8,12,16,0.6)`, `1px solid var(--bd-2)`, `border-radius: 4px`, `padding: 3px 8px`, `font 11px / 600 / 0.04em letter-spacing`)
- **Title** as `<h2>` — system sans 32px / 700 / -0.015em, white, text-shadow `0 4px 24px rgba(0,0,0,0.6)`. **Not Bebas Neue** here — this is normal UI typography, not stylized cover art.
2. **Body** — 22px top, 26px bottom, 28px horizontal:
- **Meta grid** — 4-column CSS grid, 12px gap. Each cell: `padding 10px 12px`, `background rgba(255,255,255,0.025)`, `1px solid var(--bd-1)`, `border-radius: 8px`. Cells (in order): `Size` (e.g. 8.2 GB), `Players` (icon + range), `Version` (mono, e.g. 2018.04.12), `Status` (Installed / Local / Not downloaded).
- **Description** — 14px / 1.55 line-height, `var(--t-2)`, `text-wrap: pretty`, `max-width: 64ch`.
- **Actions row** — flex row, 10px gap, 4px top padding. Order, left → right:
1. **Primary action button** (44px tall, see "Action button" below — Play / Install / Download depending on state).
2. **Start Server***only* when `game.canHostServer === true` **and** `state === 'installed'`. Same 44px height as Play, but visually a peer secondary action (see "Start Server button" below). Triggers a Tauri command that spawns the game's dedicated-server executable in headless mode against the local LAN (port + server config out of scope here — leave a `startServer(gameId)` IPC stub).
3. If `state === 'installed'`: ghost-button **Uninstall** — 44px, `background rgba(255,255,255,0.04)`, `1px solid var(--bd-2)`, `border-radius: 8px`, text `#f87171`, trash icon. On hover: bg `rgba(239,68,68,0.10)`, border `rgba(239,68,68,0.40)`, text `#fca5a5`.
4. If `state === 'local'`: ghost-button **Delete from disk** (same danger styling).
5. If `state === 'downloading'`: ghost-button **Cancel** (same danger styling).
6. Spacer (`flex: 1`).
7. Ghost-button **View files** (neutral) — opens system file manager at the game folder.
#### Start Server button
A secondary-but-equal action that sits next to **Play**. The intent is to read as a host-action ("I want to put this game on the LAN") without competing with the green Play button for the player's primary attention.
- Same shape and height as Play: 44px tall, `border-radius: 8px`, `font 14px / 600`, 8px gap between icon and label, padding `0 22px`.
- Surface: `background: color-mix(in srgb, var(--accent) 14%, rgba(255,255,255,0.04))`, `border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent)`, `box-shadow: inset 0 1px 0 rgba(255,255,255,0.06)`. Text in `--t-1`.
- **Icon** in `--accent`: a small server-rack glyph (two stacked rounded rectangles each with an LED dot and a hint of wiring). 13×13. SVG in `components.jsx → Icon.server`.
- Hover: `background: color-mix(in srgb, var(--accent) 22%, ...)`, border darkens to `color-mix(... 75%, transparent)`. Active: `transform: scale(0.98)` (shared with `.act-btn`).
- A future *running* state (live indicator dot + "Server running" label + click-to-stop) is **not** in this round — flag as a follow-up when wiring the real spawn.
The button is purposefully **not** present on game cards in the grid — hosting a server is intentional and benefits from the context of the detail overlay (player count, version, etc.). Don't add it to cards.
---
### 3. Settings dialog
Opens when the user clicks **Settings** from the kebab menu. Same modal-scrim treatment as the game-detail modal, but the panel is narrower (`min(640px, 100%)`) and styled as a list of preferences.
**Structure:**
```
┌─────────────────────────────────────────┐
│ Settings [×] │ ← head: 22 28 18, 1px bottom border
├─────────────────────────────────────────┤
│ │
│ PROFILE │ ← section title (new): 10.5px / 700 / 0.12em / uppercase / --t-3
│ │
│ Username │ ← row label: 14px / 600 / --t-1
│ Shown to other players on the LAN │ ← row hint: 12px / --t-3
│ [ Enter a username ] │ ← text input (220×36)
│ │
│ Language │
│ Interface language │
│ [English│Deutsch] │ ← segmented radio (new)
│ │
│ APPEARANCE │
│ │
│ Accent color │
│ Used for primary actions and highlights │
│ ⬤⬤⬤⬤⬤⬤ │ ← 6 swatches, right-aligned
│ │
│ Background │
│ Backdrop behind the library │
│ [Flat │Gradient│Animat]│ ← segmented radio
│ │
│ LIBRARY │
│ │
│ Game folder │
│ Parent directory where games are │
│ downloaded and installed │
│ [📁 /home/pfs/…/eti_games [Change…]] │ ← folder field (340×36)
│ │
│ Grid density │
│ How tightly cards are packed │
│ [Compact│Normal│Large]│
│ │
│ Cover aspect │
│ Shape of the cover art on each card │
│ [Box-art│Square│Banner│
│ │
├─────────────────────────────────────────┤
│ [Done] │ ← foot: 14 22 18, 1px top border
└─────────────────────────────────────────┘
```
**Sections** are separated by 26px gap (column flex). Rows within a section: 14px gap. Each **row** is flex row with space-between (24px gap):
- Left (`settings-row-info`): label (14px / 600 / `--t-1`) + hint (3px-top, 12px / `--t-3`)
- Right (`settings-row-control`): the control
**Profile section** (new in this round). Two rows, rendered **above** Appearance — it's the most personal/identity-shaped setting so it's the first thing the user sees in Settings.
- **Username** — `<input type="text">` wrapped in a styled container: 220px wide, 36px tall, `background var(--bg-3)`, `1px solid var(--bd-1)`, `border-radius: 8px`, `padding: 0 12px`. Input itself is transparent/borderless, `font 13.5px / 600`, color `--t-1`, placeholder `"Enter a username"` in `--t-3` / 500. `maxLength={24}`, `spellCheck={false}`. On focus the container gets `background var(--bg-2)`, border `var(--accent)`, and an accent focus ring `box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent)`.
- **Language** — same segmented-radio control as Background / Density / Cover aspect, with two options: `English` (value `'en'`) and `Deutsch` (value `'de'`). Active option gets the accent fill, same as the other segmented radios.
**Library section.** Three rows: **Game folder** (new in v3 — moved out of the top bar), **Grid density**, **Cover aspect**.
- **Game folder** — see "Game-folder field" below. The first row in the section because it's the only setting users *must* configure for the launcher to work; density and aspect are pure preference.
**Color swatch picker:** flex row of 8px-gapped buttons. Each swatch is 32×32, `border-radius: 9px`, no border. Inside, a 100% × 100% rounded-8 colored dot with inset shadow `0 0 0 1px rgba(255,255,255,0.08)`. Hover: dot scales 1.06. **Active**: dot has ring `box-shadow: 0 0 0 2px var(--bg-2), 0 0 0 4px <swatch-color>` and shows a centered white check icon with drop-shadow `0 1px 2px rgba(0,0,0,0.5)`.
Six accent options: Blue `#3b82f6`, Cyan `#22d3ee`, Violet `#a855f7`, Green `#22c55e`, Amber `#f59e0b`, Red `#ef4444`.
**Segmented radio:** inline-flex with `background var(--bg-3) #1a2330`, `1px solid var(--bd-1)`, `border-radius: 8px`, `padding: 3px`. Each button: 30px tall, `padding: 0 14px`, `border-radius: 6px`, `font 12.5px / 600`. Inactive: `color var(--t-2)`. Active: `background var(--accent)`, `color white`, inset top shadow `0 1px 0 rgba(255,255,255,0.18)`.
**Done button:** filled button in `--accent`, 36px tall, 13.5px / 600. Closes the dialog.
Persisted settings (write through to local storage / Tauri config):
- `username`: string, max 24 chars. Default `"Commander"` (placeholder — feel free to default to the OS username on first run). Used as the network identity for LAN sessions; the hint copy *"Shown to other players on the LAN"* tells the user what it does.
- `language`: `'en'` | `'de'`. Default `'en'`. Drives an i18n layer (introduce one if it doesn't exist yet — `react-i18next` or similar). Initial copy is English-only in the mock; German translations need to be added as part of implementation. Recommend detecting the OS locale on first run and defaulting to `'de'` if the system language starts with `de`.
- `accent`: one of the six hex values above. Default `#3b82f6`.
- `bg`: `flat` | `gradient` | `animated`. Default `gradient`.
- `density`: `compact` | `normal` | `large`. Default `normal`.
- `aspect`: `box` | `square` | `banner`. Default `box`.
- `gameFolder`: `string | null`. Absolute path to the parent directory where games are downloaded and installed. Default `null` (unset on first run). See "Game-folder field" below.
---
## Game-folder field
A settings row inside the **Library** section of the Settings dialog. Exposes the user's currently-configured game folder (the parent directory under which all per-game subfolders live).
**Why it lives in Settings now:** users set this once at install time and basically never touch it again. A permanent top-bar button burned high-attention chrome on a control nobody used after day one. Settings is where one-time configuration belongs.
Two visual states, driven by whether `settings.gameFolder` resolves to an accessible directory:
| State | Trigger | Path display | Border | Button label |
|---|---|---|---|---|
| **Set & valid** | path is configured and exists on disk | full path in mono, truncated head-first | default `--bd-1` | `Change…` (neutral pill) |
| **Not set / invalid** | path is `null`/empty, or path is set but the directory no longer exists | `Not set` in red | tinted red (`color-mix(in srgb, var(--danger) 35%, var(--bd-1))`) + faint red bg tint | `Choose…` (accent-filled pill) |
"Invalid" is intentionally collapsed into the same visual state as "not set" — the user's job is identical (open the picker and pick a folder), so we don't differentiate. If we later need a distinct "missing" state (e.g. to show the *last known* path so the user can re-attach an external drive), introduce a third state then; for now, keep it simple.
**Anatomy:** `inline-flex`, `width: 340px`, `height: 36px`, `padding: 0 4px 0 12px`, `gap: 8px`. `background: var(--bg-3)`, `border-radius: 8px`. Children, left to right:
1. **Folder icon**`Icon.folder` from `components.jsx`, 14×14, `var(--t-3)` (set state) or `#f87171` (unset state).
2. **Path display**`flex: 1`, mono `12px / ui-monospace`, `--t-1`, single line, `overflow: hidden; text-overflow: ellipsis`. **`direction: rtl` + `unicode-bidi: plaintext`** so truncation happens from the head and the leaf folder (the part the user actually cares about) stays visible. When unset: shows the word `Not set` in 12.5 px / 600 / `#f87171` instead.
3. **Action button** — 28 px tall pill, `border-radius: 6px`, `padding: 0 12px`, `font 12.5px / 600`. Set state: neutral `rgba(255,255,255,0.06)` bg, label `Change…`. Unset state: `var(--accent)` fill at 85% alpha, white text, label `Choose…` (so the call-to-action reads stronger when the path needs picking). Click → native folder picker via Tauri; on selection, write through to `settings.gameFolder` and rescan library.
**Hover:** border darkens to `--bd-2` (set state) or to `color-mix(in srgb, var(--danger) 55%, var(--bd-2))` (unset state). The inner button has its own hover (background opacity bumps).
**Accessibility:** the path itself is selectable text inside the field; the action button carries `aria-label="Change game folder"` / `"Choose game folder"`. The full path is also exposed via `title` on the path-display element so it's reachable on hover when truncated.
**Why no inline path on the previous top-bar button anymore?** Original design squeezed the full path into a top-bar button as truncated mono. It rarely showed the meaningful part of the path on real-world configurations, ate horizontal space, and competed with the actual primary controls (filter / search / sort) for the top bar's attention budget. In the new home (Settings), the field has all the width it needs to show a useful prefix of the path while still keeping the leaf visible — and it's only on screen when the user is actively reconfiguring.
**Data:** the component takes `value: string | null` and an `onChange(next: string)` callback. `null` (or empty/whitespace string) renders the unset state; any non-empty string renders the set state. The `onChange` callback should fire only on successful picker confirmation (not on cancel). In production, derive `value` from your settings store; if you want to additionally validate existence, do the `fs.metadata` check in the store / a hook and pass `null` when the directory is missing.
**Dev preview:** the prototype's Tweaks panel exposes a `Game folder` **text field** (under the *Library* section) that writes directly to `t.gameFolder`. Type any string to simulate the set state; clear it to simulate the unset state. This is dev-only — in the real app the value comes from the settings store via the picker, **not** from a free-form text input. Don't ship the Tweaks panel.
---
## Game card
The unit element of the library grid.
**Container:** flex column. `background: linear-gradient(180deg, var(--bg-2) 0%, var(--bg-1) 100%)`, `1px solid var(--bd-1)`, `border-radius: 10px`, `overflow: hidden`. Cursor pointer.
**Hover/focus state:**
- `transform: translateY(-2px)` (180ms `cubic-bezier(.4,1.2,.5,1)`)
- `border-color: color-mix(in srgb, var(--accent) 45%, var(--bd-2))`
- Box-shadow `0 14px 30px -16px color-mix(var(--accent), 50%, black), 0 0 0 1px color-mix(var(--accent), 30%, transparent)`
- Cover inner image scales to 1.03 (350ms cubic-bezier)
- Focus-visible: same lift + 2px solid accent outline
### Anatomy (top to bottom)
1. **Cover wrap**`width: 100%`, `aspect-ratio: 2/3` (box) / `1/1` (square) / `16/9` (banner). `position: relative`, `overflow: hidden`, fallback bg `var(--bg-3)`.
2. **Cover** (inside cover-wrap, `position: absolute; inset: 0`):
- **Base gradient** — diagonal (`linear-gradient(<110-170deg>, c1, c2)` — angle hashed from game id for variety). Per-game color pair from the game's `cover` metadata.
- **Radial accent blob** — `radial-gradient(ellipse at <x>% <y>%, <accent>38, transparent 55%)`. x/y also hashed from id.
- **Grain / scanline** — two `repeating-linear-gradient` overlays at 1px intervals, `mix-blend-mode: overlay`, opacity 0.7.
- **Decorative SVG mark** — preserveAspectRatio bottom-right, draws a triangle and dot in the accent color at 12% opacity. Variation via id hash.
- **Title** absolutely positioned at bottom-left, padding `14px`. Font `Bebas Neue` (free Google Font, fallback `Oswald, Impact, "Arial Narrow Bold", sans-serif`), 400 weight, uppercase, `letter-spacing: 0.018em`, `line-height: 1.02`, white, text-shadow `0 4px 16px <c2 + alpha>, 0 1px 0 rgba(0,0,0,0.3)`. Size scales by title length: 26px for ≤14 chars, 21px for ≤20, 17px for ≤26, 15px for longer (box aspect; see `components.jsx → GameCover` for square/banner variants).
- **Vignette** — `linear-gradient(180deg, transparent 30%, rgba(0,0,0,0.62) 100%)` over the whole cover, painted *after* the title (so the dark gradient is behind the title visually — title is z-index 2).
- **State chip** in top-right: pill with backdrop-blur, `background rgba(8,12,16,0.78)`, `1px solid rgba(255,255,255,0.08)`, `border-radius: 999px`, `padding: 4px 9px`, font `10.5px / 600`. A 6×6 colored dot (green `#22c55e` for installed, amber `#f59e0b` for local; hidden for "not downloaded") + label. Dot has glow `box-shadow: 0 0 8px <color>`.
- **Multiplayer badge** in top-left: same pill style but slightly lighter background (`rgba(8,12,16,0.65)`). Tiny "users" icon + player range (e.g. `232`). Always visible — every LAN game is multiplayer.
3. **Card body**`padding: 11px 12px 12px`, flex column, 8px gap:
- **Title** — game's full (mixed-case) title in 13.5px / 600 / `--t-1`, single line, ellipsis on overflow.
- **Meta line** — 11.5px tabular-nums, `--t-3`: size · genre. Dot separator at 50% opacity.
- **Action button** (full width) — primary action depending on state, see below.
### Action button
A single button per card with the *primary action for the current state*. Color-coded as the main affordance for state at a glance.
```
state label button style
───────────── ────────── ────────────────────────────────────────────
not downloaded Download neutral: bg rgba(255,255,255,0.08), 1px var(--bd-2), text var(--t-1)
local Install bg var(--accent), text white, inset top hl
installed Play bg linear-gradient(180deg, #2bd07f 0%, #1aa460 100%), text white, inset top hl
downloading — progress see "Download progress" below — the button slot is replaced with a live progress component
```
Common sizing: 32px tall (card) or 44px tall (modal). `border-radius: 7px` (card) / 8px (modal). `font 12.5px / 600` (card) / `14px / 600` (modal). 6px gap between icon and label. Icons: filled play triangle, download arrow, install arrow-onto-line (all 12×12).
Hover: `filter: brightness(1.12)`. Active: `transform: scale(0.98)`.
**Uninstall / Delete-from-disk** are NOT on the card — only in the detail overlay (as ghost-danger buttons).
---
## Download progress (state === 'downloading')
When a game is actively downloading, the **action-button slot is replaced** by an inline progress component. The component is its own visual primitive (`DownloadProgress` in `components.jsx`); it is NOT a button with a `<progress>` child. Two layouts share the same primitive:
### Shared visuals
- Container: `border-radius: 7px` (card) / `9px` (modal), `1px solid color-mix(in srgb, var(--accent) 45%, var(--bd-2))`, faint accent halo via `box-shadow`. `container-type: inline-size` (we use container queries for graceful fallback, see below).
- **Progress fill** (`.dl-fill`): absolutely positioned, `width: <pct>%`, animated via `transition: width 480ms cubic-bezier(.4,0,.2,1)`. Background is a vertical gradient of `color-mix(in srgb, var(--accent) 3826%, transparent)`. Right edge gets a 1px accent rule + accent glow.
- **Live shimmer** on top of the fill: `repeating-linear-gradient(115deg, transparent 0 14px, rgba(255,255,255,0.05) 14px 22px)` panned via `animation: dl-stripe 1.4s linear infinite`, `mix-blend-mode: screen`. Subtle — it reads as "live" without being distracting.
- **Pulse dot** (`.dl-pulse`): 7px accent dot with an outward-pulsing `box-shadow` ring (1.4s ease-out infinite). Visual cue that the network transfer is active.
- **Tabular numerics** on all values (`font-variant-numeric: tabular-nums`) so the percentage and speed don't jitter as digits roll over.
### Card layout (`.dl-md`, replaces the 32px action button)
A single row. Two values, separated by `justify-content: space-between`:
- **Left:** `<pulse> <pct>%` — 12px / 600, `var(--t-1)`. `%` glyph at 0.55 opacity. e.g. `• 32%`.
- **Right:** `<speed>` — 11px / 500, `var(--t-2)`. Short format: `49 MB/s` (no decimals at card scale).
Heights match the action button per density: 30px compact / 32px normal / 34px large. Padding `0 10px` (9 compact / 12 large). Font sizes scale similarly (see `styles.css`).
**Container-query graceful degradation** — this is the important part, it has to fit every aspect/density combo:
```css
@container (max-width: 132px) { .dl-md .dl-speed { display: none; } .dl-md-row { justify-content: center; gap: 6px; } }
@container (max-width: 96px) { .dl-md .dl-pulse { display: none; } }
```
At 132 px and below, the speed disappears and the percentage centres. At 96 px and below, the pulse dot also drops, leaving just the percentage. This is what guarantees `compact` density + `box` aspect (the narrowest combination) still reads cleanly.
The state chip in the cover corner still says "Downloading" — we are deliberately NOT repeating that label inside the progress bar.
### Detail-overlay layout (`.dl-lg`, replaces the 44px modal action button)
Fixed 56px height. CSS-grid with three columns and two rows:
```
grid-template-columns: minmax(0, 1fr) auto auto;
grid-template-areas:
"primary pct cancel"
"secondary pct cancel";
```
- **Primary row** (`.dl-lg-primary`, top-left) — pulse dot + the uppercase live label `DOWNLOADING` in `color-mix(in srgb, var(--accent) 80%, white)`, 13px / 600, `letter-spacing: 0.02em`. This is the only place the word "Downloading" appears in the component.
- **Secondary row** (`.dl-lg-secondary`, bottom-left) — the live stats. 12px, four groups separated by `·` (0.45 opacity):
1. `<strong>11.4 GB</strong> / 35 GB` (`var(--t-1)` strong + `var(--t-2)` rest)
2. `47.6 MB/s` (`var(--t-1)`)
3. `[users-icon] 5``.dl-peers`, inline-flex with 4px gap, icon at 0.7 opacity, count in `var(--t-1)` 600 tabular-nums. Hidden entirely when `game.peers` is falsy. Communicates this is a LAN swarm transfer; the full sentence lives in the `title` tooltip.
4. `8 min left` (`var(--t-2)`)
- **pct column** — large percentage, 20px / 700, `letter-spacing: -0.01em`, `var(--t-1)`. `%` glyph at 12px / 600 / 0.55 opacity.
- **cancel column** — 28×28 square, `1px solid var(--bd-2)`, `border-radius: 6px`, X icon. Hover: bg `rgba(239,68,68,0.12)`, border `rgba(239,68,68,0.40)`, text `#fca5a5`. Cancelling reverts the game to its prior state (`local` if any data was kept, `none` otherwise) — dev decides the underlying behavior.
**Graceful degradation in narrow modals:**
```css
@container (max-width: 320px) { .dl-lg-secondary .dl-eta, .dl-lg-secondary .dl-sep-eta { display: none; } }
@container (max-width: 240px) { .dl-lg-secondary .dl-peers, .dl-lg-secondary .dl-sep-peers { display: none; } }
```
ETA drops first, then peers; bytes + speed always stay (they're the actionable numbers). The pct/cancel column never collapses.
### Number formatting
All helpers live in `data.jsx`:
- `fmtSpeed(mbps)``49.4 MB/s` below 100, `MM MB/s` (rounded) at/above 100. Used in `.dl-lg`.
- `fmtSpeedShort(mbps)` — always rounded: `49 MB/s`. Used in `.dl-md` so the card stays compact.
- `fmtBytes(gb)``<1 GB → MB rounded`, `<10 GB → up to 2 decimals` (trailing zeros stripped: `2.35 GB`, `2.3 GB`, `2 GB`), `≥10 GB → 1 decimal max` (`11.4 GB`, `35 GB`).
- `fmtEta(seconds)``< 60s → "N s"`, `< 60min → "N min"`, else `"H h M min"`.
Keep these formats; they're tuned so the secondary row never wraps at normal modal width.
### Data shape
The `Game` type gains a `downloading` state plus two transient fields:
```ts
type Game = {
// … existing fields …
state: 'installed' | 'local' | 'downloading' | 'none';
progress?: number; // 01, only when state === 'downloading'
speed?: number; // current throughput in MB/s
peers?: number; // number of LAN peers currently seeding
};
```
In the real app, `progress`, `speed`, and `peers` come from the download worker (Tauri command emitting events). The mock's `useLiveDownload(game)` hook (in `components.jsx`) is just a placeholder — 600ms `setInterval` advancing `progress` proportional to `speed`, with `speed` smoothed via a low-pass filter and small random drift so the number doesn't look fake. `peers` is read straight off the game object (static in the mock); in production, push updates as peers join/leave the swarm — the `.dl-peers` chip re-renders silently. Replace the hook with a `useEffect` that subscribes to your real progress events; the rendering layer needs nothing else.
Filter changes:
- `Local` filter includes `installed` + `local` + `downloading` (in-flight downloads belong on the Local tab — you're managing them).
- Sort by `state` orders `installed < local < downloading < none`.
### State chip
Add a fourth entry to `STATE_META`:
```js
downloading: { label: 'Downloading', dot: 'var(--accent)' }
```
Dot uses the live accent so it visually ties to the progress fill.
---
## Filter controls — variant B (not used, kept for reference)
The two-row chrome has a different filter style — **underlined tabs with counts**, like browser tabs:
- Buttons: no background, `padding: 10px 14px 12px`, font `13.5px / 600`. Color `--t-2` inactive, `--t-1` active.
- Count chip after label: 11.5px / 600, `padding 1px 7px`, rounded pill. Inactive bg `rgba(255,255,255,0.06)`, text `--t-3`. Active bg `rgba(255,255,255,0.10)`, text `--t-1`.
- Active tab has a 2px underline at the bottom (`left: 12px, right: 12px`) in `--accent`, animated in via opacity + scaleX (220ms cubic-bezier).
Implement only if you decide variant A doesn't work after building.
---
## Interactions & behavior
- **Click game card** (anywhere except the action button) → open detail overlay.
- **Click action button on card** → trigger the state-appropriate action without opening the overlay. `e.stopPropagation()` on the button.
- **Press / (slash)** → focus the search input.
- **Type in search** → live-filter the visible grid by title or tag (case-insensitive substring).
- **Click filter tab / segmented pill** → change filter.
- **Click sort button** → opens dropdown; click an option → re-sort grid; clicking outside the menu closes it.
- **Hover game card** → lift + accent border glow + cover image scale 1.03.
- **Click "Settings"** in kebab → open Settings dialog. Changes apply live and persist immediately (no Apply button — Done just closes).
- **Click "Change…" / "Choose…" in the Settings → Library → Game folder row** → open native folder picker via Tauri; on selection, write to `settings.gameFolder` and rescan library. The field indicates whether a valid folder is currently configured (mono path + neutral `Change…`) or not (red `Not set` + accent-filled `Choose…`) — see "Game-folder field" above.
- **Click "Unpack logs"** in kebab → opens a logs viewer (separate window or modal — out of scope for this design).
- **Click "Refresh library"** in kebab → re-runs the library scan.
- **Esc** → closes any open modal (detail overlay, Settings).
### Transitions / animations
- Card hover: `180ms cubic-bezier(.4,1.2,.5,1)` on transform/border, `350ms cubic-bezier(.4,1.2,.5,1)` on cover scale.
- Modal fade-in: scrim `opacity 0 → 1` over 180ms ease; modal `transform: scale(.96) translateY(8px) → scale(1) translateY(0)` and opacity over 250ms `cubic-bezier(.3,1.3,.4,1)`.
- Segmented filter thumb: `220ms cubic-bezier(.4,1.2,.5,1)` on `left` and `width`.
- Underline tab indicator (variant B): `200ms` on opacity, `250ms cubic-bezier(.4,1.2,.5,1)` on `transform: scaleX`.
- Animated background option: subtle 18s ease-in-out infinite alternate background-position shift on two accent-tinted radial gradients.
---
## State management
Recommend Zustand or a single React context for global launcher state; Tauri commands for filesystem and process operations.
**Library state** (rebuilt on `refresh`):
```ts
type Game = {
id: string;
title: string;
size: number; // GB
version: string; // "YYYY.MM.DD"
desc: string;
state: 'installed' | 'local' | 'downloading' | 'none';
progress?: number; // 01 — present only when state === 'downloading'
speed?: number; // MB/s — present only when state === 'downloading'
peers?: number; // LAN peers currently seeding
players: string; // e.g. "232"
tags: string[];
cover: { c1: string; c2: string; accent: string; mood?: string };
canHostServer?: boolean; // true if the game ships with a dedicated-server binary
};
```
**Server-capable games** in the mock catalog (`canHostServer: true`): BF1942, BF2, CoD2, CoD4, CoD:UO, CS 1.6, CS:Source, Cube 2/Sauerbraten, Doom 3, L4D2, Minecraft, Quake III, TF2, UT2004. RTS / social-deduction / co-op-only-P2P games (AoE II HD, RA3, Generals ZH, Among Us, Portal 2, StarCraft, Warcraft III, AvP, 8-Bit Armies, BlazeRush) are not flagged — they host in-game. In production the flag should come from the same per-game manifest that drives titles / sizes / cover art. Wire each entry to whatever launch command the dedicated server uses (`hldsexec`, `srcds`, `minecraft_server.jar`, etc.); the IPC stub looks like `startServer(gameId)` returning a handle or process id.
**UI state:**
```ts
type LauncherUI = {
filter: 'all' | 'local' | 'installed';
sort: 'az' | 'size' | 'recent' | 'state';
query: string;
openGameId: string | null;
settingsOpen: boolean;
};
```
**Persisted settings** (mirror of Settings dialog state):
```ts
type LauncherSettings = {
username: string;
language: 'en' | 'de';
accent: string; // hex from the curated 6-color palette
bg: 'flat' | 'gradient' | 'animated';
density: 'compact' | 'normal' | 'large';
aspect: 'box' | 'square' | 'banner';
gameFolder: string | null; // v3: moved out of top bar, persists actual path
};
```
Persist via Tauri's plugin-store or a local JSON file in app data dir. Changes from the Settings dialog should write through immediately (no Apply button).
**Storage figures:** computed by summing game sizes per state, plus free-space query via Tauri.
---
## Design tokens
### Color
| token | value | usage |
| | **launcher/** | **logo/** |
|---|---|---|
| `--bg-0` | `#0a0e13` | launcher background |
| `--bg-1` | `#0f151c` | card bottom gradient stop |
| `--bg-2` | `#131b25` | top bar / card top / search bg |
| `--bg-3` | `#1a2330` | settings segmented bg / cover fallback |
| `--bg-4` | `#232f3e` | (reserved) |
| `--bd-1` | `rgba(255,255,255,0.06)` | subtle border |
| `--bd-2` | `rgba(255,255,255,0.10)` | stronger border |
| `--bd-3` | `rgba(255,255,255,0.16)` | scrollbar thumb |
| `--t-1` | `#e6edf3` | primary text |
| `--t-2` | `#9aa6b4` | secondary text |
| `--t-3` | `#6b7785` | muted text / metadata |
| `--t-4` | `#4a5663` | (reserved) |
| `--ok` | `#22c55e` | "installed" dot |
| `--warn` | `#f59e0b` | "local" dot |
| `--danger` | `#ef4444` | destructive actions |
| `--accent` | user-selected, default `#3b82f6` | primary actions, focus rings, brand mark |
| What | The full launcher UI redesign | The brand mark + wordmark lockup |
| Read first | `launcher/SPEC.md` | `logo/INTEGRATION.md` |
| Preview | open `launcher/design_reference/SoftLAN Launcher.html` | open `logo/demo.html` |
| Fidelity | High — final colors/type/spacing/interactions | Final — recolors live via the `accent` token |
### Typography
- **UI font** — system sans stack: `-apple-system, BlinkMacSystemFont, "Segoe UI Variable", "Segoe UI", system-ui, sans-serif`
- **Cover-art display font** — `"Bebas Neue"` (Google Fonts, weight 400) with fallback `"Oswald", Impact, "Arial Narrow Bold", sans-serif`
- **Monospace** — `ui-monospace, "SF Mono", Menlo, Consolas, monospace` (used for: directory path, version field in detail overlay)
Sizing reference:
- Brand wordmark: 15 / 700
- Modal title: 32 / 700 / -0.015em
- Card title: 13.5 / 600
- Filter pill label: 12.5 / 600
- Settings row label: 14 / 600
- Section title (settings): 10.5 / 700 / 0.12em uppercase
- Meta (size · genre, etc.): 11.5 / tabular-nums / `--t-3`
- Hint text: 12 / `--t-3`
### Spacing & radii
- Card radius: 10px
- Modal radius: 14px
- Pill/control radius: 8px (search, sort, dir button), 999px (filter segmented), 7px (action button)
- Common gaps: 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 28
- Card body padding: 11 12 12
### Shadows
- Card hover: `0 14px 30px -16px color-mix(var(--accent), 50%, black), 0 0 0 1px color-mix(var(--accent), 30%, transparent)`
- Modal: `0 30px 80px -10px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.04)`
- Brand mark: `0 6px 20px -6px color-mix(var(--accent), 60%, black), inset 0 1px 0 rgba(255,255,255,0.22)`
- Action button (filled): `0 6px 16px -8px <color>, inset 0 1px 0 rgba(255,255,255,0.22)`
The two share one design language. The **accent** color is a single token
(`--accent`, default `#3b82f6`) that drives the launcher's primary actions *and*
the logo — wire it once and both follow. The brand mark in the launcher's top
bar (`launcher/SPEC.md` → "Top bar → Brand") **is** the logo component from
`logo/pixel-live.jsx` at `size={28}`; the static 28px "S" in the mock is a
placeholder for it.
---
## Assets
## Recommended reading order
Cover art in the design files is **stylized placeholder art** — generated entirely from the game's metadata (color pair + accent color + id hash for angle/blob position) plus the title typeset in Bebas Neue. There are no real game cover image assets in this design.
In the production app, the launcher should ideally use real cover-art when available (fetch from IGDB / Steam / local game folder) and fall back to the placeholder generator for games without art. The placeholder generator is in `design_reference/components.jsx → GameCover`.
The icon set (search, play, **server**, install, download, folder, kebab, sort, users, close, check, chevron, trash) is in `design_reference/components.jsx → Icon`. They are 12-14px inline SVGs using `currentColor`. Reuse as-is or substitute with the codebase's existing icon library at the same visual weight. The `server` glyph is new in this round — two stacked rounded rectangles with LED dots, used only on the Start Server button.
Fonts to load:
```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bebas+Neue&display=swap">
```
1. **This file** — the map.
2. **`launcher/SPEC.md`** — the bulk of the work. Screens, components, data
shapes, design tokens, interactions, and what's out of scope. Start at
"Screens / views" and keep the "Design tokens" section open alongside.
3. **`logo/INTEGRATION.md`** — drop the live mark and the wordmark lockup into
the chrome. Short; mostly a component API + static-asset reference.
4. Open both preview files in a browser to see the real thing in motion.
---
## File reference
## Status
```
design_reference/
├── SoftLAN Launcher.html ← entry; wires React + Babel, mounts <App>
├── styles.css ← all visual styles (CSS custom props + components)
├── data.jsx ← mock GAMES array + filter/sort helpers + STORAGE mock
├── components.jsx ← Icon, GameCover, StateChip, ActionButton, GameCard,
│ SegmentedFilters, UnderlineFilters, SearchField,
│ SortMenu, StorageMeter, KebabMenu,
│ GameDetailModal, SettingsDialog (incl. GameFolderField)
└── launcher.jsx ← <Launcher> component composing chrome + grid + modals
```
- **Launcher:** high-fidelity, decisions locked. Variant **A** is the chosen
chrome. Includes **Call to Play** (rally the LAN around a game + time — live
or scheduled, with RSVP, check-in window, and per-call chat). Open questions
(empty/error states, logs viewer, keyboard grid nav, German strings, "server
running" state, real-time transport for Call to Play) are listed at the end
of `SPEC.md`.
- **Logo:** final. Live component + static assets + horizontal lockup (dark and
light) all included.
To preview the design in a browser:
1. Open `SoftLAN Launcher.html` in a static-server (e.g. `python -m http.server` from the folder).
2. You'll see a design canvas with all variants (A, B, C, D, E, F) side-by-side. Click an artboard's expand button to view it full-screen.
- **A / B** — chrome variants (A is the chosen direction)
- **C** — detail overlay for an installed, server-capable game (Counter-Strike 1.6) → shows **Play + Start Server + Uninstall**
- **D** — detail overlay for a downloaded-but-not-installed game (CoD 4) → shows **Install + Delete from disk**
- **E** — detail overlay for a downloading game (AvP) → shows the live progress component + **Cancel**
- **F** — Settings dialog open, with the new **Profile** section at the top
3. The "Tweaks" floating panel in the bottom-right is dev-only — it lets you live-change every persisted setting (username / language / accent / background / density / aspect / game folder). In the production app these all live in the Settings dialog.
---
## Out of scope / open questions for the developer
- **Unpack logs viewer** — referenced from kebab menu but not designed. Surface it as a separate window or a slide-in panel, dev's choice.
- **Empty state** — when filter returns 0 games (e.g. nothing installed yet). Show a centered message with a CTA to install the first game.
- **Error state on action** — if a Download / Install fails, show inline error on the affected card (red border + retry button), and a toast.
- **Progress state** — designed. See "Download progress" section above. The action-button slot is swapped for a live `DownloadProgress` component (card + modal variants with container-query fallback for narrow tiles). Wire it to your real progress events; the rendering layer is dev-ready.
- **Keyboard arrow nav** — arrow keys should move focus between cards in the grid; not implemented in the mock but mentioned as a goal.
- **"Server running" state** — once Start Server actually spawns a process, the button should switch to a *running* state (live indicator dot + "Server running" label + click-to-stop). Not designed this round — flag for follow-up alongside whatever server-status panel the app grows.
- **German translations** — the language toggle is wired in Settings, but the catalog of translated UI strings hasn't been compiled. Stand up `react-i18next` (or equivalent) and seed `en.json` from the existing copy; `de.json` is a translation task for whoever owns localization.
Do **not** ship the Babel-in-browser setup, the design-canvas wrappers, or the
Tweaks panel from the prototypes — they're presentation scaffolding. The Tweaks
panel is superseded by the in-app **Settings dialog** (spec'd in `SPEC.md`).
+777
View File
@@ -0,0 +1,777 @@
# Handoff: SoftLAN Launcher redesign
A modern, gamer-friendly redesign of the SoftLAN local-network game launcher, replacing the current basic UI with a Steam-inspired dark layout that keeps high usability while adding cover art, state-coded actions, a game-detail overlay, and an in-app Settings dialog.
---
## About the design files
The files in `design_reference/` are **design references created in HTML/React via Babel-in-the-browser** — prototypes built to communicate the intended look, layout, and behavior. They are **not production code to copy directly**.
The target codebase is a **Tauri + React** desktop app. The task is to **recreate these designs inside that codebase**, using its existing patterns (component conventions, state management, routing, IPC to Rust for filesystem / process work). Use the design files for:
- Exact pixel/spacing/color/typography values
- Component composition and interactions
- Copy and microcopy
- Animation easings/durations
But:
- Don't ship the Babel-in-browser setup or import the .jsx files as-is
- Don't keep the `<deck>` / design-canvas wrapping — that's only for presenting variants
- Don't ship the Tweaks panel — it's superseded by the in-app **Settings dialog** (see "Screens" below)
- Re-implement using whatever the codebase uses (Vite + plain JSX, CSS modules / styled-components / tailwind, etc.)
## Fidelity
**High-fidelity.** Final colors, typography, spacing, and interactions are decided. Pixel-fidelity to the mock is the goal — recreate exactly, using the codebase's libraries/patterns. Only deviate where the codebase has its own dictate (e.g. an existing button primitive that's near-identical).
## Layout variants
The HTML mock includes two chrome variants — **A (single-row)** and **B (two-row)** — to choose from. **The user selected A as the primary direction.** Implement A. Variant B is left in the reference for context only.
---
## Changes since v4
- **Call to Play added.** A whole new coordination layer on top of the library:
rally the LAN around a game and a time. Two flavors — **Play now** (give
people N minutes to ready up) and **Scheduled** ("Among Us at 20:00", collect
"I'm in" RSVPs, then a **check-in window** opens 15 min before start). Every
call carries a small group **chat**. Surfaced in three places: a **Call to
Play button** in the top bar (with an active-call badge), a persistent stack
of **quick bars** above the grid, and a full **overlay** with per-call cards
and a create form. Full spec in the new **"Call to Play"** section below.
New source files: `calltoplay.jsx`, `ctp-chat.jsx`. New data: a mock LAN
**peer roster** (`PEERS`) in `data.jsx`. New icons: `flag`, `chat`, `send`,
`clock`, `caretUp`, `caretDown`.
## Changes since v3
- **Game-folder button removed from the top bar.** Setting the games directory is a one-time action — it doesn't deserve permanent real estate in the chrome. The button is gone from both top-bar variants, freeing the right zone for the kebab menu alone (variant A) / the storage meter + kebab pair (variant B).
- **Game folder moved into Settings → Library.** Now a row inside the Settings dialog, styled like the other Library rows. Two visual states (set / not-set) carry over from the old button — see "Settings dialog → Library → Game folder" below.
- **Persisted setting renamed.** `gameFolderSet: boolean``gameFolder: string | null`. The actual path is now persisted, not just a "is it configured?" flag. Default is `null` (unset on first run; user must pick a folder before the library scans).
## Changes since v2
- **Top bar layout reorganized.** The single-row top bar is now structured as three visual zones (still one row on wide windows):
- **Left:** brand mark + wordmark.
- **Center (semantically the "search cluster"):** segmented filter pills · search field · sort menu. The **search field is positioned at the geometric center of the window** — filter pills sit immediately to its left, sort menu immediately to its right.
- **Right:** kebab menu (game-folder configuration has moved into Settings — see v3 changes).
- Below ~1100 px of launcher width (container query), the three zones collapse into a single left-to-right flowing row (no wrap, no centering). Implement via container query on the launcher root; viewport media query is acceptable if your codebase doesn't use container queries yet.
- See "Top bar (variant A)" below for the full spec and rationale.
## Changes since v1
- **Settings → Profile section** added at the top of the dialog with two new persisted preferences: **Username** (text input) and **Language** (segmented `English` / `Deutsch`). See "Settings dialog" below for shape + persistence keys.
- **Start Server** action added to the **game detail overlay**, next to **Play**, for installed games that support a dedicated server. Driven by a new `canHostServer: true` flag on the game record. See "Detail overlay → Actions row" and "Game data shape" for the full spec.
- Grid cards are **unchanged** — Start Server only ever appears in the detail overlay.
---
## Screens / views
### 1. Main library (variant A — primary)
The default screen. A grid of game cards over a dark, gradient-tinted background.
**Layout (top-to-bottom):**
1. **Top bar** — single row, sticky, full width, 64px tall, semi-transparent dark with backdrop-blur. Background `rgba(10,14,19,0.65)` + `backdrop-filter: blur(20px) saturate(140%)`. Border-bottom `1px solid rgba(255,255,255,0.06)`. Padding `14px 24px`. **Layout:** a 3-column CSS grid — `grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr)` with `column-gap: 16px` — putting the search field in the middle (auto-sized) column so it sits at the **geometric center of the window** regardless of how wide the side groups are. The side columns are each `display: flex; justify-content: space-between` so their contents pin to the outer edge on one end and hug the search on the other.
- **Left zone (col 1, flex space-between):**
- **Brand** (pinned far-left) — 28×28 px rounded square in `--accent` (default `#3b82f6`) with the letter "S" in Bebas Neue 20 px white. Next to it, the wordmark "SoftLAN" in 15 px / 700 weight `--t-1` `#e6edf3`.
- **Segmented filter pills** (pinned right, hugging the search field) — pill-shaped container (`background var(--bg-2) #131b25`, `1px solid rgba(255,255,255,0.06)`, `border-radius: 999px`, `padding: 4px`). Three buttons:
- `All Games` · count chip
- `Local` · count chip
- `Installed` · count chip
Active button has an animated pill thumb (background `var(--accent)`, transitions `left` and `width` with `cubic-bezier(.4,1.2,.5,1)` over 220 ms), text becomes white, count-chip background goes `rgba(0,0,0,0.25)`. Inactive: text `var(--t-2) #9aa6b4`, count-chip background `rgba(255,255,255,0.08)`.
`Local` = installed *or* downloaded-but-not-yet-installed. `Installed` = installed only. `All Games` = everything available on the network.
The filter is grouped semantically with the search — it scopes what the user is searching, so it belongs at the search field's left shoulder.
- **Center zone (col 2, search alone):**
- **Search field** — 36 px tall, `flex: 0 1 360px` (caps at 360 px wide so it can't elbow into the side zones). `background var(--bg-2)`, `1px solid var(--bd-1)`, `border-radius: 8px`, padding `0 12px`. Leading magnifying-glass icon (14×14, `currentColor`) and a trailing "/" kbd hint (`background rgba(255,255,255,0.06)`, `border-radius: 4px`, font `11px ui-monospace`). On focus: border `color-mix(in srgb, var(--accent) 60%, var(--bd-2))`, background `var(--bg-1)`, ring `box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent)`. The `/` key shortcut should focus the search.
- **Right zone (col 3, flex space-between with two sub-groups):**
- **Sort menu** (pinned left, hugging search) — 36 px button, same surface style as search. Label `Sort: <bold value>` plus 13 px sort-bars icon and 11 px chevron. Click reveals dropdown menu below. Options: `Name (AZ)`, `Size (largest)`, `Recently Played`, `Status`. This is the only thing on the *left* side of the right zone — it's part of the search cluster, so it hugs the search.
- **Kebab menu** (`⋮`, pinned far-right) — 36×36 button with same surface as search. Menu items: `Settings` (opens Settings dialog), `Refresh library`, separator, `Unpack logs`, `About SoftLAN`. This is the only "app-level" control left in the top bar; the game-folder picker has moved into Settings.
- **Call to Play button** (`.ctp-btn`, sits just left of the kebab in the far-right sub-group) — 36 px pill, flag icon + `Call to Play` label. Carries an accent-filled **badge** with the count of active (non-started) calls. Opens the Call to Play overlay. See the **"Call to Play"** section for the full feature. In variant B it lives in row 1's right group, between the storage meter and the kebab.
**Narrow-window fallback** (container width < 1100 px): the grid is replaced by a single `display: flex; flex-wrap: nowrap; gap: 16px` row. All items align left-to-right in source order (brand → filter → search → sort → kebab). The search field becomes `flex: 1 1 auto` so it absorbs remaining slack. The geometric centering is abandoned at narrow widths because there isn't enough horizontal slack for it to read cleanly. Implement via container query (`@container launcher (max-width: 1100px)`) on the launcher root; a viewport media query is an acceptable fallback if you're not using container queries yet.
2. **Results bar** — 18px top padding inside the scroll wrapper, 24px horizontal. Flex row with space-between:
- Left: `Showing <strong>N</strong> of M games` in 12.5px `var(--t-2)` (strong is `var(--t-1)`).
- Right: compact **storage meter** — 200px min-width, 4px-tall horizontal bar with two stacked segments (`installed` and `local`), plus a 11px text row underneath: `<sq> 78 GB installed <sq> 41 GB local 384 GB free`. Squares are 8×8px rounded 2px, colored `var(--accent)` and `color-mix(var(--accent), 55%)`.
3. **Grid** — CSS grid with `repeat(auto-fill, minmax(188px, 1fr))` at default density, 16px gap, 24px horizontal padding, 32px bottom padding. Scrolls vertically.
- Density: `compact` → min 148, gap 12. `normal` → min 188, gap 16. `large` → min 244, gap 20.
**Game card** (see "Game card" below for full anatomy).
---
### 2. Game detail overlay
Opens when the user **clicks anywhere on a game card except the action button**. Modal over a scrim. Closes on scrim click, Esc key, or the close button. Should also work via keyboard nav (Enter on focused card).
**Scrim:** absolutely positioned over the launcher, `inset: 0`, `z-index: 100`, `background: rgba(4,7,11,0.7)`, `backdrop-filter: blur(8px)`, fade-in 180ms. Padding 32px, content centered.
**Modal panel:** `min(880px, 100%)` wide, `background: linear-gradient(180deg, var(--bg-2) 0%, var(--bg-1) 100%)`, `1px solid var(--bd-2)`, `border-radius: 14px`, drop shadow `0 30px 80px -10px rgba(0,0,0,0.7)`. Scales in from 0.96 with 250ms `cubic-bezier(.3,1.3,.4,1)`.
**Modal structure (top-to-bottom):**
1. **Hero banner**`aspect-ratio: 16/7`. Full-bleed cover art rendered as a banner (same gradient + accent treatment as the small cards, scaled up). Bottom-fade gradient `linear-gradient(180deg, transparent 40%, var(--bg-2) 100%)` so text reads.
- **State chip** in the top-left of the hero (same chip style as on cards — see Game Card).
- **Close button** top-right: 32×32 square, `background rgba(8,12,16,0.7)`, `1px solid var(--bd-2)`, `border-radius: 8px`, `backdrop-filter: blur(8px)`, X icon.
- **Title overlay** in bottom-left at `left: 28px, right: 28px, bottom: 22px`:
- Tags row — small uppercase pills (`background rgba(8,12,16,0.6)`, `1px solid var(--bd-2)`, `border-radius: 4px`, `padding: 3px 8px`, `font 11px / 600 / 0.04em letter-spacing`)
- **Title** as `<h2>` — system sans 32px / 700 / -0.015em, white, text-shadow `0 4px 24px rgba(0,0,0,0.6)`. **Not Bebas Neue** here — this is normal UI typography, not stylized cover art.
2. **Body** — 22px top, 26px bottom, 28px horizontal:
- **Meta grid** — 4-column CSS grid, 12px gap. Each cell: `padding 10px 12px`, `background rgba(255,255,255,0.025)`, `1px solid var(--bd-1)`, `border-radius: 8px`. Cells (in order): `Size` (e.g. 8.2 GB), `Players` (icon + range), `Version` (mono, e.g. 2018.04.12), `Status` (Installed / Local / Not downloaded).
- **Description** — 14px / 1.55 line-height, `var(--t-2)`, `text-wrap: pretty`, `max-width: 64ch`.
- **Actions row** — flex row, 10px gap, 4px top padding. Order, left → right:
1. **Primary action button** (44px tall, see "Action button" below — Play / Install / Download depending on state).
2. **Start Server***only* when `game.canHostServer === true` **and** `state === 'installed'`. Same 44px height as Play, but visually a peer secondary action (see "Start Server button" below). Triggers a Tauri command that spawns the game's dedicated-server executable in headless mode against the local LAN (port + server config out of scope here — leave a `startServer(gameId)` IPC stub).
3. If `state === 'installed'`: ghost-button **Uninstall** — 44px, `background rgba(255,255,255,0.04)`, `1px solid var(--bd-2)`, `border-radius: 8px`, text `#f87171`, trash icon. On hover: bg `rgba(239,68,68,0.10)`, border `rgba(239,68,68,0.40)`, text `#fca5a5`.
4. If `state === 'local'`: ghost-button **Delete from disk** (same danger styling).
5. If `state === 'downloading'`: ghost-button **Cancel** (same danger styling).
6. Spacer (`flex: 1`).
7. Ghost-button **View files** (neutral) — opens system file manager at the game folder.
#### Start Server button
A secondary-but-equal action that sits next to **Play**. The intent is to read as a host-action ("I want to put this game on the LAN") without competing with the green Play button for the player's primary attention.
- Same shape and height as Play: 44px tall, `border-radius: 8px`, `font 14px / 600`, 8px gap between icon and label, padding `0 22px`.
- Surface: `background: color-mix(in srgb, var(--accent) 14%, rgba(255,255,255,0.04))`, `border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent)`, `box-shadow: inset 0 1px 0 rgba(255,255,255,0.06)`. Text in `--t-1`.
- **Icon** in `--accent`: a small server-rack glyph (two stacked rounded rectangles each with an LED dot and a hint of wiring). 13×13. SVG in `components.jsx → Icon.server`.
- Hover: `background: color-mix(in srgb, var(--accent) 22%, ...)`, border darkens to `color-mix(... 75%, transparent)`. Active: `transform: scale(0.98)` (shared with `.act-btn`).
- A future *running* state (live indicator dot + "Server running" label + click-to-stop) is **not** in this round — flag as a follow-up when wiring the real spawn.
The button is purposefully **not** present on game cards in the grid — hosting a server is intentional and benefits from the context of the detail overlay (player count, version, etc.). Don't add it to cards.
---
### 3. Settings dialog
Opens when the user clicks **Settings** from the kebab menu. Same modal-scrim treatment as the game-detail modal, but the panel is narrower (`min(640px, 100%)`) and styled as a list of preferences.
**Structure:**
```
┌─────────────────────────────────────────┐
│ Settings [×] │ ← head: 22 28 18, 1px bottom border
├─────────────────────────────────────────┤
│ │
│ PROFILE │ ← section title (new): 10.5px / 700 / 0.12em / uppercase / --t-3
│ │
│ Username │ ← row label: 14px / 600 / --t-1
│ Shown to other players on the LAN │ ← row hint: 12px / --t-3
│ [ Enter a username ] │ ← text input (220×36)
│ │
│ Language │
│ Interface language │
│ [English│Deutsch] │ ← segmented radio (new)
│ │
│ APPEARANCE │
│ │
│ Accent color │
│ Used for primary actions and highlights │
│ ⬤⬤⬤⬤⬤⬤ │ ← 6 swatches, right-aligned
│ │
│ Background │
│ Backdrop behind the library │
│ [Flat │Gradient│Animat]│ ← segmented radio
│ │
│ LIBRARY │
│ │
│ Game folder │
│ Parent directory where games are │
│ downloaded and installed │
│ [📁 /home/pfs/…/eti_games [Change…]] │ ← folder field (340×36)
│ │
│ Grid density │
│ How tightly cards are packed │
│ [Compact│Normal│Large]│
│ │
│ Cover aspect │
│ Shape of the cover art on each card │
│ [Box-art│Square│Banner│
│ │
├─────────────────────────────────────────┤
│ [Done] │ ← foot: 14 22 18, 1px top border
└─────────────────────────────────────────┘
```
**Sections** are separated by 26px gap (column flex). Rows within a section: 14px gap. Each **row** is flex row with space-between (24px gap):
- Left (`settings-row-info`): label (14px / 600 / `--t-1`) + hint (3px-top, 12px / `--t-3`)
- Right (`settings-row-control`): the control
**Profile section** (new in this round). Two rows, rendered **above** Appearance — it's the most personal/identity-shaped setting so it's the first thing the user sees in Settings.
- **Username** — `<input type="text">` wrapped in a styled container: 220px wide, 36px tall, `background var(--bg-3)`, `1px solid var(--bd-1)`, `border-radius: 8px`, `padding: 0 12px`. Input itself is transparent/borderless, `font 13.5px / 600`, color `--t-1`, placeholder `"Enter a username"` in `--t-3` / 500. `maxLength={24}`, `spellCheck={false}`. On focus the container gets `background var(--bg-2)`, border `var(--accent)`, and an accent focus ring `box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent)`.
- **Language** — same segmented-radio control as Background / Density / Cover aspect, with two options: `English` (value `'en'`) and `Deutsch` (value `'de'`). Active option gets the accent fill, same as the other segmented radios.
**Library section.** Three rows: **Game folder** (new in v3 — moved out of the top bar), **Grid density**, **Cover aspect**.
- **Game folder** — see "Game-folder field" below. The first row in the section because it's the only setting users *must* configure for the launcher to work; density and aspect are pure preference.
**Color swatch picker:** flex row of 8px-gapped buttons. Each swatch is 32×32, `border-radius: 9px`, no border. Inside, a 100% × 100% rounded-8 colored dot with inset shadow `0 0 0 1px rgba(255,255,255,0.08)`. Hover: dot scales 1.06. **Active**: dot has ring `box-shadow: 0 0 0 2px var(--bg-2), 0 0 0 4px <swatch-color>` and shows a centered white check icon with drop-shadow `0 1px 2px rgba(0,0,0,0.5)`.
Six accent options: Blue `#3b82f6`, Cyan `#22d3ee`, Violet `#a855f7`, Green `#22c55e`, Amber `#f59e0b`, Red `#ef4444`.
**Segmented radio:** inline-flex with `background var(--bg-3) #1a2330`, `1px solid var(--bd-1)`, `border-radius: 8px`, `padding: 3px`. Each button: 30px tall, `padding: 0 14px`, `border-radius: 6px`, `font 12.5px / 600`. Inactive: `color var(--t-2)`. Active: `background var(--accent)`, `color white`, inset top shadow `0 1px 0 rgba(255,255,255,0.18)`.
**Done button:** filled button in `--accent`, 36px tall, 13.5px / 600. Closes the dialog.
Persisted settings (write through to local storage / Tauri config):
- `username`: string, max 24 chars. Default `"Commander"` (placeholder — feel free to default to the OS username on first run). Used as the network identity for LAN sessions; the hint copy *"Shown to other players on the LAN"* tells the user what it does.
- `language`: `'en'` | `'de'`. Default `'en'`. Drives an i18n layer (introduce one if it doesn't exist yet — `react-i18next` or similar). Initial copy is English-only in the mock; German translations need to be added as part of implementation. Recommend detecting the OS locale on first run and defaulting to `'de'` if the system language starts with `de`.
- `accent`: one of the six hex values above. Default `#3b82f6`.
- `bg`: `flat` | `gradient` | `animated`. Default `gradient`.
- `density`: `compact` | `normal` | `large`. Default `normal`.
- `aspect`: `box` | `square` | `banner`. Default `box`.
- `gameFolder`: `string | null`. Absolute path to the parent directory where games are downloaded and installed. Default `null` (unset on first run). See "Game-folder field" below.
---
## Game-folder field
A settings row inside the **Library** section of the Settings dialog. Exposes the user's currently-configured game folder (the parent directory under which all per-game subfolders live).
**Why it lives in Settings now:** users set this once at install time and basically never touch it again. A permanent top-bar button burned high-attention chrome on a control nobody used after day one. Settings is where one-time configuration belongs.
Two visual states, driven by whether `settings.gameFolder` resolves to an accessible directory:
| State | Trigger | Path display | Border | Button label |
|---|---|---|---|---|
| **Set & valid** | path is configured and exists on disk | full path in mono, truncated head-first | default `--bd-1` | `Change…` (neutral pill) |
| **Not set / invalid** | path is `null`/empty, or path is set but the directory no longer exists | `Not set` in red | tinted red (`color-mix(in srgb, var(--danger) 35%, var(--bd-1))`) + faint red bg tint | `Choose…` (accent-filled pill) |
"Invalid" is intentionally collapsed into the same visual state as "not set" — the user's job is identical (open the picker and pick a folder), so we don't differentiate. If we later need a distinct "missing" state (e.g. to show the *last known* path so the user can re-attach an external drive), introduce a third state then; for now, keep it simple.
**Anatomy:** `inline-flex`, `width: 340px`, `height: 36px`, `padding: 0 4px 0 12px`, `gap: 8px`. `background: var(--bg-3)`, `border-radius: 8px`. Children, left to right:
1. **Folder icon**`Icon.folder` from `components.jsx`, 14×14, `var(--t-3)` (set state) or `#f87171` (unset state).
2. **Path display**`flex: 1`, mono `12px / ui-monospace`, `--t-1`, single line, `overflow: hidden; text-overflow: ellipsis`. **`direction: rtl` + `unicode-bidi: plaintext`** so truncation happens from the head and the leaf folder (the part the user actually cares about) stays visible. When unset: shows the word `Not set` in 12.5 px / 600 / `#f87171` instead.
3. **Action button** — 28 px tall pill, `border-radius: 6px`, `padding: 0 12px`, `font 12.5px / 600`. Set state: neutral `rgba(255,255,255,0.06)` bg, label `Change…`. Unset state: `var(--accent)` fill at 85% alpha, white text, label `Choose…` (so the call-to-action reads stronger when the path needs picking). Click → native folder picker via Tauri; on selection, write through to `settings.gameFolder` and rescan library.
**Hover:** border darkens to `--bd-2` (set state) or to `color-mix(in srgb, var(--danger) 55%, var(--bd-2))` (unset state). The inner button has its own hover (background opacity bumps).
**Accessibility:** the path itself is selectable text inside the field; the action button carries `aria-label="Change game folder"` / `"Choose game folder"`. The full path is also exposed via `title` on the path-display element so it's reachable on hover when truncated.
**Why no inline path on the previous top-bar button anymore?** Original design squeezed the full path into a top-bar button as truncated mono. It rarely showed the meaningful part of the path on real-world configurations, ate horizontal space, and competed with the actual primary controls (filter / search / sort) for the top bar's attention budget. In the new home (Settings), the field has all the width it needs to show a useful prefix of the path while still keeping the leaf visible — and it's only on screen when the user is actively reconfiguring.
**Data:** the component takes `value: string | null` and an `onChange(next: string)` callback. `null` (or empty/whitespace string) renders the unset state; any non-empty string renders the set state. The `onChange` callback should fire only on successful picker confirmation (not on cancel). In production, derive `value` from your settings store; if you want to additionally validate existence, do the `fs.metadata` check in the store / a hook and pass `null` when the directory is missing.
**Dev preview:** the prototype's Tweaks panel exposes a `Game folder` **text field** (under the *Library* section) that writes directly to `t.gameFolder`. Type any string to simulate the set state; clear it to simulate the unset state. This is dev-only — in the real app the value comes from the settings store via the picker, **not** from a free-form text input. Don't ship the Tweaks panel.
---
## Game card
The unit element of the library grid.
**Container:** flex column. `background: linear-gradient(180deg, var(--bg-2) 0%, var(--bg-1) 100%)`, `1px solid var(--bd-1)`, `border-radius: 10px`, `overflow: hidden`. Cursor pointer.
**Hover/focus state:**
- `transform: translateY(-2px)` (180ms `cubic-bezier(.4,1.2,.5,1)`)
- `border-color: color-mix(in srgb, var(--accent) 45%, var(--bd-2))`
- Box-shadow `0 14px 30px -16px color-mix(var(--accent), 50%, black), 0 0 0 1px color-mix(var(--accent), 30%, transparent)`
- Cover inner image scales to 1.03 (350ms cubic-bezier)
- Focus-visible: same lift + 2px solid accent outline
### Anatomy (top to bottom)
1. **Cover wrap**`width: 100%`, `aspect-ratio: 2/3` (box) / `1/1` (square) / `16/9` (banner). `position: relative`, `overflow: hidden`, fallback bg `var(--bg-3)`.
2. **Cover** (inside cover-wrap, `position: absolute; inset: 0`):
- **Base gradient** — diagonal (`linear-gradient(<110-170deg>, c1, c2)` — angle hashed from game id for variety). Per-game color pair from the game's `cover` metadata.
- **Radial accent blob** — `radial-gradient(ellipse at <x>% <y>%, <accent>38, transparent 55%)`. x/y also hashed from id.
- **Grain / scanline** — two `repeating-linear-gradient` overlays at 1px intervals, `mix-blend-mode: overlay`, opacity 0.7.
- **Decorative SVG mark** — preserveAspectRatio bottom-right, draws a triangle and dot in the accent color at 12% opacity. Variation via id hash.
- **Title** absolutely positioned at bottom-left, padding `14px`. Font `Bebas Neue` (free Google Font, fallback `Oswald, Impact, "Arial Narrow Bold", sans-serif`), 400 weight, uppercase, `letter-spacing: 0.018em`, `line-height: 1.02`, white, text-shadow `0 4px 16px <c2 + alpha>, 0 1px 0 rgba(0,0,0,0.3)`. Size scales by title length: 26px for ≤14 chars, 21px for ≤20, 17px for ≤26, 15px for longer (box aspect; see `components.jsx → GameCover` for square/banner variants).
- **Vignette** — `linear-gradient(180deg, transparent 30%, rgba(0,0,0,0.62) 100%)` over the whole cover, painted *after* the title (so the dark gradient is behind the title visually — title is z-index 2).
- **State chip** in top-right: pill with backdrop-blur, `background rgba(8,12,16,0.78)`, `1px solid rgba(255,255,255,0.08)`, `border-radius: 999px`, `padding: 4px 9px`, font `10.5px / 600`. A 6×6 colored dot (green `#22c55e` for installed, amber `#f59e0b` for local; hidden for "not downloaded") + label. Dot has glow `box-shadow: 0 0 8px <color>`.
- **Multiplayer badge** in top-left: same pill style but slightly lighter background (`rgba(8,12,16,0.65)`). Tiny "users" icon + player range (e.g. `232`). Always visible — every LAN game is multiplayer.
3. **Card body**`padding: 11px 12px 12px`, flex column, 8px gap:
- **Title** — game's full (mixed-case) title in 13.5px / 600 / `--t-1`, single line, ellipsis on overflow.
- **Meta line** — 11.5px tabular-nums, `--t-3`: size · genre. Dot separator at 50% opacity.
- **Action button** (full width) — primary action depending on state, see below.
### Action button
A single button per card with the *primary action for the current state*. Color-coded as the main affordance for state at a glance.
```
state label button style
───────────── ────────── ────────────────────────────────────────────
not downloaded Download neutral: bg rgba(255,255,255,0.08), 1px var(--bd-2), text var(--t-1)
local Install bg var(--accent), text white, inset top hl
installed Play bg linear-gradient(180deg, #2bd07f 0%, #1aa460 100%), text white, inset top hl
downloading — progress see "Download progress" below — the button slot is replaced with a live progress component
```
Common sizing: 32px tall (card) or 44px tall (modal). `border-radius: 7px` (card) / 8px (modal). `font 12.5px / 600` (card) / `14px / 600` (modal). 6px gap between icon and label. Icons: filled play triangle, download arrow, install arrow-onto-line (all 12×12).
Hover: `filter: brightness(1.12)`. Active: `transform: scale(0.98)`.
**Uninstall / Delete-from-disk** are NOT on the card — only in the detail overlay (as ghost-danger buttons).
---
## Download progress (state === 'downloading')
When a game is actively downloading, the **action-button slot is replaced** by an inline progress component. The component is its own visual primitive (`DownloadProgress` in `components.jsx`); it is NOT a button with a `<progress>` child. Two layouts share the same primitive:
### Shared visuals
- Container: `border-radius: 7px` (card) / `9px` (modal), `1px solid color-mix(in srgb, var(--accent) 45%, var(--bd-2))`, faint accent halo via `box-shadow`. `container-type: inline-size` (we use container queries for graceful fallback, see below).
- **Progress fill** (`.dl-fill`): absolutely positioned, `width: <pct>%`, animated via `transition: width 480ms cubic-bezier(.4,0,.2,1)`. Background is a vertical gradient of `color-mix(in srgb, var(--accent) 3826%, transparent)`. Right edge gets a 1px accent rule + accent glow.
- **Live shimmer** on top of the fill: `repeating-linear-gradient(115deg, transparent 0 14px, rgba(255,255,255,0.05) 14px 22px)` panned via `animation: dl-stripe 1.4s linear infinite`, `mix-blend-mode: screen`. Subtle — it reads as "live" without being distracting.
- **Pulse dot** (`.dl-pulse`): 7px accent dot with an outward-pulsing `box-shadow` ring (1.4s ease-out infinite). Visual cue that the network transfer is active.
- **Tabular numerics** on all values (`font-variant-numeric: tabular-nums`) so the percentage and speed don't jitter as digits roll over.
### Card layout (`.dl-md`, replaces the 32px action button)
A single row. Two values, separated by `justify-content: space-between`:
- **Left:** `<pulse> <pct>%` — 12px / 600, `var(--t-1)`. `%` glyph at 0.55 opacity. e.g. `• 32%`.
- **Right:** `<speed>` — 11px / 500, `var(--t-2)`. Short format: `49 MB/s` (no decimals at card scale).
Heights match the action button per density: 30px compact / 32px normal / 34px large. Padding `0 10px` (9 compact / 12 large). Font sizes scale similarly (see `styles.css`).
**Container-query graceful degradation** — this is the important part, it has to fit every aspect/density combo:
```css
@container (max-width: 132px) { .dl-md .dl-speed { display: none; } .dl-md-row { justify-content: center; gap: 6px; } }
@container (max-width: 96px) { .dl-md .dl-pulse { display: none; } }
```
At 132 px and below, the speed disappears and the percentage centres. At 96 px and below, the pulse dot also drops, leaving just the percentage. This is what guarantees `compact` density + `box` aspect (the narrowest combination) still reads cleanly.
The state chip in the cover corner still says "Downloading" — we are deliberately NOT repeating that label inside the progress bar.
### Detail-overlay layout (`.dl-lg`, replaces the 44px modal action button)
Fixed 56px height. CSS-grid with three columns and two rows:
```
grid-template-columns: minmax(0, 1fr) auto auto;
grid-template-areas:
"primary pct cancel"
"secondary pct cancel";
```
- **Primary row** (`.dl-lg-primary`, top-left) — pulse dot + the uppercase live label `DOWNLOADING` in `color-mix(in srgb, var(--accent) 80%, white)`, 13px / 600, `letter-spacing: 0.02em`. This is the only place the word "Downloading" appears in the component.
- **Secondary row** (`.dl-lg-secondary`, bottom-left) — the live stats. 12px, four groups separated by `·` (0.45 opacity):
1. `<strong>11.4 GB</strong> / 35 GB` (`var(--t-1)` strong + `var(--t-2)` rest)
2. `47.6 MB/s` (`var(--t-1)`)
3. `[users-icon] 5``.dl-peers`, inline-flex with 4px gap, icon at 0.7 opacity, count in `var(--t-1)` 600 tabular-nums. Hidden entirely when `game.peers` is falsy. Communicates this is a LAN swarm transfer; the full sentence lives in the `title` tooltip.
4. `8 min left` (`var(--t-2)`)
- **pct column** — large percentage, 20px / 700, `letter-spacing: -0.01em`, `var(--t-1)`. `%` glyph at 12px / 600 / 0.55 opacity.
- **cancel column** — 28×28 square, `1px solid var(--bd-2)`, `border-radius: 6px`, X icon. Hover: bg `rgba(239,68,68,0.12)`, border `rgba(239,68,68,0.40)`, text `#fca5a5`. Cancelling reverts the game to its prior state (`local` if any data was kept, `none` otherwise) — dev decides the underlying behavior.
**Graceful degradation in narrow modals:**
```css
@container (max-width: 320px) { .dl-lg-secondary .dl-eta, .dl-lg-secondary .dl-sep-eta { display: none; } }
@container (max-width: 240px) { .dl-lg-secondary .dl-peers, .dl-lg-secondary .dl-sep-peers { display: none; } }
```
ETA drops first, then peers; bytes + speed always stay (they're the actionable numbers). The pct/cancel column never collapses.
### Number formatting
All helpers live in `data.jsx`:
- `fmtSpeed(mbps)``49.4 MB/s` below 100, `MM MB/s` (rounded) at/above 100. Used in `.dl-lg`.
- `fmtSpeedShort(mbps)` — always rounded: `49 MB/s`. Used in `.dl-md` so the card stays compact.
- `fmtBytes(gb)``<1 GB → MB rounded`, `<10 GB → up to 2 decimals` (trailing zeros stripped: `2.35 GB`, `2.3 GB`, `2 GB`), `≥10 GB → 1 decimal max` (`11.4 GB`, `35 GB`).
- `fmtEta(seconds)``< 60s → "N s"`, `< 60min → "N min"`, else `"H h M min"`.
Keep these formats; they're tuned so the secondary row never wraps at normal modal width.
### Data shape
The `Game` type gains a `downloading` state plus two transient fields:
```ts
type Game = {
// … existing fields …
state: 'installed' | 'local' | 'downloading' | 'none';
progress?: number; // 01, only when state === 'downloading'
speed?: number; // current throughput in MB/s
peers?: number; // number of LAN peers currently seeding
};
```
In the real app, `progress`, `speed`, and `peers` come from the download worker (Tauri command emitting events). The mock's `useLiveDownload(game)` hook (in `components.jsx`) is just a placeholder — 600ms `setInterval` advancing `progress` proportional to `speed`, with `speed` smoothed via a low-pass filter and small random drift so the number doesn't look fake. `peers` is read straight off the game object (static in the mock); in production, push updates as peers join/leave the swarm — the `.dl-peers` chip re-renders silently. Replace the hook with a `useEffect` that subscribes to your real progress events; the rendering layer needs nothing else.
Filter changes:
- `Local` filter includes `installed` + `local` + `downloading` (in-flight downloads belong on the Local tab — you're managing them).
- Sort by `state` orders `installed < local < downloading < none`.
### State chip
Add a fourth entry to `STATE_META`:
```js
downloading: { label: 'Downloading', dot: 'var(--accent)' }
```
Dot uses the live accent so it visually ties to the progress fill.
---
## Call to Play
A coordination layer that sits on top of the library: **rally the people on the
LAN around a specific game at a specific time.** It answers the actual social
question at a LAN party ("what are we all playing, and when?") that a plain
library grid can't.
Source: `calltoplay.jsx` (the feature) + `ctp-chat.jsx` (per-call chat + shared
`ctpAvatarColor` / `ctpFmtClock` helpers). Styles are the `ctp-*` classes in
`styles.css`. Backed by the mock LAN **peer roster** `PEERS` in `data.jsx`.
### Two flavors of call
| | **Play now** | **Scheduled** |
|---|---|---|
| Set up with | game + max players + **duration** (5/10/15/30/60 min) | game + max players + **clock time** (24h, + which day) |
| Others respond | `Ready now` or `+N minutes` | `I'm in` (RSVP), then check in later |
| Resolves | when the roster fills **or** the timer runs out | at the scheduled time, after a check-in window |
| Who starts it | the **caller** decides the actual launch | the **caller**, once people have checked in |
**Check-in window.** `CHECKIN_LEAD_MS = 15 min`. A scheduled call sits in the
`scheduled` phase collecting RSVPs until 15 minutes before its start time, then
flips into the `checkin` phase: it lights up everywhere and everyone who said
"I'm in" is nudged to answer with the same `Ready now` / `+N minutes` states as
a play-now call. A play-now call is effectively "always in its check-in window."
**Every call carries a small group chat** (see "Per-call chat" below).
### Three surfaces
1. **Top-bar button** (`CallToPlayButton` / `.ctp-btn`) — flag icon + label +
an accent badge counting active (non-`started`) calls. Opens the overlay.
2. **Quick bars** (`CallToPlayTicker` / `.ctp-ticker-stack`) — a persistent
stack rendered at the top of the grid area, **one row per active call**.
Sorted **ready → starting-soon → the rest**, ties broken by whichever
resolves/starts soonest (`TICKER_RANK` = ready 0, soon 1, call/scheduled 2;
then by `deadline`). Clicking a row opens the overlay focused on that call.
3. **Overlay** (`CallToPlayOverlay`) — a modal (same scrim/panel treatment as
the other dialogs) with a header, a **Call a new match** button, the create
form, and a list of **nomination cards** (started calls sink to the bottom).
### Status model
Two derived values drive everything (`calltoplay.jsx`):
- `phaseOf(call)``'now'` (no `scheduledFor`) · `'scheduled'` (>15 min out) ·
`'checkin'` (within the 15-min lead).
- `statusOf(call)` (quick-bar/label status) → `'started'` · `'ready'`
(`readyCount >= maxPlayers`, or state `done`) · `'soon'` (`deadline - now ≤
15 min`) · `'scheduled'` (has a clock time) · `'call'` (a plain play-now call).
Quick-bar labels + LED colors: **SCHEDULED**, **CALL TO PLAY**, **STARTING
SOON**, **READY** (`TICKER_LABEL`), each with its own dot color via
`.ctp-ticker-dot[data-status]`.
**Per-participant ready state:** `ready` (explicitly readied, or their `readyAt`
countdown has elapsed) · `in` (RSVP'd to a scheduled call but not checked in
yet) · `pending` (checked in with a `+N minutes` buffer, counting down). Shown
as compact **ready-bubbles** in the quick bars (`MiniBubbles`: initials +
green ring/check when ready, `+Nm` tag when pending, dimmed when just "in") and
as larger `AvatarChip`s in the nomination card roster.
### Nomination card (`NominationCard`)
Top to bottom:
1. **Header** — square game cover + title + a sub-line: `Called by <creator> · N/M peers have it installed` (or `Scheduled by <creator> · starts at HH:MM · …`), and a **timer** on the right: a live `M:SS` countdown for play-now/check-in, the **clock time + "in N min"** for a scheduled call, `Ready`, or `Launching…`. Countdown urgency (`data-urgency` high/mid/low) tints it as time runs low.
2. **Check-in note** — only in the `checkin` phase: a clock icon + "Starting soon — check-in is open" (or a personalized nudge if you RSVP'd).
3. **Progress bar** — time remaining as a fill (accent, → green when done); hidden while a call is still in the far-out `scheduled` phase.
4. **Roster**`readyCount/maxPlayers ready` (scheduled shows `N in · up to M players`; check-in adds `· K not checked in yet`), then avatar chips for each participant plus empty slots up to `maxPlayers`.
5. **Actions** — context-dependent on your role (**creator** / **participant** / **outsider**) and phase: `Ready now` + `+5/10/15/30m` buffer buttons, `I'm in` (RSVP), `Start now` / `Add 5 more minutes` (creator once resolved), `Leave` / `Can't make it`, or a status note.
6. **Chat** — the collapsible per-call chat panel.
7. **Cancel** — creators get a `Cancel this call` link with an inline confirm.
### Create form (`CreateNominationForm`)
Game search (typeahead over the catalog) → on pick, max-players defaults to the
game's parsed player cap (`parseMaxPlayers`). **When** toggles `Now` vs
`Schedule`. `Now` reveals the **Give people** duration chips. `Schedule` reveals
a **24-hour time picker** (hour/minute steppers **and** a "Type a time" free-text
field accepting `20:00` / `2000` / `9:30`) plus **day chips** (Today + next two
days). The confirm button reads `Call it — <game>` or `Schedule it — <game> ·
<day> <time>`.
### Per-call chat (`CtpChat`, `ctp-chat.jsx`)
Collapsed to a one-line toggle showing an unread badge + a preview of the last
message; expands to a compact IRC-style list (colored usernames via
`ctpAvatarColor`, `HH:MM` timestamps) + an input. All chat state is local to the
card. Usernames are colored deterministically by a hash of the name.
### Data shape
```ts
type Nomination = {
id: string;
gameId: string;
creator: string; // username of the caller
maxPlayers: number;
createdAt: number; // ms epoch
scheduledFor: number | null; // ms epoch clock time; null = play-now
deadline: number; // play-now: createdAt + durationMin; scheduled: === scheduledFor
participants: Record<string, { // keyed by username
status: 'ready' | 'in' | 'pending';
joinedAt: number;
readyAt?: number; // ms epoch a 'pending' buffer elapses
}>;
messages: { id: string; from: string; text: string; at: number }[];
state: 'open' | 'done' | 'started';
startedAt?: number;
};
```
The `useNominations({ username, seed })` hook owns the list and exposes
`createNomination`, `respond`, `rsvp`, `sendMessage`, `leave`, `cancel`,
`startNow`, `addTime`.
### Mock vs production
The mock **simulates other people** with a 1-second `setInterval`
(`tickNomination`): bots RSVP to scheduled calls, ready-up during check-in, walk
in late, and occasionally post a chat line — purely so the demo resolves
visibly (bots use second-scale buffers; real people use the minute-scale ones).
The mock also seeds a few representative calls on mount (a live call with chat,
a fresh call you started, a scheduled call whose check-in window just opened,
and one scheduled for later collecting RSVPs).
**In production, replace the simulation with a real-time LAN transport:** the
launcher instances broadcast nominations, responses, RSVPs, and chat messages to
each other (whatever the app uses — a small pub/sub over the LAN, a lightweight
signaling server, or Tauri-side networking). Swap the guts of `useNominations`
(the `setInterval` + local `setNoms`) for your networking layer that pushes the
same state shape; the rendering components need nothing else. The `username` that
identifies "you" comes from `settings.username` (the Profile setting), not a
prop default.
---
## Filter controls — variant B (not used, kept for reference)
The two-row chrome has a different filter style — **underlined tabs with counts**, like browser tabs:
- Buttons: no background, `padding: 10px 14px 12px`, font `13.5px / 600`. Color `--t-2` inactive, `--t-1` active.
- Count chip after label: 11.5px / 600, `padding 1px 7px`, rounded pill. Inactive bg `rgba(255,255,255,0.06)`, text `--t-3`. Active bg `rgba(255,255,255,0.10)`, text `--t-1`.
- Active tab has a 2px underline at the bottom (`left: 12px, right: 12px`) in `--accent`, animated in via opacity + scaleX (220ms cubic-bezier).
Implement only if you decide variant A doesn't work after building.
---
## Interactions & behavior
- **Click game card** (anywhere except the action button) → open detail overlay.
- **Click action button on card** → trigger the state-appropriate action without opening the overlay. `e.stopPropagation()` on the button.
- **Press / (slash)** → focus the search input.
- **Type in search** → live-filter the visible grid by title or tag (case-insensitive substring).
- **Click filter tab / segmented pill** → change filter.
- **Click sort button** → opens dropdown; click an option → re-sort grid; clicking outside the menu closes it.
- **Hover game card** → lift + accent border glow + cover image scale 1.03.
- **Click "Settings"** in kebab → open Settings dialog. Changes apply live and persist immediately (no Apply button — Done just closes).
- **Click "Change…" / "Choose…" in the Settings → Library → Game folder row** → open native folder picker via Tauri; on selection, write to `settings.gameFolder` and rescan library. The field indicates whether a valid folder is currently configured (mono path + neutral `Change…`) or not (red `Not set` + accent-filled `Choose…`) — see "Game-folder field" above.
- **Click "Unpack logs"** in kebab → opens a logs viewer (separate window or modal — out of scope for this design).
- **Click "Refresh library"** in kebab → re-runs the library scan.
- **Esc** → closes any open modal (detail overlay, Settings).
### Transitions / animations
- Card hover: `180ms cubic-bezier(.4,1.2,.5,1)` on transform/border, `350ms cubic-bezier(.4,1.2,.5,1)` on cover scale.
- Modal fade-in: scrim `opacity 0 → 1` over 180ms ease; modal `transform: scale(.96) translateY(8px) → scale(1) translateY(0)` and opacity over 250ms `cubic-bezier(.3,1.3,.4,1)`.
- Segmented filter thumb: `220ms cubic-bezier(.4,1.2,.5,1)` on `left` and `width`.
- Underline tab indicator (variant B): `200ms` on opacity, `250ms cubic-bezier(.4,1.2,.5,1)` on `transform: scaleX`.
- Animated background option: subtle 18s ease-in-out infinite alternate background-position shift on two accent-tinted radial gradients.
---
## State management
Recommend Zustand or a single React context for global launcher state; Tauri commands for filesystem and process operations.
**Library state** (rebuilt on `refresh`):
```ts
type Game = {
id: string;
title: string;
size: number; // GB
version: string; // "YYYY.MM.DD"
desc: string;
state: 'installed' | 'local' | 'downloading' | 'none';
progress?: number; // 01 — present only when state === 'downloading'
speed?: number; // MB/s — present only when state === 'downloading'
peers?: number; // LAN peers currently seeding
players: string; // e.g. "232"
tags: string[];
cover: { c1: string; c2: string; accent: string; mood?: string };
canHostServer?: boolean; // true if the game ships with a dedicated-server binary
};
```
**Server-capable games** in the mock catalog (`canHostServer: true`): BF1942, BF2, CoD2, CoD4, CoD:UO, CS 1.6, CS:Source, Cube 2/Sauerbraten, Doom 3, L4D2, Minecraft, Quake III, TF2, UT2004. RTS / social-deduction / co-op-only-P2P games (AoE II HD, RA3, Generals ZH, Among Us, Portal 2, StarCraft, Warcraft III, AvP, 8-Bit Armies, BlazeRush) are not flagged — they host in-game. In production the flag should come from the same per-game manifest that drives titles / sizes / cover art. Wire each entry to whatever launch command the dedicated server uses (`hldsexec`, `srcds`, `minecraft_server.jar`, etc.); the IPC stub looks like `startServer(gameId)` returning a handle or process id.
**UI state:**
```ts
type LauncherUI = {
filter: 'all' | 'local' | 'installed';
sort: 'az' | 'size' | 'recent' | 'state';
query: string;
openGameId: string | null;
settingsOpen: boolean;
};
```
**Persisted settings** (mirror of Settings dialog state):
```ts
type LauncherSettings = {
username: string;
language: 'en' | 'de';
accent: string; // hex from the curated 6-color palette
bg: 'flat' | 'gradient' | 'animated';
density: 'compact' | 'normal' | 'large';
aspect: 'box' | 'square' | 'banner';
gameFolder: string | null; // v3: moved out of top bar, persists actual path
};
```
Persist via Tauri's plugin-store or a local JSON file in app data dir. Changes from the Settings dialog should write through immediately (no Apply button).
**Storage figures:** computed by summing game sizes per state, plus free-space query via Tauri.
---
## Design tokens
### Color
| token | value | usage |
|---|---|---|
| `--bg-0` | `#0a0e13` | launcher background |
| `--bg-1` | `#0f151c` | card bottom gradient stop |
| `--bg-2` | `#131b25` | top bar / card top / search bg |
| `--bg-3` | `#1a2330` | settings segmented bg / cover fallback |
| `--bg-4` | `#232f3e` | (reserved) |
| `--bd-1` | `rgba(255,255,255,0.06)` | subtle border |
| `--bd-2` | `rgba(255,255,255,0.10)` | stronger border |
| `--bd-3` | `rgba(255,255,255,0.16)` | scrollbar thumb |
| `--t-1` | `#e6edf3` | primary text |
| `--t-2` | `#9aa6b4` | secondary text |
| `--t-3` | `#6b7785` | muted text / metadata |
| `--t-4` | `#4a5663` | (reserved) |
| `--ok` | `#22c55e` | "installed" dot |
| `--warn` | `#f59e0b` | "local" dot |
| `--danger` | `#ef4444` | destructive actions |
| `--accent` | user-selected, default `#3b82f6` | primary actions, focus rings, brand mark |
### Typography
- **UI font** — system sans stack: `-apple-system, BlinkMacSystemFont, "Segoe UI Variable", "Segoe UI", system-ui, sans-serif`
- **Cover-art display font** — `"Bebas Neue"` (Google Fonts, weight 400) with fallback `"Oswald", Impact, "Arial Narrow Bold", sans-serif`
- **Monospace** — `ui-monospace, "SF Mono", Menlo, Consolas, monospace` (used for: directory path, version field in detail overlay)
Sizing reference:
- Brand wordmark: 15 / 700
- Modal title: 32 / 700 / -0.015em
- Card title: 13.5 / 600
- Filter pill label: 12.5 / 600
- Settings row label: 14 / 600
- Section title (settings): 10.5 / 700 / 0.12em uppercase
- Meta (size · genre, etc.): 11.5 / tabular-nums / `--t-3`
- Hint text: 12 / `--t-3`
### Spacing & radii
- Card radius: 10px
- Modal radius: 14px
- Pill/control radius: 8px (search, sort, dir button), 999px (filter segmented), 7px (action button)
- Common gaps: 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 28
- Card body padding: 11 12 12
### Shadows
- Card hover: `0 14px 30px -16px color-mix(var(--accent), 50%, black), 0 0 0 1px color-mix(var(--accent), 30%, transparent)`
- Modal: `0 30px 80px -10px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.04)`
- Brand mark: `0 6px 20px -6px color-mix(var(--accent), 60%, black), inset 0 1px 0 rgba(255,255,255,0.22)`
- Action button (filled): `0 6px 16px -8px <color>, inset 0 1px 0 rgba(255,255,255,0.22)`
---
## Assets
Cover art in the design files is **stylized placeholder art** — generated entirely from the game's metadata (color pair + accent color + id hash for angle/blob position) plus the title typeset in Bebas Neue. There are no real game cover image assets in this design.
In the production app, the launcher should ideally use real cover-art when available (fetch from IGDB / Steam / local game folder) and fall back to the placeholder generator for games without art. The placeholder generator is in `design_reference/components.jsx → GameCover`.
The icon set (search, play, **server**, install, download, folder, kebab, sort, users, close, check, chevron, trash, **flag**, **clock**, **chat**, **send**, **caretUp**, **caretDown**) is in `design_reference/components.jsx → Icon`. They are 12-14px inline SVGs using `currentColor`. Reuse as-is or substitute with the codebase's existing icon library at the same visual weight. The `server` glyph drives the Start Server button; `flag` / `clock` / `chat` / `send` / `caretUp` / `caretDown` are used by Call to Play.
Fonts to load:
```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bebas+Neue&display=swap">
```
---
## File reference
```
design_reference/
├── SoftLAN Launcher.html ← entry; wires React + Babel, mounts <App>
├── styles.css ← all visual styles (CSS custom props + components)
├── data.jsx ← mock GAMES array + PEERS roster + filter/sort helpers + STORAGE mock
├── components.jsx ← Icon, GameCover, StateChip, ActionButton, GameCard,
│ SegmentedFilters, UnderlineFilters, SearchField,
│ SortMenu, StorageMeter, KebabMenu,
│ GameDetailModal, SettingsDialog (incl. GameFolderField)
├── ctp-chat.jsx ← CtpChat panel + ctpAvatarColor / ctpFmtClock helpers
├── calltoplay.jsx ← Call to Play: useNominations hook, quick bars,
│ overlay, nomination cards, create form
└── launcher.jsx ← <Launcher> component composing chrome + grid +
modals + Call to Play
```
To preview the design in a browser:
1. Open `SoftLAN Launcher.html` in a static-server (e.g. `python -m http.server` from the folder).
2. You'll see a design canvas with all variants side-by-side. Click an artboard's expand button to view it full-screen.
- **Call to Play** (top section) — the quick-bar main view, and the same with the overlay open (live call + chat, your call, a check-in nudge, and a scheduled RSVP). Click anything to interact live — create a call, ready up, RSVP, chat.
- **A / B** — chrome variants (A is the chosen direction)
- **C** — detail overlay for an installed, server-capable game (Counter-Strike 1.6) → shows **Play + Start Server + Uninstall**
- **D** — detail overlay for a downloaded-but-not-installed game (CoD 4) → shows **Install + Delete from disk**
- **E** — detail overlay for a downloading game (AvP) → shows the live progress component + **Cancel**
- **F** — Settings dialog open, with the new **Profile** section at the top
3. The "Tweaks" floating panel in the bottom-right is dev-only — it lets you live-change every persisted setting (username / language / accent / background / density / aspect / game folder). In the production app these all live in the Settings dialog.
---
## Out of scope / open questions for the developer
- **Unpack logs viewer** — referenced from kebab menu but not designed. Surface it as a separate window or a slide-in panel, dev's choice.
- **Empty state** — when filter returns 0 games (e.g. nothing installed yet). Show a centered message with a CTA to install the first game.
- **Error state on action** — if a Download / Install fails, show inline error on the affected card (red border + retry button), and a toast.
- **Progress state** — designed. See "Download progress" section above. The action-button slot is swapped for a live `DownloadProgress` component (card + modal variants with container-query fallback for narrow tiles). Wire it to your real progress events; the rendering layer is dev-ready.
- **Keyboard arrow nav** — arrow keys should move focus between cards in the grid; not implemented in the mock but mentioned as a goal.
- **"Server running" state** — once Start Server actually spawns a process, the button should switch to a *running* state (live indicator dot + "Server running" label + click-to-stop). Not designed this round — flag for follow-up alongside whatever server-status panel the app grows.
- **Call to Play real-time transport** — the feature is fully designed and interactive, but the mock fakes other players with a local `setInterval` simulation. Production needs a real LAN transport that broadcasts nominations / responses / RSVPs / chat between launcher instances and pushes the same state shape into `useNominations`. Notifications when a call you're in enters its check-in window (OS notification / tray) are also a follow-up.
- **German translations** — the language toggle is wired in Settings, but the catalog of translated UI strings hasn't been compiled. Stand up `react-i18next` (or equivalent) and seed `en.json` from the existing copy; `de.json` is a translation task for whoever owns localization.
@@ -23,6 +23,8 @@
<script type="text/babel" src="tweaks-panel.jsx"></script>
<script type="text/babel" src="data.jsx"></script>
<script type="text/babel" src="components.jsx"></script>
<script type="text/babel" src="ctp-chat.jsx"></script>
<script type="text/babel" src="calltoplay.jsx"></script>
<script type="text/babel" src="launcher.jsx"></script>
</head>
<body>
@@ -32,7 +34,7 @@
const { useState } = React;
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"accent": "#3b82f6",
"accent": "#a855f7",
"density": "normal",
"aspect": "square",
"bg": "gradient",
@@ -47,9 +49,77 @@ function App() {
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
const heroGame = GAMES.find(g => g.id === 'cs'); // installed + canHostServer → shows Play + Start Server + Uninstall
// Seed data for the "Call to Play" showcase — a live call with chat, a
// fresh call started by you, a SCHEDULED call whose 15-minute check-in
// window just opened (highlighted, you still have to ready up), and one
// scheduled for later tonight that's still collecting "I'm in" RSVPs.
const ctpSeeds = React.useMemo(() => {
const now = Date.now();
const me = (t.username || 'you').trim() || 'you';
return [
{
gameId: 'cs', creator: 'shadowfox', maxPlayers: 10, ageSec: 95, durationMin: 25,
participants: {
pixelqueen: { status: 'ready', joinedAt: now - 60000 },
kbmaster: { status: 'ready', joinedAt: now - 40000 },
nullptr: { status: 'pending', joinedAt: now - 20000, readyAt: now + 50000 },
},
messages: [
{ from: 'shadowfox', text: 'dust2, default rules — who is in?', agoSec: 90 },
{ from: 'kbmaster', text: 'in. someone seed the map pack for nullptr', agoSec: 60 },
{ from: 'nullptr', text: 'downloading, 2 min', agoSec: 35 },
],
},
{
gameId: 'l4d2', creator: me, maxPlayers: 4, ageSec: 15, durationMin: 6,
participants: {
afk_andy: { status: 'pending', joinedAt: now - 5000, readyAt: now + 35000 },
},
},
{
gameId: 'tf2', creator: 'wavedash', maxPlayers: 12, ageSec: 3600, scheduledInMin: 12,
participants: {
[me]: { status: 'in', joinedAt: now - 3000000 },
gg_val: { status: 'ready', joinedAt: now - 2000000 },
crit_happens: { status: 'in', joinedAt: now - 1500000 },
lowping_larry: { status: 'pending', joinedAt: now - 1000000, readyAt: now + 240000 },
},
messages: [
{ from: 'wavedash', text: 'payload maps tonight — check in when you are at your seat', agoSec: 300 },
{ from: 'gg_val', text: 'ready, lobby is open', agoSec: 120 },
],
},
{
gameId: 'amongus', creator: 'pixelqueen', maxPlayers: 10, ageSec: 5400, scheduledInMin: 128,
participants: {
ctrlaltdave: { status: 'in', joinedAt: now - 4000000 },
afk_andy: { status: 'in', joinedAt: now - 3000000 },
},
messages: [
{ from: 'pixelqueen', text: 'right after the pizza break — bring headphones', agoSec: 4800 },
{ from: 'ctrlaltdave', text: 'in. can we start 10 min later?', agoSec: 3600 },
],
},
];
}, []); // eslint-disable-line react-hooks/exhaustive-deps
return (
<React.Fragment>
<DesignCanvas>
<DCSection id="call-to-play" title="Call to Play — now, scheduled &amp; chat"
subtitle="Anyone can call a match — right now (give people N minutes to ready up) or scheduled for a clock time ('Among Us at 20:00'). Scheduled calls collect 'I'm in' RSVPs; 15 minutes before start the call enters its check-in window and lights up everywhere, nudging everyone who's in to answer Ready now or +N minutes. Each call carries a small group chat. The quick bars lead with the status — SCHEDULED / CALL TO PLAY / STARTING SOON / READY, each with its own LED color — sorted ready → starting soon → the rest (soonest first), with per-player ready-bubbles and the latest chat line inline. Click anything to interact live.">
<DCArtboard id="ctp-quickbars" label="Main view — quick bars with ready-bubbles + check-in highlight" width={1340} height={840}>
<Launcher variant="single" tweaks={t} setTweak={setTweak}
initialFilter="all" initialSort="recent"
seedCallToPlay={ctpSeeds}/>
</DCArtboard>
<DCArtboard id="ctp-live" label="Overlay — live call, your call, check-in &amp; scheduled RSVP + chat" width={1340} height={840}>
<Launcher variant="single" tweaks={t} setTweak={setTweak}
initialFilter="all" initialSort="recent"
seedCallToPlay={ctpSeeds} initialCtpOpen={true}/>
</DCArtboard>
</DCSection>
<DCSection id="chrome" title="Chrome variations"
subtitle="Two ways to organize the top bar — pick whichever density of controls you prefer. Click any card to open the detail overlay, or the kebab menu to open Settings.">
<DCArtboard id="single-row" label="A · Single-row + segmented filters" width={1340} height={840}>
@@ -0,0 +1,814 @@
// calltoplay.jsx — "Call to Play": rally the LAN around a game + a time.
//
// Two flavors of call, one feature:
// · Play NOW — pick a game, max players, and how long to give people.
// Others respond "Ready now" or "+N minutes". Resolves early if the
// roster fills, or when the clock runs out — the CALLER starts it.
// · SCHEDULED — pick a game and a clock time ("Among Us at 20:00").
// People RSVP with "I'm in". 15 minutes before start the call enters
// its CHECK-IN window: it gets highlighted everywhere and everyone
// who's in is nudged to answer with the same ready / +N-minute states.
//
// Every call also carries a small group chat (see ctp-chat.jsx).
//
// Loaded after data.jsx + components.jsx + ctp-chat.jsx, before launcher.jsx.
const { useState, useEffect, useMemo, useRef, useLayoutEffect } = React;
const GAMES_ = window.GAMES;
const PEERS_ = window.PEERS;
const installedPeersFor_ = window.installedPeersFor;
const parseMaxPlayers_ = window.parseMaxPlayers;
const Icon_ = window.Icon;
const GameCover_ = window.GameCover;
const CtpChat_ = window.CtpChat;
const avatarColor = window.ctpAvatarColor;
const fmtClock = window.ctpFmtClock;
// Check-in opens this long before a scheduled call's start time — and any
// call (scheduled or not) counts as "starting soon" inside this window.
const CHECKIN_LEAD_MS = 15 * 60000;
function phaseOf(n, now) {
if (!n.scheduledFor) return 'now';
return now < n.scheduledFor - CHECKIN_LEAD_MS ? 'scheduled' : 'checkin';
}
// Quick-bar status: READY > STARTING SOON > CALL TO PLAY / SCHEDULED.
function statusOf(n, now) {
if (n.state === 'started') return 'started';
if (n.state === 'done' || readyCountOf(n, now) >= n.maxPlayers) return 'ready';
if (n.deadline - now <= CHECKIN_LEAD_MS) return 'soon';
return n.scheduledFor ? 'scheduled' : 'call';
}
function fmtCountdown(ms) {
if (ms <= 0) return '0:00';
const total = Math.ceil(ms / 1000);
const m = Math.floor(total / 60), s = total % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}
function fmtCountdownShort(ms) {
if (ms <= 0) return 'now';
const total = Math.ceil(ms / 1000);
if (total < 60) return `${total}s`;
return `${Math.ceil(total / 60)}m`;
}
function fmtUntil(ms) {
if (ms <= 60000) return 'in <1 min';
const min = Math.round(ms / 60000);
if (min < 60) return `in ${min} min`;
const h = Math.floor(min / 60), m = min % 60;
return m ? `in ${h}h ${m}m` : `in ${h}h`;
}
function isReady(p, now) {
if (!p) return false;
return p.status === 'ready' || (p.readyAt != null && now >= p.readyAt);
}
function readyCountOf(n, now) {
return Object.values(n.participants).filter(p => isReady(p, now)).length;
}
function inCountOf(n, now) {
return Object.values(n.participants).filter(p => !isReady(p, now) && p.status === 'in').length;
}
// ────────────────────────────────────────────────────────────────────
// Simulation — bots trickle into open calls (RSVP on scheduled ones,
// ready-up during check-in / now-calls) and occasionally post a chat
// line. Bots use second-scale buffers purely so the demo resolves
// visibly; real people would use minute-scale ones.
// ────────────────────────────────────────────────────────────────────
const CTP_BOT_LINES = [
'anyone need the game files? I can seed',
'gimme 5, grabbing food',
'who hosts?',
'voice is up — channel 2',
'ok, back at my desk',
'poke your neighbor, we need one more',
'default settings, no mods — right?',
'glhf',
];
function maybeBotChat(n, now) {
const msgs = n.messages || [];
const speakers = Object.keys(n.participants).filter(p => PEERS_.includes(p));
if (!speakers.length || msgs.length > 40 || Math.random() >= 0.03) return msgs;
const from = speakers[Math.floor(Math.random() * speakers.length)];
const text = CTP_BOT_LINES[Math.floor(Math.random() * CTP_BOT_LINES.length)];
return [...msgs, { id: `bm${now}${Math.random().toString(36).slice(2, 6)}`, from, text, at: now }];
}
function tickNomination(n, now) {
if (n.state !== 'open') return n;
const phase = phaseOf(n, now);
let participants = n.participants;
const joined = Object.keys(participants);
const available = PEERS_.filter(p => p !== n.creator && !joined.includes(p));
if (phase === 'scheduled') {
// RSVP trickle — far from start, people just say "I'm in".
if (available.length && joined.length < n.maxPlayers && Math.random() < 0.06) {
const bot = available[Math.floor(Math.random() * available.length)];
participants = { ...participants, [bot]: { status: 'in', joinedAt: now } };
}
return { ...n, participants, messages: maybeBotChat({ ...n, participants }, now) };
}
// Check-in window: RSVP'd bots convert to ready / "+N minutes".
const inBots = joined.filter(p => PEERS_.includes(p) && participants[p].status === 'in');
if (phase === 'checkin' && inBots.length && Math.random() < 0.22) {
const bot = inBots[Math.floor(Math.random() * inBots.length)];
participants = {
...participants,
[bot]: Math.random() < 0.6
? { ...participants[bot], status: 'ready' }
: { ...participants[bot], status: 'pending', readyAt: now + (10 + Math.random() * 70) * 1000 },
};
} else if (available.length && joined.length < n.maxPlayers && Math.random() < 0.10) {
// Walk-ins can still join late.
const bot = available[Math.floor(Math.random() * available.length)];
const readyNow = Math.random() < 0.55;
participants = {
...participants,
[bot]: readyNow
? { status: 'ready', joinedAt: now }
: { status: 'pending', joinedAt: now, readyAt: now + (10 + Math.random() * 70) * 1000 },
};
}
const messages = maybeBotChat({ ...n, participants }, now);
const readyCount = readyCountOf({ participants }, now);
const state = (readyCount >= n.maxPlayers || now >= n.deadline) ? 'done' : 'open';
return { ...n, participants, messages, state };
}
function makeSeedNomination({ gameId, creator, maxPlayers, ageSec = 0, durationMin = 10, scheduledInMin = null, participants = {}, messages = [] }) {
const now = Date.now() - ageSec * 1000;
const scheduledFor = scheduledInMin != null ? Date.now() + scheduledInMin * 60000 : null;
return {
id: `seed-${gameId}-${creator}`,
gameId, creator, maxPlayers,
createdAt: now,
scheduledFor,
deadline: scheduledFor != null ? scheduledFor : now + durationMin * 60000,
participants: { [creator]: { status: scheduledFor ? 'in' : 'ready', joinedAt: now }, ...participants },
messages: messages.map((m, i) => ({ id: `sm-${gameId}-${i}`, from: m.from, text: m.text, at: Date.now() - (m.agoSec || 0) * 1000 })),
state: 'open',
};
}
function useNominations({ username, seed = [] }) {
const [noms, setNoms] = useState(() => seed.map(makeSeedNomination));
useEffect(() => {
const id = setInterval(() => {
setNoms(prev => {
const now = Date.now();
return prev
.map(n => tickNomination(n, now))
.filter(n => !(n.state === 'started' && now - (n.startedAt || 0) > 2600));
});
}, 1000);
return () => clearInterval(id);
}, []);
const createNomination = (gameId, maxPlayers, durationMin, scheduledFor = null) => {
const now = Date.now();
const nom = {
id: `n${now}${Math.random().toString(36).slice(2, 7)}`,
gameId, creator: username, maxPlayers,
createdAt: now,
scheduledFor,
deadline: scheduledFor != null ? scheduledFor : now + durationMin * 60000,
participants: { [username]: { status: scheduledFor ? 'in' : 'ready', joinedAt: now } },
messages: [],
state: 'open',
};
setNoms(prev => [nom, ...prev]);
};
const respond = (id, choice) => {
setNoms(prev => prev.map(n => {
if (n.id !== id) return n;
const now = Date.now();
const participants = { ...n.participants };
participants[username] = choice === 'ready'
? { status: 'ready', joinedAt: (participants[username] || {}).joinedAt || now }
: { status: 'pending', joinedAt: (participants[username] || {}).joinedAt || now, readyAt: now + choice * 60000 };
return { ...n, participants };
}));
};
// RSVP for a scheduled call: "I'm in".
const rsvp = (id) => {
setNoms(prev => prev.map(n => n.id === id
? { ...n, participants: { ...n.participants, [username]: { status: 'in', joinedAt: Date.now() } } }
: n));
};
const sendMessage = (id, text) => {
const t = String(text || '').trim();
if (!t) return;
setNoms(prev => prev.map(n => n.id === id
? { ...n, messages: [...(n.messages || []), { id: `m${Date.now()}${Math.random().toString(36).slice(2, 6)}`, from: username, text: t, at: Date.now() }] }
: n));
};
const leave = (id) => {
setNoms(prev => prev.map(n => {
if (n.id !== id) return n;
const participants = { ...n.participants };
delete participants[username];
return { ...n, participants };
}));
};
const cancel = (id) => setNoms(prev => prev.filter(n => n.id !== id));
const startNow = (id) => setNoms(prev => prev.map(n => n.id === id ? { ...n, state: 'started', startedAt: Date.now() } : n));
const addTime = (id, minutes = 5) => setNoms(prev => prev.map(n => n.id === id ? { ...n, deadline: Date.now() + minutes * 60000, state: 'open' } : n));
return { nominations: noms, createNomination, respond, rsvp, sendMessage, leave, cancel, startNow, addTime };
}
// ────────────────────────────────────────────────────────────────────
// Top-bar entry point
// ────────────────────────────────────────────────────────────────────
function CallToPlayButton({ nominations, onClick }) {
const active = nominations.filter(n => n.state !== 'started');
return (
<button className="ctp-btn" onClick={onClick}>
<Icon_.flag/>
<span>Call to Play</span>
{active.length > 0 && <span className="ctp-badge">{active.length}</span>}
</button>
);
}
// ────────────────────────────────────────────────────────────────────
// Mini ready-bubbles — the per-player state, compressed for the
// quick bars: green ring + check = ready, "+Nm" tag = needs minutes,
// dimmed = RSVP'd but not checked in yet.
// ────────────────────────────────────────────────────────────────────
function MiniBubbles({ nomination: n, now, max = 6 }) {
const entries = Object.entries(n.participants);
const shown = entries.slice(0, max);
return (
<span className="ctp-ticker-bubbles">
{shown.map(([name, p]) => {
const ready = isReady(p, now);
const st = ready ? 'ready' : p.status === 'in' ? 'in' : 'pending';
const initials = name.replace(/[^a-z0-9]/gi, '').slice(0, 2).toUpperCase();
const title = ready ? `${name} — ready`
: st === 'in' ? `${name} — in, not checked in yet`
: `${name} — ready in ${fmtCountdownShort(p.readyAt - now)}`;
return (
<span key={name} className="ctp-mini" data-state={st} title={title} style={{ background: avatarColor(name) }}>
{initials}
{ready && <span className="ctp-mini-check"><Icon_.check/></span>}
{st === 'pending' && <i className="ctp-mini-tag">{fmtCountdownShort(p.readyAt - now)}</i>}
</span>
);
})}
{entries.length > max && <span className="ctp-mini ctp-mini-more">+{entries.length - max}</span>}
</span>
);
}
// ────────────────────────────────────────────────────────────────────
// Persistent quick bars — one row per active call, soonest first.
// Scheduled calls show their clock time; once check-in opens the row
// lights up to nudge everyone who said "I'm in".
// ────────────────────────────────────────────────────────────────────
const TICKER_LABEL = { scheduled: 'Scheduled', call: 'Call to Play', soon: 'Starting soon', ready: 'Ready' };
// Sort rank: ready first, starting-soon next, everything else after —
// ties broken by whichever resolves/starts soonest.
const TICKER_RANK = { ready: 0, soon: 1, call: 2, scheduled: 2 };
function CallToPlayTickerRow({ nomination: n, accent, onOpen }) {
const [, setTick] = useState(0);
useEffect(() => { const id = setInterval(() => setTick(t => t + 1), 1000); return () => clearInterval(id); }, []);
const game = GAMES_.find(g => g.id === n.gameId);
if (!game) return null;
const now = Date.now();
const status = statusOf(n, now);
const ready = readyCountOf(n, now);
const total = Object.keys(n.participants).length;
const remaining = Math.max(0, n.deadline - now);
const msgs = n.messages || [];
const last = msgs[msgs.length - 1];
const count = status === 'scheduled' ? `${total} in` : `${ready}/${n.maxPlayers} ready`;
const time = status === 'ready' ? 'waiting to start'
: status === 'scheduled' ? `${fmtClock(n.scheduledFor)} · ${fmtUntil(n.scheduledFor - now)}`
: n.scheduledFor ? `starts ${fmtClock(n.scheduledFor)} · ${fmtCountdown(remaining)}`
: fmtCountdown(remaining);
return (
<button className="ctp-ticker" data-status={status} style={{ '--accent': accent }} onClick={() => onOpen(n.id)}>
<span className="ctp-ticker-dot" data-status={status}/>
<span className="ctp-ticker-label" data-status={status}>{TICKER_LABEL[status]}</span>
<span className="ctp-ticker-game">{game.title}</span>
<span className="ctp-ticker-by">by {n.creator}</span>
<span className="ctp-ticker-ready">{count}</span>
<span className="ctp-ticker-time">{time}</span>
{last ? (
<span className="ctp-ticker-chat" title={`${last.from}: ${last.text}`}>
<Icon_.chat/>
<b style={{ color: avatarColor(last.from) }}>{last.from}:</b>
<span className="ctp-ticker-chat-text">{last.text}</span>
</span>
) : <span className="ctp-ticker-chat"/>}
<MiniBubbles nomination={n} now={now}/>
<span className="ctp-ticker-cta">{status === 'soon' ? 'Check in' : 'View'}<Icon_.chevron/></span>
</button>
);
}
function CallToPlayTicker({ nominations, accent, onOpen }) {
const now = Date.now();
const active = nominations.filter(n => n.state !== 'started')
.sort((a, b) => TICKER_RANK[statusOf(a, now)] - TICKER_RANK[statusOf(b, now)] || a.deadline - b.deadline);
if (!active.length) return null;
return (
<div className="ctp-ticker-stack">
{active.map(n => <CallToPlayTickerRow key={n.id} nomination={n} accent={accent} onOpen={onOpen}/>)}
</div>
);
}
// ────────────────────────────────────────────────────────────────────
// Avatar chip — ready / counting-down / RSVP'd ("in")
// ────────────────────────────────────────────────────────────────────
function AvatarChip({ name, participant, now }) {
const ready = isReady(participant, now);
const isIn = !ready && participant.status === 'in';
const remaining = participant.readyAt ? Math.max(0, participant.readyAt - now) : 0;
const initials = name.replace(/[^a-z0-9]/gi, '').slice(0, 2).toUpperCase();
const title = ready ? `${name} — ready`
: isIn ? `${name} — in, not checked in yet`
: `${name} — ready in ${fmtCountdown(remaining)}`;
return (
<div className={`ctp-avatar ${ready ? 'is-ready' : isIn ? 'is-in' : 'is-pending'}`} title={title}>
<span className="ctp-avatar-dot" style={{ background: avatarColor(name) }}>{initials}</span>
{ready
? <span className="ctp-avatar-check"><Icon_.check/></span>
: isIn
? <span className="ctp-avatar-in">in</span>
: <span className="ctp-avatar-pending">{fmtCountdownShort(remaining)}</span>}
</div>
);
}
// ────────────────────────────────────────────────────────────────────
// Nomination card
// ────────────────────────────────────────────────────────────────────
function NominationCard({ nomination: n, username, accent, actions, focused }) {
const [, setTick] = useState(0);
useEffect(() => { const id = setInterval(() => setTick(t => t + 1), 1000); return () => clearInterval(id); }, []);
const [confirmCancel, setConfirmCancel] = useState(false);
const game = GAMES_.find(g => g.id === n.gameId);
if (!game) return null;
const now = Date.now();
const entries = Object.entries(n.participants);
const readyCount = readyCountOf(n, now);
const inCount = inCountOf(n, now);
const isMe = username in n.participants;
const myStatus = n.participants[username];
const isCreator = n.creator === username;
const isDone = n.state === 'done';
const isStarted = n.state === 'started';
const phase = phaseOf(n, now);
const isSched = phase === 'scheduled' && !isDone && !isStarted;
const isCheckin = phase === 'checkin' && !isDone && !isStarted;
const windowStart = n.scheduledFor ? n.scheduledFor - CHECKIN_LEAD_MS : n.createdAt;
const totalMs = Math.max(1, n.deadline - windowStart);
const remaining = Math.max(0, n.deadline - now);
const pct = Math.max(0, Math.min(100, (remaining / totalMs) * 100));
const urgency = pct < 20 ? 'high' : pct < 50 ? 'mid' : 'low';
const peerCount = installedPeersFor_(game).length;
let actionsBody;
if (isStarted) {
actionsBody = <div className="ctp-note ctp-note-launch">Launching {game.title} for {n.maxPlayers} players</div>;
} else if (isSched) {
if (isCreator) {
actionsBody = <div className="ctp-note">Scheduled for {fmtClock(n.scheduledFor)} check-in opens 15 min before start.</div>;
} else if (isMe) {
actionsBody = (
<React.Fragment>
<div className="ctp-me-status">Youre in well nudge you to check in 15 min before start</div>
<button className="ghost-btn ghost-danger" onClick={() => actions.leave(n.id)}>Cant make it</button>
</React.Fragment>
);
} else {
actionsBody = (
<React.Fragment>
<button className="act-btn act-play" onClick={() => actions.rsvp(n.id)}><Icon_.check/><span>Im in</span></button>
<div className="ctp-note">Check-in opens 15 min before start</div>
</React.Fragment>
);
}
} else if (isCreator) {
if (isDone) {
actionsBody = (
<React.Fragment>
<button className="act-btn act-play" onClick={() => actions.startNow(n.id)}><Icon_.play/><span>Start now</span></button>
<button className="ghost-btn" onClick={() => actions.addTime(n.id, 5)}>Add 5 more minutes</button>
</React.Fragment>
);
} else if (myStatus && myStatus.status === 'in') {
actionsBody = (
<React.Fragment>
<button className="act-btn act-play" onClick={() => actions.respond(n.id, 'ready')}><Icon_.play/><span>Ready now</span></button>
<div className="ctp-buffer-group">
{[5, 10, 15].map(min => (
<button key={min} className="ctp-buffer-btn" onClick={() => actions.respond(n.id, min)}>+{min}m</button>
))}
</div>
</React.Fragment>
);
} else {
actionsBody = <div className="ctp-note">Waiting for players to ready up</div>;
}
} else if (isDone) {
actionsBody = <div className="ctp-note">{readyCount >= n.maxPlayers ? 'Everyones ready.' : `Its time — waiting for ${n.creator} to start.`}</div>;
} else if (isMe) {
if (myStatus.status === 'in') {
actionsBody = (
<React.Fragment>
<div className="ctp-me-status">You said youre in check in:</div>
<button className="act-btn act-play" onClick={() => actions.respond(n.id, 'ready')}><Icon_.play/><span>Ready now</span></button>
<div className="ctp-buffer-group">
{[5, 10, 15].map(min => (
<button key={min} className="ctp-buffer-btn" onClick={() => actions.respond(n.id, min)}>+{min}m</button>
))}
</div>
<button className="ghost-btn ghost-danger" onClick={() => actions.leave(n.id)}>Leave</button>
</React.Fragment>
);
} else {
const ready = isReady(myStatus, now);
actionsBody = (
<React.Fragment>
<div className="ctp-me-status">{ready ? 'Youre in — ready' : `You: ready in ${fmtCountdown(myStatus.readyAt - now)}`}</div>
{!ready && <button className="ghost-btn" onClick={() => actions.respond(n.id, 'ready')}>Im ready now</button>}
<button className="ghost-btn ghost-danger" onClick={() => actions.leave(n.id)}>Leave</button>
</React.Fragment>
);
}
} else {
actionsBody = (
<React.Fragment>
<button className="act-btn act-play" onClick={() => actions.respond(n.id, 'ready')}><Icon_.play/><span>Ready now</span></button>
<div className="ctp-buffer-group">
{[5, 10, 15, 30].map(min => (
<button key={min} className="ctp-buffer-btn" onClick={() => actions.respond(n.id, min)}>+{min}m</button>
))}
</div>
</React.Fragment>
);
}
const timerEl = isStarted ? <div className="ctp-card-timer" data-urgency="off">Launching</div>
: isDone ? <div className="ctp-card-timer" data-urgency="off">Ready</div>
: isSched ? (
<div className="ctp-card-timer is-sched" data-urgency="off">
<span className="ctp-card-clock">{fmtClock(n.scheduledFor)}</span>
<span className="ctp-card-until">{fmtUntil(n.scheduledFor - now)}</span>
</div>
)
: isCheckin ? (
<div className="ctp-card-timer is-sched" data-urgency={urgency}>
<span className="ctp-card-clock">{fmtCountdown(remaining)}</span>
<span className="ctp-card-until">starts {fmtClock(n.scheduledFor)}</span>
</div>
)
: <div className="ctp-card-timer" data-urgency={urgency}>{fmtCountdown(remaining)}</div>;
const subEl = n.scheduledFor
? <React.Fragment>Scheduled by <strong>{n.creator}</strong> · starts at {fmtClock(n.scheduledFor)} · {peerCount}/{PEERS_.length} peers have it installed</React.Fragment>
: <React.Fragment>Called by <strong>{n.creator}</strong> · {peerCount}/{PEERS_.length} peers have it installed</React.Fragment>;
const rosterLabel = isSched
? `${entries.length} in · up to ${n.maxPlayers} players`
: isCheckin && inCount > 0
? `${readyCount}/${n.maxPlayers} ready · ${inCount} not checked in yet`
: `${readyCount}/${n.maxPlayers} ready`;
return (
<div className={`ctp-card ${isDone ? 'is-done' : ''} ${isStarted ? 'is-started' : ''} ${isCheckin ? 'is-checkin' : ''} ${focused ? 'is-focused' : ''}`}>
<div className="ctp-card-top">
<div className="ctp-card-cover"><GameCover_ game={game} aspect="square"/></div>
<div className="ctp-card-info">
<div className="ctp-card-title">{game.title}</div>
<div className="ctp-card-sub">{subEl}</div>
</div>
{timerEl}
</div>
{isCheckin && (
<div className="ctp-checkin-note">
<Icon_.clock/>
<span>{isMe && myStatus && myStatus.status === 'in'
? 'Starting soon — you said youre in. Check in below.'
: 'Starting soon — check-in is open.'}</span>
</div>
)}
{!isSched && (
<div className="ctp-progress">
<div className="ctp-progress-fill"
style={{ width: `${isStarted || isDone ? 100 : pct}%`, background: isDone || isStarted ? 'var(--ok)' : accent }}/>
</div>
)}
<div className="ctp-roster">
<div className="ctp-roster-count">{rosterLabel}</div>
<div className="ctp-avatars">
{entries.map(([name, p]) => <AvatarChip key={name} name={name} participant={p} now={now}/>)}
{Array.from({ length: Math.max(0, n.maxPlayers - entries.length) }).map((_, i) => (
<div key={`empty${i}`} className="ctp-avatar ctp-avatar-empty"/>
))}
</div>
</div>
<div className="ctp-actions">{actionsBody}</div>
<CtpChat_ nomination={n} username={username} disabled={isStarted}
onSend={(text) => actions.sendMessage(n.id, text)}/>
{isCreator && !isStarted && (
confirmCancel ? (
<div className="ctp-cancel-confirm">
<span>Cancel this call for everyone?</span>
<div className="ctp-cancel-confirm-btns">
<button className="ghost-btn ghost-danger" onClick={() => actions.cancel(n.id)}>Yes, cancel it</button>
<button className="ghost-btn" onClick={() => setConfirmCancel(false)}>No, keep it</button>
</div>
</div>
) : (
<button className="ctp-cancel-link" onClick={() => setConfirmCancel(true)}>
<Icon_.trash/><span>Cancel this call</span>
</button>
)
)}
</div>
);
}
// ────────────────────────────────────────────────────────────────────
// Create-nomination form — "Now" or a scheduled clock time
// ────────────────────────────────────────────────────────────────────
function CreateNominationForm({ accent, onCancel, onCreate }) {
const [query, setQuery] = useState('');
const [gameId, setGameId] = useState(null);
const [maxPlayers, setMaxPlayers] = useState(8);
const [duration, setDuration] = useState(10);
const [when, setWhen] = useState('now');
const [dayOffset, setDayOffset] = useState(0);
const nextSlot = () => {
const d = new Date(Date.now() + 40 * 60000);
d.setMinutes(d.getMinutes() <= 30 ? 30 : 60, 0, 0);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
};
const [timeStr, setTimeStr] = useState(nextSlot); // committed "HH:MM" (24h)
const [editing, setEditing] = useState(false);
const [timeDraft, setTimeDraft] = useState(timeStr);
const timeInputRef = useRef(null);
// Select-all when the type-a-time field appears, so the user can overtype
// immediately. Done in a layout effect (not onFocus+autoFocus) so it fires
// after the browser places the autofocus caret, and re-asserts if the
// per-second nominations tick re-renders the overlay.
useLayoutEffect(() => {
if (editing && timeInputRef.current) {
timeInputRef.current.focus();
timeInputRef.current.select();
}
}, [editing]);
const [hStr, mStr] = timeStr.split(':');
// Nudge the time by whole hours or minute-steps, wrapping within 023:59.
const bump = (field, delta) => {
let [h, m] = timeStr.split(':').map(Number);
if (field === 'h') h = (h + delta + 24) % 24;
else {
let total = h * 60 + m + delta;
total = ((total % 1440) + 1440) % 1440;
h = Math.floor(total / 60); m = total % 60;
}
setTimeStr(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`);
};
// Constrain free-text entry: digits + at most one colon, ≤2 digits per side
// (≤5 chars with a colon), ≤4 digits without one. Anything else is rejected
// (returns the previous draft) — so a colon can't be typed after 3 digits.
const sanitizeTime = (raw, prev) => {
const v = String(raw).replace(/[^\d:]/g, '');
if ((v.match(/:/g) || []).length > 1) return prev;
const ci = v.indexOf(':');
if (ci !== -1) {
if (v.slice(0, ci).length > 2 || v.slice(ci + 1).length > 2) return prev;
return v;
}
return v.length > 4 ? prev : v;
};
// Accepts "20:00", "2000", "20", "9:30" — normalizes or reverts.
const commitDraft = () => {
const mm = String(timeDraft).trim().match(/^(\d{1,2})(?:[:.\s]?(\d{2}))?$/);
if (mm) {
const h = Number(mm[1]), min = Number(mm[2] || 0);
if (h <= 23 && min <= 59) setTimeStr(`${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}`);
}
setEditing(false);
};
const DE_DAYS = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
const dayChips = [0, 1, 2].map(off => {
const d = new Date();
d.setDate(d.getDate() + off);
const label = off === 0 ? 'Today' : DE_DAYS[d.getDay()];
return { off, label };
});
const matches = useMemo(() => {
if (!query.trim()) return [];
const q = query.toLowerCase();
return GAMES_.filter(g => g.title.toLowerCase().includes(q)).slice(0, 6);
}, [query]);
const selected = GAMES_.find(g => g.id === gameId);
const pickGame = (g) => {
setGameId(g.id);
setMaxPlayers(parseMaxPlayers_(g.players));
setQuery(g.title);
};
const scheduledTs = () => {
const [h, m] = timeStr.split(':').map(Number);
const d = new Date();
d.setDate(d.getDate() + dayOffset);
d.setHours(h || 0, m || 0, 0, 0);
if (d.getTime() < Date.now() + 60000) d.setDate(d.getDate() + 1);
return d.getTime();
};
return (
<div className="ctp-create">
<div className="ctp-create-row">
<label className="ctp-create-label">Game</label>
<div className="search ctp-create-search" style={{ '--accent': accent }}>
<Icon_.search/>
<input type="text" placeholder="Search the catalog…" value={query}
onChange={(e) => { setQuery(e.target.value); setGameId(null); }}/>
</div>
{!selected && query.trim() && (
<div className="ctp-create-matches">
{matches.map(g => (
<button key={g.id} onClick={() => pickGame(g)}>
<span>{g.title}</span>
<span className="ctp-create-match-meta">{g.players} players</span>
</button>
))}
{matches.length === 0 && <div className="ctp-create-nomatch">No games match "{query}"</div>}
</div>
)}
</div>
{selected && (
<React.Fragment>
<div className="ctp-create-row ctp-create-row-inline">
<label className="ctp-create-label">Max players</label>
<input type="number" className="ctp-create-num" min={2} max={64} value={maxPlayers}
onChange={(e) => setMaxPlayers(Math.max(2, Number(e.target.value) || 2))}/>
<span className="ctp-create-hint">Suggested for {selected.title}: {parseMaxPlayers_(selected.players)}</span>
</div>
<div className="ctp-create-row">
<label className="ctp-create-label">When</label>
<div className="ctp-duration-opts">
<button className={`ctp-duration-btn ${when === 'now' ? 'is-active' : ''}`}
style={when === 'now' ? { background: accent, borderColor: accent } : undefined}
onClick={() => setWhen('now')}>Now</button>
<button className={`ctp-duration-btn ${when === 'later' ? 'is-active' : ''}`}
style={when === 'later' ? { background: accent, borderColor: accent } : undefined}
onClick={() => setWhen('later')}>Schedule</button>
</div>
{when === 'later' && (
<React.Fragment>
<div className="ctp-timepick">
{editing ? (
<input ref={timeInputRef} type="text" inputMode="numeric" maxLength={5} placeholder="20:00"
className="ctp-time-editinput" value={timeDraft}
onChange={(e) => setTimeDraft(prev => sanitizeTime(e.target.value, prev))}
onBlur={commitDraft}
onKeyDown={(e) => { if (e.key === 'Enter') commitDraft(); if (e.key === 'Escape') setEditing(false); }}/>
) : (
<React.Fragment>
<div className="ctp-timepick-field">
<button type="button" className="ctp-time-step" aria-label="Hour up" onClick={() => bump('h', 1)}><Icon_.caretUp/></button>
<span className="ctp-time-cell">{hStr}</span>
<button type="button" className="ctp-time-step" aria-label="Hour down" onClick={() => bump('h', -1)}><Icon_.caretDown/></button>
</div>
<span className="ctp-time-colon">:</span>
<div className="ctp-timepick-field">
<button type="button" className="ctp-time-step" aria-label="Minute up" onClick={() => bump('m', 15)}><Icon_.caretUp/></button>
<span className="ctp-time-cell">{mStr}</span>
<button type="button" className="ctp-time-step" aria-label="Minute down" onClick={() => bump('m', -15)}><Icon_.caretDown/></button>
</div>
<button type="button" className="ctp-time-type"
onClick={() => { setTimeDraft(timeStr); setEditing(true); }}>Type a time</button>
</React.Fragment>
)}
</div>
<div className="ctp-time-row ctp-day-row">
<span className="ctp-day-label">Day</span>
{dayChips.map(c => (
<button key={c.off} className={`ctp-duration-btn ctp-day-btn ${dayOffset === c.off ? 'is-active' : ''}`}
style={dayOffset === c.off ? { background: accent, borderColor: accent } : undefined}
onClick={() => setDayOffset(c.off)}>{c.label}</button>
))}
</div>
<span className="ctp-create-hint">24-hour clock. People RSVP with Im in check-in to ready up opens 15 min before start.</span>
</React.Fragment>
)}
</div>
{when === 'now' && (
<div className="ctp-create-row">
<label className="ctp-create-label">Give people</label>
<div className="ctp-duration-opts">
{[5, 10, 15, 30, 60].map(min => (
<button key={min} className={`ctp-duration-btn ${duration === min ? 'is-active' : ''}`}
style={duration === min ? { background: accent, borderColor: accent } : undefined}
onClick={() => setDuration(min)}>{min}m</button>
))}
</div>
</div>
)}
<div className="ctp-create-foot">
<button className="ghost-btn" onClick={onCancel}>Cancel</button>
<button className="act-btn act-play"
onClick={() => onCreate(selected.id, maxPlayers, duration, when === 'later' ? scheduledTs() : null)}>
{when === 'later'
? `Schedule it — ${selected.title} · ${dayOffset > 0 ? `${dayChips[dayOffset].label} ` : ''}${timeStr}`
: `Call it — ${selected.title}`}
</button>
</div>
</React.Fragment>
)}
{!selected && (
<div className="ctp-create-foot">
<button className="ghost-btn" onClick={onCancel}>Cancel</button>
</div>
)}
</div>
);
}
// ────────────────────────────────────────────────────────────────────
// Full overlay
// ────────────────────────────────────────────────────────────────────
function CallToPlayOverlay({ nominations, username, accent, onClose, actions, focusId }) {
const [showCreate, setShowCreate] = useState(false);
const sorted = [...nominations].sort((a, b) => (a.state === 'started' ? 1 : 0) - (b.state === 'started' ? 1 : 0) || a.deadline - b.deadline);
return (
<div className="modal-scrim" onClick={onClose}>
<div className="modal ctp-modal" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={onClose} aria-label="Close"><Icon_.close/></button>
<div className="ctp-head">
<h2>Call to Play</h2>
<p className="ctp-head-sub">Rally the LAN around a game and a time right now, or scheduled for later with an Im in RSVP. The caller decides when it actually starts.</p>
{!showCreate && (
<button className="act-btn act-play ctp-head-new" onClick={() => setShowCreate(true)}>
<Icon_.flag/><span>Call a new match</span>
</button>
)}
</div>
<div className="ctp-body">
{showCreate && (
<CreateNominationForm accent={accent}
onCancel={() => setShowCreate(false)}
onCreate={(gid, max, dur, sched) => { actions.createNomination(gid, max, dur, sched); setShowCreate(false); }}/>
)}
{sorted.length === 0 && !showCreate && (
<div className="ctp-empty">No active calls right now be the one to start something.</div>
)}
{sorted.map(n => (
<NominationCard key={n.id} nomination={n} username={username} accent={accent} actions={actions}
focused={n.id === focusId}/>
))}
</div>
</div>
</div>
);
}
Object.assign(window, {
useNominations, makeSeedNomination,
CallToPlayButton, CallToPlayTicker, CallToPlayOverlay,
NominationCard, CreateNominationForm,
});
@@ -20,6 +20,12 @@ const Icon = {
check: (p) => <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="m3 8 3.5 3.5L13 5"/></svg>,
chevron:(p) => <svg viewBox="0 0 16 16" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="m4 6 4 4 4-4"/></svg>,
trash: (p) => <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M3 4.5h10"/><path d="M5.5 4.5V3a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1.5"/><path d="M4.5 4.5 5 13a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1l.5-8.5"/></svg>,
flag: (p) => <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M3.5 1.5v13"/><path d="M3.5 2.25c3-1.3 4.5 1.3 7.5 0v6.7c-3 1.3-4.5-1.3-7.5 0"/></svg>,
clock: (p) => <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" {...p}><circle cx="8" cy="8" r="6"/><path d="M8 4.5V8l2.5 1.5"/></svg>,
chat: (p) => <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M2 3.5A1.5 1.5 0 0 1 3.5 2h9A1.5 1.5 0 0 1 14 3.5v6a1.5 1.5 0 0 1-1.5 1.5H6.5L3 14v-3h.5A1.5 1.5 0 0 1 2 9.5z"/></svg>,
send: (p) => <svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor" {...p}><path d="M1.5 2 15 8 1.5 14l2.3-6z"/></svg>,
caretUp: (p) => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="m4 10 4-4 4 4"/></svg>,
caretDown: (p) => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="m4 6 4 4 4-4"/></svg>,
};
//
@@ -217,6 +223,10 @@ function GameCard({ game, accent, aspect, onOpen }) {
<span>{fmtSize(game.size)}</span>
<span className="card-dot">·</span>
<span>{game.tags[0]}</span>
<span className="card-dot">·</span>
<span className="card-peers" title={`${installedPeersFor(game).length} of ${PEERS.length} peers on the LAN have this installed`}>
<Icon.users/>{installedPeersFor(game).length}/{PEERS.length}
</span>
</div>
<ActionButton state={game.state} accent={accent} full game={game}/>
</div>
@@ -293,6 +303,7 @@ const SORTS = [
{ key: 'size', label: 'Size (largest)' },
{ key: 'recent', label: 'Recently Played' },
{ key: 'state', label: 'Status' },
{ key: 'peers', label: 'Peers with it (most)' },
];
function SortMenu({ value, onChange, accent }) {
@@ -0,0 +1,86 @@
// ctp-chat.jsx — shared Call-to-Play helpers (avatar colors, clock formatting)
// plus the per-call chat panel. Loaded after components.jsx, before calltoplay.jsx.
const { useState: useChatState, useEffect: useChatEffect, useRef: useChatRef } = React;
const CTP_AVATAR_COLORS = ['#60a5fa', '#34d399', '#c084fc', '#fbbf24', '#f472b6', '#38bdf8', '#a3e635', '#fb7185'];
const ctpHashName = (s) => [...String(s)].reduce((a, c) => a + c.charCodeAt(0), 0);
const ctpAvatarColor = (name) => CTP_AVATAR_COLORS[ctpHashName(name) % CTP_AVATAR_COLORS.length];
// 24h clock, LAN-party style: "20:00"
const ctpFmtClock = (ts) => {
const d = new Date(ts);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
};
// ────────────────────────────────────────────────────────────────────
// Per-call chat — collapsed to a one-line toggle with an unread badge
// and a preview of the last message; expands to a compact IRC-style
// message list + input. All state is local to the card.
// ────────────────────────────────────────────────────────────────────
function CtpChat({ nomination: n, username, onSend, disabled }) {
const IconX = window.Icon;
const msgs = n.messages || [];
const [open, setOpen] = useChatState(false);
const [seen, setSeen] = useChatState(msgs.length);
const [draft, setDraft] = useChatState('');
const listRef = useChatRef(null);
useChatEffect(() => { if (open) setSeen(msgs.length); }, [open, msgs.length]);
useChatEffect(() => {
if (open && listRef.current) listRef.current.scrollTop = listRef.current.scrollHeight;
}, [open, msgs.length]);
const unread = Math.max(0, msgs.length - seen);
const last = msgs[msgs.length - 1];
const submit = () => {
const t = draft.trim();
if (!t) return;
onSend(t);
setDraft('');
};
return (
<div className={`ctp-chat ${open ? 'is-open' : ''}`}>
<button className="ctp-chat-toggle" onClick={() => setOpen(o => !o)}>
<IconX.chat/>
<span>Chat</span>
{msgs.length > 0 && <span className="ctp-chat-count">{msgs.length}</span>}
{!open && unread > 0 && <span className="ctp-chat-unread">{unread}</span>}
{!open && last && (
<span className="ctp-chat-preview">
<b style={{ color: ctpAvatarColor(last.from) }}>{last.from}:</b> {last.text}
</span>
)}
<span className="ctp-chat-chevron"><IconX.chevron/></span>
</button>
{open && (
<React.Fragment>
<div className="ctp-chat-list" ref={listRef}>
{msgs.length === 0 && <div className="ctp-chat-empty">No messages yet say hi.</div>}
{msgs.map(m => (
<div key={m.id} className={`ctp-chat-msg ${m.from === username ? 'is-me' : ''}`}>
<b style={{ color: ctpAvatarColor(m.from) }}>{m.from}</b>
<span className="ctp-chat-time">{ctpFmtClock(m.at)}</span>
<span className="ctp-chat-text"> {m.text}</span>
</div>
))}
</div>
{!disabled && (
<div className="ctp-chat-form">
<input type="text" className="ctp-chat-input" placeholder="Message the group…"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') submit(); }}/>
<button className="ctp-chat-send" onClick={submit} aria-label="Send"><IconX.send/></button>
</div>
)}
</React.Fragment>
)}
</div>
);
}
Object.assign(window, { CtpChat, ctpAvatarColor, ctpFmtClock });
@@ -203,7 +203,37 @@ const STORAGE = {
total: 512,
};
//
// LAN roster (mock) used by the peer-install indicator and by the
// Call to Play feature to simulate other people in the room.
//
const PEERS = [
'shadowfox', 'pixelqueen', 'kbmaster', 'nullptr', 'wavedash',
'crit_happens', 'afk_andy', 'lowping_larry', 'gg_val', 'ctrlaltdave',
];
const hashStr = (s) => [...String(s)].reduce((a, c) => a + c.charCodeAt(0), 0);
// Deterministic-but-varied subset of PEERS who "have this game installed"
// stable per game id so the same game always shows the same roster/count.
function installedPeersFor(game) {
const h = hashStr(game.id);
const count = 1 + (h % PEERS.length); // 1..PEERS.length
const scored = PEERS.map((p, i) => ({ p, s: (h * 7 + i * 13 + hashStr(p)) % 97 }));
scored.sort((a, b) => a.s - b.s);
return scored.slice(0, count).map(x => x.p);
}
// "216" / "1-8" 16 / 8. Falls back to 8 if unparsable.
function parseMaxPlayers(playersStr) {
const m = String(playersStr).match(/(\d+)\s*$/);
return m ? parseInt(m[1], 10) : 8;
}
window.GAMES = GAMES;
window.PEERS = PEERS;
window.installedPeersFor = installedPeersFor;
window.parseMaxPlayers = parseMaxPlayers;
window.STATE_META = STATE_META;
window.ACTION_FOR_STATE = ACTION_FOR_STATE;
window.countByFilter = countByFilter;
@@ -0,0 +1,966 @@
// DesignCanvas.jsx — Figma-ish design canvas wrapper
// Warm gray grid bg + Sections + Artboards + PostIt notes.
// Artboards are reorderable (grip-drag), deletable, labels/titles are
// inline-editable, and any artboard can be opened in a fullscreen focus
// overlay (←/→/Esc). State persists to a .design-canvas.state.json sidecar
// via the host bridge. No assets, no deps.
//
// Usage:
// <DesignCanvas>
// <DCSection id="onboarding" title="Onboarding" subtitle="First-run variants">
// <DCArtboard id="a" label="A · Dusk" width={260} height={480}>…</DCArtboard>
// <DCArtboard id="b" label="B · Minimal" width={260} height={480}>…</DCArtboard>
// </DCSection>
// </DesignCanvas>
const DC = {
bg: '#f0eee9',
grid: 'rgba(0,0,0,0.06)',
label: 'rgba(60,50,40,0.7)',
title: 'rgba(40,30,20,0.85)',
subtitle: 'rgba(60,50,40,0.6)',
postitBg: '#fef4a8',
postitText: '#5a4a2a',
font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
};
// One-time CSS injection (classes are dc-prefixed so they don't collide with
// the hosted design's own styles).
if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
const s = document.createElement('style');
s.id = 'dc-styles';
s.textContent = [
'.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:3px;padding:0 2px;margin:0 -2px}',
'.dc-editable:focus{background:#fff;box-shadow:0 0 0 1.5px #c96442}',
'[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}',
'[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}',
'[data-dc-slot].dc-dragging .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #c96442;transform:scale(1.02)}',
// isolation:isolate contains artboard content's z-indexes so a
// z-indexed child (sticky navbar etc.) can't paint over .dc-header or
// the .dc-menu popover that drops into the top of the card.
'.dc-card{isolation:isolate;transition:box-shadow .15s,transform .15s}',
'.dc-card *{scrollbar-width:none}',
'.dc-card *::-webkit-scrollbar{display:none}',
// Per-artboard header: grip + label on the left, delete/expand on the
// right. Single flex row; when the artboard's on-screen width is too
// narrow for both the label yields (ellipsis, then hidden entirely below
// ~4ch via the container query) and the buttons stay on the row.
'.dc-header{position:absolute;bottom:100%;left:-4px;margin-bottom:calc(4px * var(--dc-inv-zoom,1));z-index:2;',
' display:flex;align-items:center;container-type:inline-size}',
'.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px;flex:1 1 auto;min-width:0}',
'.dc-grip{flex:0 0 auto;cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s,opacity .12s}',
'.dc-grip:hover{background:rgba(0,0,0,.08)}',
'.dc-grip:active{cursor:grabbing}',
'.dc-labeltext{flex:1 1 auto;min-width:0;cursor:pointer;border-radius:4px;padding:3px 6px;',
' display:flex;align-items:center;transition:background .12s;overflow:hidden}',
// Below ~4ch of label room: hide the label entirely, and drop the grip to
// hover-only (same reveal rule as .dc-btns) so a narrow header is clean
// until the card is moused.
'@container (max-width: 110px){',
' .dc-labeltext{display:none}',
' .dc-grip{opacity:0}',
' [data-dc-slot]:hover .dc-grip{opacity:1}',
'}',
'.dc-labeltext:hover{background:rgba(0,0,0,.05)}',
'.dc-labeltext .dc-editable{overflow:hidden;text-overflow:ellipsis;max-width:100%}',
'.dc-labeltext .dc-editable:focus{overflow:visible;text-overflow:clip}',
'.dc-btns{flex:0 0 auto;margin-left:auto;display:flex;gap:2px;opacity:0;transition:opacity .12s}',
'[data-dc-slot]:hover .dc-btns,.dc-btns:has(.dc-menu){opacity:1}',
'.dc-expand,.dc-kebab{width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;',
' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center;',
' font:inherit;transition:background .12s,color .12s}',
'.dc-expand:hover,.dc-kebab:hover{background:rgba(0,0,0,.06);color:#2a251f}',
// Slot hosting an open menu floats above later siblings (which otherwise
// paint on top — same z-index:auto, later DOM order) so the popup isn't
// clipped by the next card.
'[data-dc-slot]:has(.dc-menu){z-index:10}',
'.dc-menu{position:absolute;top:100%;right:0;margin-top:4px;background:#fff;border-radius:8px;',
' box-shadow:0 8px 28px rgba(0,0,0,.18),0 0 0 1px rgba(0,0,0,.05);padding:4px;min-width:160px;z-index:10}',
'.dc-menu button{display:block;width:100%;padding:7px 10px;border:0;background:transparent;',
' border-radius:5px;font-family:inherit;font-size:13px;font-weight:500;line-height:1.2;',
' color:#29261b;cursor:pointer;text-align:left;transition:background .12s;white-space:nowrap}',
'.dc-menu button:hover{background:rgba(0,0,0,.05)}',
'.dc-menu hr{border:0;border-top:1px solid rgba(0,0,0,.08);margin:4px 2px}',
'.dc-menu .dc-danger{color:#c96442}',
'.dc-menu .dc-danger:hover{background:rgba(201,100,66,.1)}',
// Chrome (titles / labels / buttons) counter-scales against the viewport
// zoom so it stays a constant on-screen size. --dc-inv-zoom is set by
// DCViewport on every transform update and inherits to all descendants —
// any overlay inside the world (e.g. a TweaksPanel on an artboard) can use
// it the same way.
//
// The header uses transform:scale (out-of-flow, so layout impact doesn't
// matter) with its world-space width set to card-width / inv-zoom so that
// after counter-scaling its on-screen width exactly matches the card's —
// that's what lets the container query + text-overflow behave against the
// card's visible edge at every zoom level.
//
// The section head uses CSS zoom instead of transform so its layout box
// grows with the counter-scale, pushing the card row down — otherwise the
// constant-screen-size title would overflow into the (shrinking) world-
// space gap and overlap the artboard headers at low zoom.
'.dc-header{width:calc((100% + 4px) / var(--dc-inv-zoom,1));',
' transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom left}',
'.dc-sectionhead{zoom:var(--dc-inv-zoom,1)}',
].join('\n');
document.head.appendChild(s);
}
const DCCtx = React.createContext(null);
// Recursively unwrap React.Fragment so <>…</> grouping doesn't hide
// DCSection/DCArtboard children from the type-based walks below.
function dcFlatten(children) {
const out = [];
React.Children.forEach(children, (c) => {
if (c && c.type === React.Fragment) out.push(...dcFlatten(c.props.children));
else out.push(c);
});
return out;
}
// ─────────────────────────────────────────────────────────────
// DesignCanvas — stateful wrapper around the pan/zoom viewport.
// Owns runtime state (per-section order, renamed titles/labels, hidden
// artboards, focused artboard). Order/titles/labels/hidden persist to a
// .design-canvas.state.json
// sidecar next to the HTML. Reads go via plain fetch() so the saved
// arrangement is visible anywhere the HTML + sidecar are served together
// (omelette preview, direct link, downloaded zip). Writes go through the
// host's window.omelette bridge — editing requires the omelette runtime.
// Focus is ephemeral.
// ─────────────────────────────────────────────────────────────
const DC_STATE_FILE = '.design-canvas.state.json';
function DesignCanvas({ children, minScale, maxScale, style }) {
const [state, setState] = React.useState({ sections: {}, focus: null });
// Hold rendering until the sidecar read settles so the saved order/titles
// appear on first paint (no source-order flash). didRead gates writes until
// the read settles so the empty initial state can't clobber a slow read;
// skipNextWrite suppresses the one echo-write that would otherwise follow
// hydration.
const [ready, setReady] = React.useState(false);
const didRead = React.useRef(false);
const skipNextWrite = React.useRef(false);
React.useEffect(() => {
let off = false;
fetch('./' + DC_STATE_FILE)
.then((r) => (r.ok ? r.json() : null))
.then((saved) => {
if (off || !saved || !saved.sections) return;
skipNextWrite.current = true;
setState((s) => ({ ...s, sections: saved.sections }));
})
.catch(() => {})
.finally(() => { didRead.current = true; if (!off) setReady(true); });
const t = setTimeout(() => { if (!off) setReady(true); }, 150);
return () => { off = true; clearTimeout(t); };
}, []);
React.useEffect(() => {
if (!didRead.current) return;
if (skipNextWrite.current) { skipNextWrite.current = false; return; }
const t = setTimeout(() => {
window.omelette?.writeFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {});
}, 250);
return () => clearTimeout(t);
}, [state.sections]);
// Build registries synchronously from children so FocusOverlay can read
// them in the same render. Fragments are flattened; wrapping in other
// elements still opts out of focus/reorder.
const registry = {}; // slotId -> { sectionId, artboard }
const sectionMeta = {}; // sectionId -> { title, subtitle, slotIds[] }
const sectionOrder = [];
dcFlatten(children).forEach((sec) => {
if (!sec || sec.type !== DCSection) return;
const sid = sec.props.id ?? sec.props.title;
if (!sid) return;
sectionOrder.push(sid);
const persisted = state.sections[sid] || {};
const abs = [];
dcFlatten(sec.props.children).forEach((ab) => {
if (!ab || ab.type !== DCArtboard) return;
const aid = ab.props.id ?? ab.props.label;
if (aid) abs.push([aid, ab]);
});
// hidden is scoped to one source revision — when the agent regenerates
// (artboard-ID set changes), prior deletes don't apply to new content.
const srcKey = abs.map(([k]) => k).join('\x1f');
const hidden = persisted.srcKey === srcKey ? (persisted.hidden || []) : [];
const srcIds = [];
abs.forEach(([aid, ab]) => {
if (hidden.includes(aid)) return;
registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab };
srcIds.push(aid);
});
const kept = (persisted.order || []).filter((k) => srcIds.includes(k));
sectionMeta[sid] = {
title: persisted.title ?? sec.props.title,
subtitle: sec.props.subtitle,
slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))],
};
});
const api = React.useMemo(() => ({
state,
section: (id) => state.sections[id] || {},
patchSection: (id, p) => setState((s) => ({
...s,
sections: { ...s.sections, [id]: { ...s.sections[id], ...(typeof p === 'function' ? p(s.sections[id] || {}) : p) } },
})),
setFocus: (slotId) => setState((s) => ({ ...s, focus: slotId })),
}), [state]);
// Esc exits focus; any outside pointerdown commits an in-progress rename.
React.useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') api.setFocus(null); };
const onPd = (e) => {
const ae = document.activeElement;
if (ae && ae.isContentEditable && !ae.contains(e.target)) ae.blur();
};
document.addEventListener('keydown', onKey);
document.addEventListener('pointerdown', onPd, true);
return () => {
document.removeEventListener('keydown', onKey);
document.removeEventListener('pointerdown', onPd, true);
};
}, [api]);
return (
<DCCtx.Provider value={api}>
<DCViewport minScale={minScale} maxScale={maxScale} style={style}>{ready && children}</DCViewport>
{state.focus && registry[state.focus] && (
<DCFocusOverlay entry={registry[state.focus]} sectionMeta={sectionMeta} sectionOrder={sectionOrder} />
)}
</DCCtx.Provider>
);
}
// ─────────────────────────────────────────────────────────────
// DCViewport — transform-based pan/zoom (internal)
//
// Input mapping (Figma-style):
// • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events)
// • trackpad scroll → pan (two-finger)
// • mouse wheel → zoom (notched; distinguished from trackpad scroll)
// • middle-drag / primary-drag-on-bg → pan
//
// Transform state lives in a ref and is written straight to the DOM
// (translate3d + will-change) so wheel ticks don't go through React —
// keeps pans at 60fps on dense canvases.
// ─────────────────────────────────────────────────────────────
function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
const vpRef = React.useRef(null);
const worldRef = React.useRef(null);
const tf = React.useRef({ x: 0, y: 0, scale: 1 });
// Persist viewport across reloads so the user lands back where they were
// after an agent edit or browser refresh. The sandbox origin is already
// per-project; pathname keeps multiple canvas files in one project apart.
const tfKey = 'dc-viewport:' + location.pathname;
const saveT = React.useRef(0);
const lastPostedScale = React.useRef();
const apply = React.useCallback(() => {
const { x, y, scale } = tf.current;
const el = worldRef.current;
if (!el) return;
el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
// Exposed for zoom-invariant chrome (labels, buttons, TweaksPanel).
el.style.setProperty('--dc-inv-zoom', String(1 / scale));
// Keep the host toolbar's % readout in sync with the canvas scale. Pan
// ticks leave scale unchanged — skip the cross-frame post for those.
if (lastPostedScale.current !== scale) {
lastPostedScale.current = scale;
window.parent.postMessage({ type: '__dc_zoom', scale }, '*');
}
clearTimeout(saveT.current);
saveT.current = setTimeout(() => {
try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
}, 200);
}, [tfKey]);
React.useLayoutEffect(() => {
const flush = () => {
clearTimeout(saveT.current);
try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
};
try {
const s = JSON.parse(localStorage.getItem(tfKey) || 'null');
if (s && Number.isFinite(s.x) && Number.isFinite(s.y) && Number.isFinite(s.scale)) {
tf.current = { x: s.x, y: s.y, scale: Math.min(maxScale, Math.max(minScale, s.scale)) };
apply();
}
} catch {}
// Flush on pagehide and unmount so a reload within the 200ms debounce
// window doesn't drop the last pan/zoom.
window.addEventListener('pagehide', flush);
return () => { window.removeEventListener('pagehide', flush); flush(); };
}, []);
React.useEffect(() => {
const vp = vpRef.current;
if (!vp) return;
const zoomAt = (cx, cy, factor) => {
const r = vp.getBoundingClientRect();
const px = cx - r.left, py = cy - r.top;
const t = tf.current;
const next = Math.min(maxScale, Math.max(minScale, t.scale * factor));
const k = next / t.scale;
// --dc-inv-zoom consumers (.dc-sectionhead's CSS zoom, each section's
// marginBottom) reflow on every scale change, vertically shifting the
// world layout — so a world point mathematically pinned under the cursor
// drifts as you zoom (content creeps up on zoom-in, down on zoom-out).
// Anchor the DOM element under the cursor instead: record its screen Y,
// apply the transform + --dc-inv-zoom, then cancel whatever vertical
// drift the reflow introduced so it stays put on screen.
let marker = null, markerY0 = 0;
if (k !== 1) {
const hit = document.elementFromPoint(cx, cy);
marker = hit && hit.closest ? hit.closest('[data-dc-slot],[data-dc-section]') : null;
if (marker) markerY0 = marker.getBoundingClientRect().top;
}
// keep the world point under the cursor fixed
t.x = px - (px - t.x) * k;
t.y = py - (py - t.y) * k;
t.scale = next;
apply();
if (marker) {
// A pure zoom around (cx, cy) maps screen Y → cy + (Y - cy) * k. Any
// departure after the --dc-inv-zoom reflow is the layout drift.
const drift = marker.getBoundingClientRect().top - (cy + (markerY0 - cy) * k);
if (Math.abs(drift) > 0.1) { t.y -= drift; apply(); }
}
};
// Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends
// line-mode deltas (Firefox) or large integer pixel deltas with no X
// component (Chrome/Safari, typically multiples of 100/120). Trackpad
// two-finger scroll sends small/fractional pixel deltas, often with
// non-zero deltaX. ctrlKey is set by the browser for trackpad pinch.
const isMouseWheel = (e) =>
e.deltaMode !== 0 ||
(e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40);
const onWheel = (e) => {
e.preventDefault();
if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels
if ((e.ctrlKey || e.metaKey) && !isMouseWheel(e)) {
// trackpad pinch, or ctrl/cmd + smooth-scroll mouse. Notched
// wheels fall through to the fixed-step branch below.
zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01));
} else if (isMouseWheel(e)) {
// notched mouse wheel — fixed-ratio step per click
zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18));
} else {
// trackpad two-finger scroll — pan
tf.current.x -= e.deltaX;
tf.current.y -= e.deltaY;
apply();
}
};
// Safari sends native gesture* events for trackpad pinch with a smooth
// e.scale; preferring these over the ctrl+wheel fallback gives a much
// better feel there. No-ops on other browsers. Safari also fires
// ctrlKey wheel events during the same pinch — isGesturing makes
// onWheel drop those entirely so they neither zoom nor pan.
let gsBase = 1;
let isGesturing = false;
const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; };
const onGestureChange = (e) => {
e.preventDefault();
zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale);
};
const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; };
// Drag-pan: middle button anywhere, or primary button on canvas
// background (anything that isn't an artboard or an inline editor).
let drag = null;
const onPointerDown = (e) => {
const onBg = !e.target.closest('[data-dc-slot], .dc-editable');
if (!(e.button === 1 || (e.button === 0 && onBg))) return;
e.preventDefault();
vp.setPointerCapture(e.pointerId);
drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY };
vp.style.cursor = 'grabbing';
};
const onPointerMove = (e) => {
if (!drag || e.pointerId !== drag.id) return;
tf.current.x += e.clientX - drag.lx;
tf.current.y += e.clientY - drag.ly;
drag.lx = e.clientX; drag.ly = e.clientY;
apply();
};
const onPointerUp = (e) => {
if (!drag || e.pointerId !== drag.id) return;
vp.releasePointerCapture(e.pointerId);
drag = null;
vp.style.cursor = '';
};
// Host-driven zoom (toolbar % menu). Zooms around viewport centre so the
// visible midpoint stays fixed — matching the host's iframe-zoom feel.
const onHostMsg = (e) => {
const d = e.data;
if (d && d.type === '__dc_set_zoom' && typeof d.scale === 'number') {
const r = vp.getBoundingClientRect();
zoomAt(r.left + r.width / 2, r.top + r.height / 2, d.scale / tf.current.scale);
} else if (d && d.type === '__dc_probe') {
// Host's [readyGen] reset asks whether a canvas is present; it
// fires on the iframe's native 'load', which for canvases with
// images/fonts is after our mount-time announce, so re-announce.
// Clear the pan-tick guard so apply() re-posts the current scale
// even if it's unchanged — the host just reset dcScale to 1.
window.parent.postMessage({ type: '__dc_present' }, '*');
lastPostedScale.current = undefined;
apply();
}
};
window.addEventListener('message', onHostMsg);
// Announce canvas mode so the host toolbar proxies its % control here
// instead of scaling the iframe element (which would just shrink the
// viewport window of an infinite canvas). The apply() that follows emits
// the initial __dc_zoom so the toolbar % is correct before first pinch.
// lastPostedScale reset mirrors the __dc_probe handler: the layout
// effect's restore-path apply() may already have posted the restored
// scale (before __dc_present), so clear the guard to re-post it in order.
window.parent.postMessage({ type: '__dc_present' }, '*');
lastPostedScale.current = undefined;
apply();
vp.addEventListener('wheel', onWheel, { passive: false });
vp.addEventListener('gesturestart', onGestureStart, { passive: false });
vp.addEventListener('gesturechange', onGestureChange, { passive: false });
vp.addEventListener('gestureend', onGestureEnd, { passive: false });
vp.addEventListener('pointerdown', onPointerDown);
vp.addEventListener('pointermove', onPointerMove);
vp.addEventListener('pointerup', onPointerUp);
vp.addEventListener('pointercancel', onPointerUp);
return () => {
window.removeEventListener('message', onHostMsg);
vp.removeEventListener('wheel', onWheel);
vp.removeEventListener('gesturestart', onGestureStart);
vp.removeEventListener('gesturechange', onGestureChange);
vp.removeEventListener('gestureend', onGestureEnd);
vp.removeEventListener('pointerdown', onPointerDown);
vp.removeEventListener('pointermove', onPointerMove);
vp.removeEventListener('pointerup', onPointerUp);
vp.removeEventListener('pointercancel', onPointerUp);
};
}, [apply, minScale, maxScale]);
const gridSvg = `url("data:image/svg+xml,%3Csvg width='120' height='120' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M120 0H0v120' fill='none' stroke='${encodeURIComponent(DC.grid)}' stroke-width='1'/%3E%3C/svg%3E")`;
return (
<div
ref={vpRef}
className="design-canvas"
style={{
height: '100vh', width: '100vw',
background: DC.bg,
overflow: 'hidden',
overscrollBehavior: 'none',
touchAction: 'none',
position: 'relative',
fontFamily: DC.font,
boxSizing: 'border-box',
...style,
}}
>
<div
ref={worldRef}
style={{
position: 'absolute', top: 0, left: 0,
transformOrigin: '0 0',
willChange: 'transform',
width: 'max-content', minWidth: '100%',
minHeight: '100%',
padding: '60px 0 80px',
}}
>
<div style={{ position: 'absolute', inset: -6000, backgroundImage: gridSvg, backgroundSize: '120px 120px', pointerEvents: 'none', zIndex: -1 }} />
{children}
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// DCSection — editable title + h-row of artboards in persisted order
// ─────────────────────────────────────────────────────────────
function DCSection({ id, title, subtitle, children, gap = 48 }) {
const ctx = React.useContext(DCCtx);
const sid = id ?? title;
const all = React.Children.toArray(dcFlatten(children));
const artboards = all.filter((c) => c && c.type === DCArtboard);
const rest = all.filter((c) => !(c && c.type === DCArtboard));
const sec = (ctx && sid && ctx.section(sid)) || {};
// Must match DesignCanvas's srcKey computation exactly (it filters falsy
// IDs), or onDelete persists a srcKey that DesignCanvas never recognizes.
const allIds = artboards.map((a) => a.props.id ?? a.props.label).filter(Boolean);
const srcKey = allIds.join('\x1f');
const hidden = sec.srcKey === srcKey ? (sec.hidden || []) : [];
const srcOrder = allIds.filter((k) => !hidden.includes(k));
const order = React.useMemo(() => {
const kept = (sec.order || []).filter((k) => srcOrder.includes(k));
return [...kept, ...srcOrder.filter((k) => !kept.includes(k))];
}, [sec.order, srcOrder.join('|')]);
const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a]));
// marginBottom counter-scales so the on-screen gap between sections stays
// constant — otherwise at low zoom the (world-space) gap collapses while
// the screen-constant sectionhead below it doesn't, and the title reads as
// belonging to the section above. paddingBottom below is just enough for
// the 24px artboard-header (abs-positioned above each card) plus ~8px, so
// the title sits tight against its own row at every zoom.
return (
<div data-dc-section={sid}
style={{ marginBottom: 'calc(80px * var(--dc-inv-zoom, 1))', position: 'relative' }}>
<div style={{ padding: '0 60px' }}>
<div className="dc-sectionhead" style={{ paddingBottom: 36 }}>
<DCEditable tag="div" value={sec.title ?? title}
onChange={(v) => ctx && sid && ctx.patchSection(sid, { title: v })}
style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} />
{subtitle && <div style={{ fontSize: 16, color: DC.subtitle }}>{subtitle}</div>}
</div>
</div>
<div style={{ display: 'flex', gap, padding: '0 60px', alignItems: 'flex-start', width: 'max-content' }}>
{order.map((k) => (
<DCArtboardFrame key={k} sectionId={sid} artboard={byId[k]} order={order}
label={(sec.labels || {})[k] ?? byId[k].props.label}
onRename={(v) => ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))}
onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })}
onDelete={() => ctx && ctx.patchSection(sid, (x) => ({
hidden: [...(x.srcKey === srcKey ? (x.hidden || []) : []), k],
srcKey,
}))}
onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} />
))}
</div>
{rest}
</div>
);
}
// DCArtboard — marker; rendered by DCArtboardFrame via DCSection.
function DCArtboard() { return null; }
// Per-artboard export (kind: 'png' | 'html'). Both paths share the same
// self-contained clone: computed styles baked in, @font-face / <img> /
// inline-style background-image urls inlined as data URIs. PNG wraps the
// clone in foreignObject→canvas at 3× the artboard's natural width×height
// (same pipeline the host uses for page captures); HTML wraps it in a
// minimal standalone document. Both are independent of viewport zoom.
async function dcExport(node, w, h, name, kind) {
try { await document.fonts.ready; } catch {}
const toDataURL = (url) => fetch(url).then((r) => r.blob()).then((b) => new Promise((res) => {
const fr = new FileReader(); fr.onload = () => res(fr.result); fr.onerror = () => res(url); fr.readAsDataURL(b);
})).catch(() => url);
// Collect @font-face rules. ss.cssRules throws SecurityError on
// cross-origin sheets (e.g. fonts.googleapis.com) — in that case fetch
// the CSS text directly (those endpoints send ACAO:*) and regex-extract
// the blocks. @import and @media/@supports are walked so nested
// @font-face rules aren't missed.
const fontRules = [], pending = [], seen = new Set();
const scrapeCss = (href) => {
if (seen.has(href)) return; seen.add(href);
pending.push(fetch(href).then((r) => r.text()).then((css) => {
for (const m of css.match(/@font-face\s*{[^}]*}/g) || []) fontRules.push({ css: m, base: href });
for (const m of css.matchAll(/@import\s+(?:url\()?['"]?([^'")\s;]+)/g))
scrapeCss(new URL(m[1], href).href);
}).catch(() => {}));
};
const walk = (rules, base) => {
for (const r of rules) {
if (r.type === CSSRule.FONT_FACE_RULE) fontRules.push({ css: r.cssText, base });
else if (r.type === CSSRule.IMPORT_RULE && r.styleSheet) {
const ibase = r.styleSheet.href || base;
try { walk(r.styleSheet.cssRules, ibase); } catch { scrapeCss(ibase); }
} else if (r.cssRules) walk(r.cssRules, base);
}
};
for (const ss of document.styleSheets) {
const base = ss.href || location.href;
try { walk(ss.cssRules, base); } catch { if (ss.href) scrapeCss(ss.href); }
}
while (pending.length) await pending.shift();
const fontCss = (await Promise.all(fontRules.map(async (rule) => {
let out = rule.css, m; const re = /url\((['"]?)([^'")]+)\1\)/g;
while ((m = re.exec(rule.css))) {
if (m[2].indexOf('data:') === 0) continue;
let abs; try { abs = new URL(m[2], rule.base).href; } catch { continue; }
out = out.split(m[0]).join('url("' + await toDataURL(abs) + '")');
}
return out;
}))).join('\n');
const cloneStyled = (src) => {
if (src.nodeType === 8 || (src.nodeType === 1 && src.tagName === 'SCRIPT')) return document.createTextNode('');
const dst = src.cloneNode(false);
if (src.nodeType === 1) {
const cs = getComputedStyle(src); let txt = '';
for (let i = 0; i < cs.length; i++) txt += cs[i] + ':' + cs.getPropertyValue(cs[i]) + ';';
dst.setAttribute('style', txt + 'animation:none;transition:none;');
if (src.tagName === 'CANVAS') try { const im = document.createElement('img'); im.src = src.toDataURL(); im.setAttribute('style', txt); return im; } catch {}
}
for (let c = src.firstChild; c; c = c.nextSibling) dst.appendChild(cloneStyled(c));
return dst;
};
const clone = cloneStyled(node);
clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
// Drop the card's own shadow/radius so the export is a flush w×h rect;
// the artboard's own background (if any) is already in the computed style.
clone.style.boxShadow = 'none'; clone.style.borderRadius = '0';
const jobs = [];
clone.querySelectorAll('img').forEach((el) => {
const s = el.getAttribute('src');
if (s && s.indexOf('data:') !== 0) jobs.push(toDataURL(el.src).then((d) => el.setAttribute('src', d)));
});
[clone, ...clone.querySelectorAll('*')].forEach((el) => {
const bg = el.style.backgroundImage; if (!bg) return;
let m; const re = /url\(["']?([^"')]+)["']?\)/g;
while ((m = re.exec(bg))) {
const tok = m[0], url = m[1];
if (url.indexOf('data:') === 0) continue;
jobs.push(toDataURL(url).then((d) => { el.style.backgroundImage = el.style.backgroundImage.split(tok).join('url("' + d + '")'); }));
}
});
await Promise.all(jobs);
const xml = new XMLSerializer().serializeToString(clone);
const save = (blob, ext) => {
if (!blob) return;
const a = document.createElement('a');
a.href = URL.createObjectURL(blob); a.download = name + '.' + ext; a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
};
if (kind === 'html') {
const html = '<!doctype html><html><head><meta charset="utf-8"><title>' + name + '</title>' +
(fontCss ? '<style>' + fontCss + '</style>' : '') +
'</head><body style="margin:0">' + xml + '</body></html>';
return save(new Blob([html], { type: 'text/html' }), 'html');
}
// PNG: the SVG's own width/height must be the output resolution — an
// <img>-loaded SVG rasterizes at its intrinsic size, so sizing it at 1×
// and ctx.scale()-ing up would just upscale a 1× bitmap. viewBox maps the
// w×h foreignObject onto the px·w × px·h SVG canvas so the browser renders
// the HTML at full resolution.
const px = 3;
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + w * px + '" height="' + h * px +
'" viewBox="0 0 ' + w + ' ' + h + '"><foreignObject width="' + w + '" height="' + h + '">' +
(fontCss ? '<style><![CDATA[' + fontCss + ']]></style>' : '') + xml + '</foreignObject></svg>';
const img = new Image();
await new Promise((res, rej) => {
img.onload = res; img.onerror = () => rej(new Error('svg load failed'));
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
});
const cv = document.createElement('canvas');
cv.width = w * px; cv.height = h * px;
cv.getContext('2d').drawImage(img, 0, 0);
cv.toBlob((blob) => save(blob, 'png'), 'image/png');
}
function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus, onDelete }) {
const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props;
const id = rawId ?? rawLabel;
const ref = React.useRef(null);
const cardRef = React.useRef(null);
const menuRef = React.useRef(null);
const [menuOpen, setMenuOpen] = React.useState(false);
const [confirming, setConfirming] = React.useState(false);
// ⋯ menu: close on any outside pointerdown. Two-click delete lives inside
// the menu — first click arms the row, second commits; closing disarms.
React.useEffect(() => {
if (!menuOpen) { setConfirming(false); return; }
const off = (e) => { if (!menuRef.current || !menuRef.current.contains(e.target)) setMenuOpen(false); };
document.addEventListener('pointerdown', off, true);
return () => document.removeEventListener('pointerdown', off, true);
}, [menuOpen]);
const doExport = (kind) => {
setMenuOpen(false);
if (!cardRef.current) return;
const name = String(label || id || 'artboard').replace(/[^\w\s.-]+/g, '_');
dcExport(cardRef.current, width, height, name, kind)
.catch((e) => console.error('[design-canvas] export failed:', e));
};
// Live drag-reorder: dragged card sticks to cursor; siblings slide into
// their would-be slots in real time via transforms. DOM order only
// changes on drop.
const onGripDown = (e) => {
e.preventDefault(); e.stopPropagation();
const me = ref.current;
// translateX is applied in local (pre-scale) space but pointer deltas and
// getBoundingClientRect().left are screen-space — divide by the viewport's
// current scale so the dragged card tracks the cursor at any zoom level.
const scale = me.getBoundingClientRect().width / me.offsetWidth || 1;
const peers = Array.from(document.querySelectorAll(`[data-dc-section="${sectionId}"] [data-dc-slot]`));
const homes = peers.map((el) => ({ el, id: el.dataset.dcSlot, x: el.getBoundingClientRect().left }));
const slotXs = homes.map((h) => h.x);
const startIdx = order.indexOf(id);
const startX = e.clientX;
let liveOrder = order.slice();
me.classList.add('dc-dragging');
const layout = () => {
for (const h of homes) {
if (h.id === id) continue;
const slot = liveOrder.indexOf(h.id);
h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`;
}
};
const move = (ev) => {
const dx = ev.clientX - startX;
me.style.transform = `translateX(${dx / scale}px)`;
const cur = homes[startIdx].x + dx;
let nearest = 0, best = Infinity;
for (let i = 0; i < slotXs.length; i++) {
const d = Math.abs(slotXs[i] - cur);
if (d < best) { best = d; nearest = i; }
}
if (liveOrder.indexOf(id) !== nearest) {
liveOrder = order.filter((k) => k !== id);
liveOrder.splice(nearest, 0, id);
layout();
}
};
const up = () => {
document.removeEventListener('pointermove', move);
document.removeEventListener('pointerup', up);
const finalSlot = liveOrder.indexOf(id);
me.classList.remove('dc-dragging');
me.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`;
// After the settle transition, kill transitions + clear transforms +
// commit the reorder in the same frame so there's no visual snap-back.
setTimeout(() => {
for (const h of homes) { h.el.style.transition = 'none'; h.el.style.transform = ''; }
if (liveOrder.join('|') !== order.join('|')) onReorder(liveOrder);
requestAnimationFrame(() => requestAnimationFrame(() => {
for (const h of homes) h.el.style.transition = '';
}));
}, 180);
};
document.addEventListener('pointermove', move);
document.addEventListener('pointerup', up);
};
return (
<div ref={ref} data-dc-slot={id} style={{ position: 'relative', flexShrink: 0 }}>
<div className="dc-header" data-noncommentable="" style={{ color: DC.label }} onPointerDown={(e) => e.stopPropagation()}>
<div className="dc-labelrow">
<div className="dc-grip" onPointerDown={onGripDown} title="Drag to reorder">
<svg width="9" height="13" viewBox="0 0 9 13" fill="currentColor"><circle cx="2" cy="2" r="1.1"/><circle cx="7" cy="2" r="1.1"/><circle cx="2" cy="6.5" r="1.1"/><circle cx="7" cy="6.5" r="1.1"/><circle cx="2" cy="11" r="1.1"/><circle cx="7" cy="11" r="1.1"/></svg>
</div>
<div className="dc-labeltext" onClick={onFocus} title="Click to focus">
<DCEditable value={label} onChange={onRename} onClick={(e) => e.stopPropagation()}
style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} />
</div>
</div>
<div className="dc-btns">
<div ref={menuRef} style={{ position: 'relative' }}>
<button className="dc-kebab" title="More" onClick={() => setMenuOpen((o) => !o)}>
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor"><circle cx="2.5" cy="6" r="1.1"/><circle cx="6" cy="6" r="1.1"/><circle cx="9.5" cy="6" r="1.1"/></svg>
</button>
{menuOpen && (
<div className="dc-menu" onPointerDown={(e) => e.stopPropagation()}>
<button onClick={() => doExport('png')}>Download PNG</button>
<button onClick={() => doExport('html')}>Download HTML</button>
<hr />
<button className="dc-danger"
onClick={() => { if (confirming) { setMenuOpen(false); onDelete(); } else setConfirming(true); }}>
{confirming ? 'Click again to delete' : 'Delete'}
</button>
</div>
)}
</div>
<button className="dc-expand" onClick={onFocus} title="Focus">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><path d="M7 1h4v4M5 11H1V7M11 1L7.5 4.5M1 11l3.5-3.5"/></svg>
</button>
</div>
</div>
<div ref={cardRef} className="dc-card"
style={{ borderRadius: 2, boxShadow: '0 1px 3px rgba(0,0,0,.08),0 4px 16px rgba(0,0,0,.06)', overflow: 'hidden', width, height, background: '#fff', ...style }}>
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb', fontSize: 13, fontFamily: DC.font }}>{id}</div>}
</div>
</div>
);
}
// Inline rename — commits on blur or Enter.
function DCEditable({ value, onChange, style, tag = 'span', onClick }) {
const T = tag;
return (
<T className="dc-editable" contentEditable suppressContentEditableWarning
onClick={onClick}
onPointerDown={(e) => e.stopPropagation()}
onBlur={(e) => onChange && onChange(e.currentTarget.textContent)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
style={style}>{value}</T>
);
}
// ─────────────────────────────────────────────────────────────
// Focus mode — overlay one artboard; ←/→ within section, ↑/↓ across
// sections, Esc or backdrop click to exit.
// ─────────────────────────────────────────────────────────────
function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) {
const ctx = React.useContext(DCCtx);
const { sectionId, artboard } = entry;
const sec = ctx.section(sectionId);
const meta = sectionMeta[sectionId];
const peers = meta.slotIds;
const aid = artboard.props.id ?? artboard.props.label;
const idx = peers.indexOf(aid);
const secIdx = sectionOrder.indexOf(sectionId);
const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); };
const goSection = (d) => {
// Sections whose artboards are all deleted have slotIds:[] — step past
// them to the next non-empty section so ↑/↓ doesn't dead-end.
const n = sectionOrder.length;
for (let i = 1; i < n; i++) {
const ns = sectionOrder[(((secIdx + d * i) % n) + n) % n];
const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0];
if (first) { ctx.setFocus(`${ns}/${first}`); return; }
}
};
React.useEffect(() => {
const k = (e) => {
if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); }
if (e.key === 'ArrowRight') { e.preventDefault(); go(1); }
if (e.key === 'ArrowUp') { e.preventDefault(); goSection(-1); }
if (e.key === 'ArrowDown') { e.preventDefault(); goSection(1); }
};
document.addEventListener('keydown', k);
return () => document.removeEventListener('keydown', k);
});
const { width = 260, height = 480, children } = artboard.props;
const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight });
React.useEffect(() => { const r = () => setVp({ w: window.innerWidth, h: window.innerHeight }); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); }, []);
const scale = Math.max(0.1, Math.min((vp.w - 200) / width, (vp.h - 260) / height, 2));
const [ddOpen, setDd] = React.useState(false);
const Arrow = ({ dir, onClick }) => (
<button onClick={(e) => { e.stopPropagation(); onClick(); }}
style={{ position: 'absolute', top: '50%', [dir]: 28, transform: 'translateY(-50%)',
border: 'none', background: 'rgba(255,255,255,.08)', color: 'rgba(255,255,255,.9)',
width: 44, height: 44, borderRadius: 22, fontSize: 18, cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'background .15s' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.18)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.08)')}>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d={dir === 'left' ? 'M11 3L5 9l6 6' : 'M7 3l6 6-6 6'} /></svg>
</button>
);
// Portal to body so position:fixed is the real viewport regardless of any
// transform on DesignCanvas's ancestors (including the canvas zoom itself).
return ReactDOM.createPortal(
<div onClick={() => ctx.setFocus(null)}
onWheel={(e) => e.preventDefault()}
style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(24,20,16,.6)', backdropFilter: 'blur(14px)',
fontFamily: DC.font, color: '#fff' }}>
{/* top bar: section dropdown (left) · close (right) */}
<div onClick={(e) => e.stopPropagation()}
style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}>
<div style={{ position: 'relative' }}>
<button onClick={() => setDd((o) => !o)}
style={{ border: 'none', background: 'transparent', color: '#fff', cursor: 'pointer', padding: '6px 8px',
borderRadius: 6, textAlign: 'left', fontFamily: 'inherit' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 18, fontWeight: 600, letterSpacing: -0.3 }}>{meta.title}</span>
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" style={{ opacity: .7 }}><path d="M2 4l3.5 3.5L9 4"/></svg>
</span>
{meta.subtitle && <span style={{ display: 'block', fontSize: 13, opacity: .6, fontWeight: 400, marginTop: 2 }}>{meta.subtitle}</span>}
</button>
{ddOpen && (
<div style={{ position: 'absolute', top: '100%', left: 0, marginTop: 4, background: '#2a251f', borderRadius: 8,
boxShadow: '0 8px 32px rgba(0,0,0,.4)', padding: 4, minWidth: 200, zIndex: 10 }}>
{sectionOrder.filter((sid) => sectionMeta[sid].slotIds.length).map((sid) => (
<button key={sid} onClick={() => { setDd(false); const f = sectionMeta[sid].slotIds[0]; if (f) ctx.setFocus(`${sid}/${f}`); }}
style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none', cursor: 'pointer',
background: sid === sectionId ? 'rgba(255,255,255,.1)' : 'transparent', color: '#fff',
padding: '8px 12px', borderRadius: 5, fontSize: 14, fontWeight: sid === sectionId ? 600 : 400, fontFamily: 'inherit' }}>
{sectionMeta[sid].title}
</button>
))}
</div>
)}
</div>
<div style={{ flex: 1 }} />
<button onClick={() => ctx.setFocus(null)}
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.12)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
style={{ border: 'none', background: 'transparent', color: 'rgba(255,255,255,.7)', width: 32, height: 32,
borderRadius: 16, fontSize: 20, cursor: 'pointer', lineHeight: 1, transition: 'background .12s' }}>×</button>
</div>
{/* card centered, label + index below — only the card itself stops
propagation so any backdrop click (including the margins around
the card) exits focus */}
<div
style={{ position: 'absolute', top: 64, bottom: 56, left: 100, right: 100, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16 }}>
<div onClick={(e) => e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}>
<div style={{ width, height, transform: `scale(${scale})`, transformOrigin: 'top left', background: '#fff', borderRadius: 2, overflow: 'hidden',
boxShadow: '0 20px 80px rgba(0,0,0,.4)' }}>
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb' }}>{aid}</div>}
</div>
</div>
<div onClick={(e) => e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}>
{(sec.labels || {})[aid] ?? artboard.props.label}
<span style={{ opacity: .5, marginLeft: 10, fontVariantNumeric: 'tabular-nums' }}>{idx + 1} / {peers.length}</span>
</div>
</div>
<Arrow dir="left" onClick={() => go(-1)} />
<Arrow dir="right" onClick={() => go(1)} />
{/* dots */}
<div onClick={(e) => e.stopPropagation()}
style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}>
{peers.map((p, i) => (
<button key={p} onClick={() => ctx.setFocus(`${sectionId}/${p}`)}
style={{ border: 'none', padding: 0, cursor: 'pointer', width: 6, height: 6, borderRadius: 3,
background: i === idx ? '#fff' : 'rgba(255,255,255,.3)' }} />
))}
</div>
</div>,
document.body,
);
}
// ─────────────────────────────────────────────────────────────
// Post-it — absolute-positioned sticky note
// ─────────────────────────────────────────────────────────────
function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) {
return (
<div style={{
position: 'absolute', top, left, right, bottom, width,
background: DC.postitBg, padding: '14px 16px',
fontFamily: '"Comic Sans MS", "Marker Felt", "Segoe Print", cursive',
fontSize: 14, lineHeight: 1.4, color: DC.postitText,
boxShadow: '0 2px 8px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08)',
transform: `rotate(${rotate}deg)`,
zIndex: 5,
}}>{children}</div>
);
}
Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt });
@@ -15,6 +15,8 @@ function applyFilterAndSort(games, filter, sort, query) {
} else if (sort === 'recent') {
const order = { installed: 0, local: 1, none: 2 };
g = [...g].sort((a, b) => order[a.state] - order[b.state] || b.version.localeCompare(a.version));
} else if (sort === 'peers') {
g = [...g].sort((a, b) => installedPeersFor(b).length - installedPeersFor(a).length || a.title.localeCompare(b.title));
}
return g;
}
@@ -25,6 +27,8 @@ function Launcher({
initialFilter = 'all', initialSort = 'recent', initialQuery = '',
initialOpenGame = null,
initialSettingsOpen = false,
seedCallToPlay = [],
initialCtpOpen = false,
}) {
const { density, aspect, accent, bg } = tweaks;
const [filter, setFilter] = useState(initialFilter);
@@ -32,6 +36,11 @@ function Launcher({
const [query, setQuery] = useState(initialQuery);
const [openGame, setOpenGame] = useState(initialOpenGame);
const [settingsOpen, setSettingsOpen] = useState(initialSettingsOpen);
const [ctpOpen, setCtpOpen] = useState(initialCtpOpen);
const [ctpFocusId, setCtpFocusId] = useState(null);
const nomUsername = tweaks.username && tweaks.username.trim() ? tweaks.username.trim() : 'you';
const ctp = useNominations({ username: nomUsername, seed: seedCallToPlay });
const counts = useMemo(() => countByFilter(GAMES), []);
const list = useMemo(() => applyFilterAndSort(GAMES, filter, sort, query), [filter, sort, query]);
@@ -66,6 +75,7 @@ function Launcher({
<SortMenu value={sort} onChange={setSort} accent={accent}/>
</div>
<div className="topbar-right-trail">
<CallToPlayButton nominations={ctp.nominations} onClick={() => setCtpOpen(true)}/>
<KebabMenu items={menuItems}/>
</div>
</div>
@@ -79,6 +89,7 @@ function Launcher({
</div>
<div className="topbar-row1-right">
<StorageMeter accent={accent}/>
<CallToPlayButton nominations={ctp.nominations} onClick={() => setCtpOpen(true)}/>
<KebabMenu items={menuItems}/>
</div>
</div>
@@ -93,6 +104,8 @@ function Launcher({
)}
<main className="grid-wrap">
<CallToPlayTicker nominations={ctp.nominations} accent={accent}
onOpen={(id) => { setCtpFocusId(id); setCtpOpen(true); }}/>
{variant === 'single' && (
<div className="results-bar">
<div className="results-count">
@@ -113,6 +126,12 @@ function Launcher({
{settingsOpen && setTweak && (
<SettingsDialog settings={tweaks} onChange={setTweak} onClose={() => setSettingsOpen(false)}/>
)}
{ctpOpen && (
<CallToPlayOverlay nominations={ctp.nominations} username={nomUsername} accent={accent}
focusId={ctpFocusId}
onClose={() => { setCtpOpen(false); setCtpFocusId(null); }}
actions={{ createNomination: ctp.createNomination, respond: ctp.respond, rsvp: ctp.rsvp, sendMessage: ctp.sendMessage, leave: ctp.leave, cancel: ctp.cancel, startNow: ctp.startNow, addTime: ctp.addTime }}/>
)}
</div>
);
}
@@ -636,8 +636,14 @@
font-size: 11.5px;
color: var(--t-3);
font-variant-numeric: tabular-nums;
min-width: 0;
}
.card-meta .card-dot { opacity: 0.5; }
.card-meta .card-dot { opacity: 0.5; flex-shrink: 0; }
.card-peers {
display: inline-flex; align-items: center; gap: 3px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.card-peers svg { flex-shrink: 0; opacity: 0.75; }
.density-compact .card-body { padding: 9px 10px 10px; gap: 6px; }
.density-compact .card-title { font-size: 12.5px; }
@@ -1311,3 +1317,624 @@
.folder-field.is-unset .folder-field-btn:hover {
background: var(--accent, #3b82f6);
}
/*
Call to Play rally a game + a time
*/
/* ─── Top-bar entry button ─── */
.ctp-btn {
position: relative;
display: inline-flex; align-items: center; gap: 8px;
height: 36px; padding: 0 14px;
background: color-mix(in srgb, var(--accent) 14%, var(--bg-2));
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--bd-2));
border-radius: 8px;
color: var(--t-1);
font: inherit; font-size: 12.5px; font-weight: 700;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
transition: background .15s, border-color .15s;
}
.ctp-btn svg { color: var(--accent); flex-shrink: 0; }
.ctp-btn:hover {
background: color-mix(in srgb, var(--accent) 22%, var(--bg-2));
border-color: color-mix(in srgb, var(--accent) 65%, var(--bd-2));
}
.ctp-badge {
display: inline-grid; place-items: center;
min-width: 18px; height: 18px;
padding: 0 5px;
border-radius: 999px;
background: var(--accent);
color: white;
font-size: 10.5px; font-weight: 800;
font-variant-numeric: tabular-nums;
}
/* ─── Ticker strip ─── */
.ctp-ticker-stack {
display: flex; flex-direction: column; gap: 8px;
margin-bottom: 14px;
}
.ctp-ticker-stack .ctp-ticker { margin-bottom: 0; }
.ctp-ticker {
display: grid;
grid-template-columns:
10px /* LED */
98px /* status */
220px /* game */
130px /* by */
88px /* count */
170px /* time */
minmax(0, 1fr) /* chat — sole flexible track, absorbs bubbles variance */
max-content /* bubbles */
82px; /* cta */
align-items: center;
column-gap: 12px;
width: 100%;
padding: 10px 16px;
background: color-mix(in srgb, var(--accent) 10%, var(--bg-2));
border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--bd-2));
border-radius: 10px;
color: var(--t-1);
font: inherit; font-size: 12.5px;
cursor: pointer;
text-align: left;
transition: background .15s, border-color .15s;
}
.ctp-ticker:hover { background: color-mix(in srgb, var(--accent) 16%, var(--bg-2)); }
.ctp-ticker-dot {
width: 8px; height: 8px; border-radius: 999px; flex-shrink: 0;
background: var(--accent);
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 60%, transparent);
animation: ctp-tickerpulse 1.6s ease-out infinite;
}
@keyframes ctp-tickerpulse {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent); }
70% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent) 0%, transparent); }
100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); }
}
.ctp-ticker-label {
font-weight: 700;
color: var(--accent);
text-transform: uppercase;
font-size: 10.5px;
letter-spacing: 0.06em;
flex-shrink: 0;
}
.ctp-ticker-game { font-weight: 700; color: var(--t-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ctp-ticker-by { color: var(--t-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ctp-ticker-sep { display: none; }
.ctp-ticker-ready, .ctp-ticker-time { color: var(--t-2); font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ctp-ticker-more {
color: var(--t-3);
font-size: 11.5px;
flex-shrink: 0;
}
.ctp-ticker-cta {
display: inline-flex; align-items: center; justify-content: flex-end; gap: 4px;
font-weight: 700;
color: var(--accent);
}
.ctp-ticker-cta svg { transform: rotate(-90deg); }
/* ─── Overlay modal ─── */
.ctp-modal { width: min(720px, 100%); }
.ctp-head-row {
display: flex; align-items: center; justify-content: space-between;
gap: 12px;
margin-right: 36px;
}
.ctp-head-new { height: 40px; padding: 0 18px; font-size: 13px; margin-top: 14px; width: 100%; }
.ctp-head { padding: 26px 28px 14px; border-bottom: 1px solid var(--bd-1); }
.ctp-head h2 { margin: 0; font-size: 20px; font-weight: 700; letter-spacing: -0.01em; color: var(--t-1); }
.ctp-head-sub { margin: 6px 0 0; font-size: 12.5px; color: var(--t-3); max-width: 52ch; }
.ctp-body {
padding: 18px 24px 24px;
display: flex; flex-direction: column; gap: 14px;
max-height: 66vh;
overflow: auto;
}
.ctp-empty {
padding: 28px 8px;
text-align: center;
color: var(--t-3);
font-size: 13px;
}
/* ─── Nomination card ─── */
.ctp-card {
display: flex; flex-direction: column; gap: 12px;
padding: 14px;
background: rgba(255,255,255,0.025);
border: 1px solid var(--bd-1);
border-radius: 12px;
}
.ctp-card.is-done { border-color: color-mix(in srgb, var(--ok) 45%, var(--bd-2)); }
.ctp-card.is-started { opacity: 0.6; }
.ctp-card.is-focused { border-color: var(--accent); animation: ctp-cardflash 1.4s ease-out 1; }
@keyframes ctp-cardflash {
0% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 55%, transparent); }
100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); }
}
.ctp-card-top { display: flex; align-items: flex-start; gap: 12px; position: relative; }
.ctp-card-cover {
position: relative;
width: 52px; height: 52px;
flex-shrink: 0;
border-radius: 8px;
overflow: hidden;
}
.ctp-card-info { flex: 1; min-width: 0; }
.ctp-card-title { font-size: 14.5px; font-weight: 700; color: var(--t-1); }
.ctp-card-sub { margin-top: 3px; font-size: 11.5px; color: var(--t-3); }
.ctp-card-sub strong { color: var(--t-2); font-weight: 700; }
.ctp-card-timer {
font-variant-numeric: tabular-nums;
font-size: 18px;
font-weight: 700;
color: var(--t-1);
flex-shrink: 0;
padding-top: 2px;
}
.ctp-card-timer[data-urgency="mid"] { color: var(--warn); }
.ctp-card-timer[data-urgency="high"] { color: var(--danger); }
.ctp-card.is-done .ctp-card-timer { color: var(--ok); font-size: 14px; text-transform: uppercase; letter-spacing: 0.04em; }
.ctp-cancel-link {
display: inline-flex; align-items: center; gap: 6px;
align-self: flex-start;
margin-top: -2px;
padding: 6px 2px;
background: transparent;
border: 0;
color: var(--t-3);
font: inherit; font-size: 11.5px; font-weight: 600;
cursor: pointer;
transition: color .15s;
}
.ctp-cancel-link:hover { color: #fca5a5; }
.ctp-cancel-confirm {
display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap;
gap: 10px;
padding: 10px 12px;
background: rgba(239,68,68,0.08);
border: 1px solid rgba(239,68,68,0.35);
border-radius: 8px;
font-size: 12.5px;
color: #fca5a5;
font-weight: 600;
}
.ctp-cancel-confirm-btns { display: inline-flex; gap: 8px; flex-shrink: 0; }
.ctp-cancel-confirm-btns .ghost-btn { height: 32px; padding: 0 12px; font-size: 12px; }
.ctp-progress {
height: 5px;
border-radius: 3px;
background: rgba(255,255,255,0.06);
overflow: hidden;
}
.ctp-progress-fill { height: 100%; transition: width 1s linear; }
.ctp-roster { display: flex; flex-direction: column; gap: 8px; }
.ctp-roster-count { font-size: 11px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--t-3); }
.ctp-avatars { display: flex; flex-wrap: wrap; gap: 6px; }
.ctp-avatar {
position: relative;
display: inline-flex;
}
.ctp-avatar-dot {
width: 30px; height: 30px;
display: grid; place-items: center;
border-radius: 999px;
color: white;
font-size: 10.5px; font-weight: 800;
letter-spacing: 0.01em;
}
.ctp-avatar.is-pending .ctp-avatar-dot { opacity: 0.55; }
.ctp-avatar-check {
position: absolute; bottom: -2px; right: -2px;
width: 14px; height: 14px;
display: grid; place-items: center;
border-radius: 999px;
background: var(--ok);
color: #06240f;
border: 2px solid var(--bg-2);
}
.ctp-avatar-pending {
position: absolute; bottom: -6px; left: 50%;
transform: translateX(-50%);
font-size: 9px; font-weight: 700;
color: var(--t-2);
background: var(--bg-3);
border: 1px solid var(--bd-2);
padding: 0 4px;
border-radius: 999px;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.ctp-avatar-empty {
width: 30px; height: 30px;
border-radius: 999px;
border: 1.5px dashed var(--bd-2);
}
.ctp-actions {
display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
padding-top: 2px;
}
.ctp-actions .act-btn { height: 36px; padding: 0 16px; font-size: 12.5px; }
.ctp-actions .ghost-btn { height: 36px; padding: 0 14px; font-size: 12.5px; }
.ctp-note { font-size: 12.5px; color: var(--t-3); }
.ctp-note-launch { color: var(--ok); font-weight: 600; }
.ctp-me-status { font-size: 12.5px; font-weight: 600; color: var(--t-2); }
.ctp-buffer-group { display: inline-flex; gap: 6px; }
.ctp-buffer-btn {
height: 36px; padding: 0 11px;
background: rgba(255,255,255,0.04);
border: 1px solid var(--bd-2);
border-radius: 7px;
color: var(--t-1);
font: inherit; font-size: 12px; font-weight: 600;
cursor: pointer;
transition: background .15s, border-color .15s;
}
.ctp-buffer-btn:hover { background: rgba(255,255,255,0.08); border-color: var(--bd-3); }
/* ─── Create-nomination form ─── */
.ctp-create-cta {
display: flex; align-items: center; justify-content: center; gap: 8px;
height: 48px;
background: transparent;
border: 1.5px dashed var(--bd-3);
border-radius: 12px;
color: var(--t-2);
font: inherit; font-size: 13px; font-weight: 700;
cursor: pointer;
transition: border-color .15s, color .15s, background .15s;
}
.ctp-create-cta:hover { border-color: var(--accent); color: var(--t-1); background: rgba(255,255,255,0.02); }
.ctp-create {
display: flex; flex-direction: column; gap: 14px;
padding: 16px;
background: rgba(255,255,255,0.03);
border: 1px solid var(--bd-2);
border-radius: 12px;
}
.ctp-create-row { display: flex; flex-direction: column; gap: 6px; position: relative; }
.ctp-create-row-inline { flex-direction: row; align-items: center; gap: 10px; flex-wrap: wrap; }
.ctp-create-label {
font-size: 10.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase;
color: var(--t-3);
}
.ctp-create-search { width: 100%; }
.ctp-create-matches {
position: absolute;
top: calc(100% + 4px);
left: 0; right: 0;
z-index: 20;
max-height: 220px;
overflow: auto;
padding: 4px;
background: var(--bg-3);
border: 1px solid var(--bd-2);
border-radius: 10px;
box-shadow: 0 16px 40px -8px rgba(0,0,0,0.5);
}
.ctp-create-matches button {
display: flex; align-items: center; justify-content: space-between; gap: 10px;
width: 100%;
padding: 9px 10px;
background: transparent;
border: 0;
border-radius: 6px;
color: var(--t-1);
font: inherit; font-size: 12.5px; font-weight: 600;
text-align: left;
cursor: pointer;
}
.ctp-create-matches button:hover { background: rgba(255,255,255,0.06); }
.ctp-create-match-meta { color: var(--t-3); font-size: 11px; font-weight: 500; }
.ctp-create-nomatch { padding: 10px; font-size: 12px; color: var(--t-3); }
.ctp-create-num {
width: 70px; height: 34px;
background: var(--bg-3);
border: 1px solid var(--bd-1);
border-radius: 7px;
color: var(--t-1);
font: inherit; font-size: 13px; font-weight: 600;
text-align: center;
}
.ctp-create-hint { font-size: 11.5px; color: var(--t-3); }
.ctp-duration-opts { display: inline-flex; gap: 6px; flex-wrap: wrap; }
.ctp-duration-btn {
height: 32px; padding: 0 13px;
background: var(--bg-3);
border: 1px solid var(--bd-1);
border-radius: 7px;
color: var(--t-2);
font: inherit; font-size: 12.5px; font-weight: 700;
cursor: pointer;
transition: background .15s, color .15s, border-color .15s;
}
.ctp-duration-btn:hover { color: var(--t-1); }
.ctp-duration-btn.is-active { color: white; }
.ctp-create-foot {
display: flex; justify-content: flex-end; gap: 10px;
padding-top: 4px;
}
.ctp-create-foot .act-btn,
.ctp-create-foot .ghost-btn { height: 40px; padding: 0 18px; }
.ctp-time-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 4px; }
/* ─── Time stepper ─── */
.ctp-timepick {
display: flex; align-items: center; gap: 6px;
margin-top: 6px;
}
.ctp-timepick-field {
display: flex; flex-direction: column; align-items: center; gap: 3px;
}
.ctp-time-step {
width: 54px; height: 24px;
display: grid; place-items: center;
background: var(--bg-3);
border: 1px solid var(--bd-1);
border-radius: 7px;
color: var(--t-2);
cursor: pointer;
transition: background .12s, color .12s, border-color .12s;
}
.ctp-time-step:hover { background: var(--bg-4); color: var(--t-1); border-color: var(--bd-3); }
.ctp-time-step:active { background: color-mix(in srgb, var(--accent) 30%, var(--bg-3)); }
.ctp-time-cell {
width: 54px; height: 44px;
display: grid; place-items: center;
background: var(--bg-3);
border: 1px solid var(--bd-2);
border-radius: 9px;
font-size: 26px; font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--t-1);
letter-spacing: 0.02em;
}
.ctp-time-editinput {
width: 132px; height: 44px;
padding: 0;
background: var(--bg-3);
border: 1px solid color-mix(in srgb, var(--accent) 70%, var(--bd-2));
border-radius: 9px;
font: inherit; font-size: 26px; font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--t-1);
text-align: center;
letter-spacing: 0.06em;
}
.ctp-time-editinput:focus { outline: none; }
.ctp-time-editinput::placeholder { color: var(--t-4); }
.ctp-time-colon { font-size: 26px; font-weight: 700; color: var(--t-2); padding-bottom: 2px; }
.ctp-time-type {
align-self: center;
margin-left: 8px;
height: 30px; padding: 0 12px;
background: transparent;
border: 1px dashed var(--bd-3);
border-radius: 7px;
color: var(--t-3);
font: inherit; font-size: 11.5px; font-weight: 600;
cursor: pointer;
transition: color .12s, border-color .12s;
}
.ctp-time-type:hover { color: var(--t-1); border-color: var(--accent); }
.ctp-day-row { gap: 5px; }
.ctp-day-label {
font-size: 10.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase;
color: var(--t-3);
margin-right: 4px;
}
.ctp-day-btn { height: 26px; padding: 0 10px; font-size: 11px; border-radius: 6px; }
/* Quick-bar status variants LED + label + row tint per status:
SCHEDULED (neutral) · CALL TO PLAY (accent, pulsing) ·
STARTING SOON (amber, glowing) · READY (green, steady) */
.ctp-ticker[data-status="scheduled"] {
background: var(--bg-2);
border-color: var(--bd-2);
}
.ctp-ticker[data-status="scheduled"]:hover { background: var(--bg-3); }
.ctp-ticker[data-status="soon"] {
background: color-mix(in srgb, var(--warn) 10%, var(--bg-2));
border-color: color-mix(in srgb, var(--warn) 50%, var(--bd-2));
animation: ctp-soon-glow 2.4s ease-in-out infinite;
}
.ctp-ticker[data-status="soon"]:hover { background: color-mix(in srgb, var(--warn) 16%, var(--bg-2)); }
@keyframes ctp-soon-glow {
0%, 100% { box-shadow: 0 0 0 0 transparent; }
50% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--warn) 28%, transparent); }
}
.ctp-ticker[data-status="ready"] {
background: color-mix(in srgb, var(--ok) 11%, var(--bg-2));
border-color: color-mix(in srgb, var(--ok) 55%, var(--bd-2));
box-shadow: 0 0 14px -2px color-mix(in srgb, var(--ok) 35%, transparent);
}
.ctp-ticker[data-status="ready"]:hover { background: color-mix(in srgb, var(--ok) 17%, var(--bg-2)); }
.ctp-ticker-dot[data-status="scheduled"] { background: var(--t-3); animation: none; box-shadow: none; }
.ctp-ticker-dot[data-status="soon"] { background: var(--warn); animation: ctp-tickerpulse-warn 1.6s ease-out infinite; }
.ctp-ticker-dot[data-status="ready"] { background: var(--ok); animation: none; box-shadow: 0 0 6px var(--ok); }
@keyframes ctp-tickerpulse-warn {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--warn) 55%, transparent); }
70% { box-shadow: 0 0 0 6px transparent; }
100% { box-shadow: 0 0 0 0 transparent; }
}
.ctp-ticker-label[data-status="scheduled"] { color: var(--t-2); }
.ctp-ticker-label[data-status="soon"] { color: var(--warn); }
.ctp-ticker-label[data-status="ready"] { color: var(--ok); }
.ctp-ticker[data-status="soon"] .ctp-ticker-cta { color: var(--warn); }
.ctp-ticker[data-status="ready"] .ctp-ticker-cta { color: var(--ok); }
/* ─── Quick-bar inline chat preview ─── */
.ctp-ticker-chat {
min-width: 0;
display: inline-flex; align-items: center; gap: 5px;
color: var(--t-3);
font-size: 12px;
}
.ctp-ticker-chat svg { flex-shrink: 0; opacity: 0.7; }
.ctp-ticker-chat b { flex-shrink: 0; font-weight: 700; }
.ctp-ticker-chat-text {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
color: var(--t-2);
}
.ctp-card.is-checkin { border-color: color-mix(in srgb, var(--accent) 70%, var(--bd-2)); }
.ctp-checkin-note {
display: flex; align-items: center; gap: 8px;
padding: 8px 12px;
background: color-mix(in srgb, var(--accent) 13%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 38%, transparent);
border-radius: 8px;
font-size: 12.5px; font-weight: 600;
color: var(--t-1);
}
.ctp-checkin-note svg { color: var(--accent); flex-shrink: 0; }
.ctp-card-timer.is-sched {
display: flex; flex-direction: column; align-items: flex-end; gap: 2px;
}
.ctp-card-clock { font-size: 18px; line-height: 1; }
.ctp-card-until { font-size: 10.5px; font-weight: 600; color: var(--t-3); letter-spacing: 0.01em; }
.ctp-avatar.is-in .ctp-avatar-dot { opacity: 0.8; }
.ctp-avatar-in {
position: absolute; bottom: -6px; left: 50%;
transform: translateX(-50%);
font-size: 8.5px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em;
color: var(--t-2);
background: var(--bg-3);
border: 1px solid var(--bd-2);
padding: 0 4px;
border-radius: 999px;
white-space: nowrap;
}
/* ─── Ticker mini ready-bubbles ─── */
.ctp-ticker-bubbles {
display: inline-flex; align-items: center;
justify-self: end;
}
.ctp-mini {
position: relative;
width: 22px; height: 22px;
display: grid; place-items: center;
border-radius: 999px;
margin-left: -6px;
border: 2px solid var(--bg-2);
color: white;
font-size: 8px; font-weight: 800; letter-spacing: 0.02em;
}
.ctp-mini:first-child { margin-left: 0; }
.ctp-mini[data-state="ready"] { box-shadow: 0 0 0 1.5px var(--ok); }
.ctp-mini[data-state="pending"] { opacity: 0.75; }
.ctp-mini[data-state="in"] { opacity: 0.85; }
.ctp-mini-check {
position: absolute; bottom: -3px; right: -4px;
width: 11px; height: 11px;
display: grid; place-items: center;
border-radius: 999px;
background: var(--ok);
color: #06240f;
border: 1.5px solid var(--bg-2);
}
.ctp-mini-check svg { width: 7px; height: 7px; }
.ctp-mini-tag {
position: absolute; bottom: -6px; right: -7px;
font-style: normal;
font-size: 8px; font-weight: 700;
line-height: 11px;
padding: 0 3px;
border-radius: 999px;
background: var(--bg-3);
border: 1px solid var(--bd-2);
color: var(--t-2);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.ctp-mini-more { background: var(--bg-4); color: var(--t-2); font-size: 8.5px; }
/* ─── Per-call chat ─── */
.ctp-chat {
display: flex; flex-direction: column;
border-top: 1px solid var(--bd-1);
margin-top: 2px;
padding-top: 8px;
}
.ctp-chat-toggle {
display: flex; align-items: center; gap: 8px;
width: 100%;
padding: 4px 2px;
background: transparent;
border: 0;
color: var(--t-3);
font: inherit; font-size: 11.5px; font-weight: 700;
cursor: pointer;
transition: color .15s;
}
.ctp-chat-toggle:hover { color: var(--t-1); }
.ctp-chat-toggle svg { flex-shrink: 0; }
.ctp-chat-count { color: var(--t-4); font-weight: 600; }
.ctp-chat-unread {
min-width: 16px; height: 16px;
display: grid; place-items: center;
padding: 0 4px;
border-radius: 999px;
background: var(--accent);
color: white;
font-size: 9.5px; font-weight: 800;
flex-shrink: 0;
}
.ctp-chat-preview {
flex: 1; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-weight: 500;
color: var(--t-3);
text-align: left;
}
.ctp-chat-preview b { font-weight: 700; }
.ctp-chat-chevron { margin-left: auto; flex-shrink: 0; transition: transform .15s; }
.ctp-chat.is-open .ctp-chat-chevron { transform: rotate(180deg); }
.ctp-chat-list {
display: flex; flex-direction: column; gap: 6px;
max-height: 168px;
overflow-y: auto;
margin: 8px 0;
padding: 10px 12px;
background: rgba(0,0,0,0.22);
border: 1px solid var(--bd-1);
border-radius: 8px;
}
.ctp-chat-msg { font-size: 12px; line-height: 1.45; color: var(--t-2); overflow-wrap: anywhere; }
.ctp-chat-msg b { font-weight: 700; }
.ctp-chat-text { color: var(--t-1); }
.ctp-chat-time { margin-left: 6px; font-size: 10px; color: var(--t-4); font-variant-numeric: tabular-nums; }
.ctp-chat-empty { font-size: 11.5px; color: var(--t-4); text-align: center; padding: 6px 0; }
.ctp-chat-form { display: flex; gap: 6px; }
.ctp-chat-input {
flex: 1; height: 32px;
padding: 0 10px;
background: var(--bg-3);
border: 1px solid var(--bd-1);
border-radius: 7px;
color: var(--t-1);
font: inherit; font-size: 12px;
}
.ctp-chat-input::placeholder { color: var(--t-4); }
.ctp-chat-input:focus { outline: none; border-color: color-mix(in srgb, var(--accent) 60%, var(--bd-2)); }
.ctp-chat-send {
width: 32px; height: 32px;
display: grid; place-items: center;
background: color-mix(in srgb, var(--accent) 24%, var(--bg-3));
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--bd-2));
border-radius: 7px;
color: var(--t-1);
cursor: pointer;
flex-shrink: 0;
transition: background .15s;
}
.ctp-chat-send:hover { background: color-mix(in srgb, var(--accent) 40%, var(--bg-3)); }
@@ -0,0 +1,568 @@
// tweaks-panel.jsx
// Reusable Tweaks shell + form-control helpers.
//
// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
// individual prototypes don't re-roll it. Ships a consistent set of controls so you
// don't hand-draw <input type="range">, segmented radios, steppers, etc.
//
// Usage (in an HTML file that loads React + Babel):
//
// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
// "primaryColor": "#D97757",
// "palette": ["#D97757", "#29261b", "#f6f4ef"],
// "fontSize": 16,
// "density": "regular",
// "dark": false
// }/*EDITMODE-END*/;
//
// function App() {
// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
// return (
// <div style={{ fontSize: t.fontSize, color: t.primaryColor }}>
// Hello
// <TweaksPanel>
// <TweakSection label="Typography" />
// <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px"
// onChange={(v) => setTweak('fontSize', v)} />
// <TweakRadio label="Density" value={t.density}
// options={['compact', 'regular', 'comfy']}
// onChange={(v) => setTweak('density', v)} />
// <TweakSection label="Theme" />
// <TweakColor label="Primary" value={t.primaryColor}
// options={['#D97757', '#2A6FDB', '#1F8A5B', '#7A5AE0']}
// onChange={(v) => setTweak('primaryColor', v)} />
// <TweakColor label="Palette" value={t.palette}
// options={[['#D97757', '#29261b', '#f6f4ef'],
// ['#475569', '#0f172a', '#f1f5f9']]}
// onChange={(v) => setTweak('palette', v)} />
// <TweakToggle label="Dark mode" value={t.dark}
// onChange={(v) => setTweak('dark', v)} />
// </TweaksPanel>
// </div>
// );
// }
//
// ─────────────────────────────────────────────────────────────────────────────
const __TWEAKS_STYLE = `
.twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
max-height:calc(100vh - 32px);display:flex;flex-direction:column;
transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom right;
background:rgba(250,249,247,.78);color:#29261b;
-webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
border:.5px solid rgba(255,255,255,.6);border-radius:14px;
box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
.twk-hd{display:flex;align-items:center;justify-content:space-between;
padding:10px 8px 10px 14px;cursor:move;user-select:none}
.twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
.twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
.twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
.twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
overflow-y:auto;overflow-x:hidden;min-height:0;
scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
.twk-body::-webkit-scrollbar{width:8px}
.twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
.twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
border:2px solid transparent;background-clip:content-box}
.twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
border:2px solid transparent;background-clip:content-box}
.twk-row{display:flex;flex-direction:column;gap:5px}
.twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
.twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
color:rgba(41,38,27,.72)}
.twk-lbl>span:first-child{font-weight:500}
.twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
.twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
color:rgba(41,38,27,.45);padding:10px 0 0}
.twk-sect:first-child{padding-top:0}
.twk-field{appearance:none;box-sizing:border-box;width:100%;min-width:0;height:26px;padding:0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;
background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
.twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
select.twk-field{padding-right:22px;
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
background-repeat:no-repeat;background-position:right 8px center}
.twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
border-radius:999px;background:rgba(0,0,0,.12);outline:none}
.twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
width:14px;height:14px;border-radius:50%;background:#fff;
border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
background:rgba(0,0,0,.06);user-select:none}
.twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
.twk-seg.dragging .twk-seg-thumb{transition:none}
.twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;
overflow-wrap:anywhere}
.twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
.twk-toggle[data-on="1"]{background:#34c759}
.twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
.twk-toggle[data-on="1"] i{transform:translateX(14px)}
.twk-num{display:flex;align-items:center;box-sizing:border-box;min-width:0;height:26px;padding:0 0 0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
.twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
user-select:none;padding-right:8px}
.twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
outline:none;color:inherit;-moz-appearance:textfield}
.twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
-webkit-appearance:none;margin:0}
.twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
.twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
.twk-btn:hover{background:rgba(0,0,0,.88)}
.twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
.twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
.twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
background:transparent;flex-shrink:0}
.twk-swatch::-webkit-color-swatch-wrapper{padding:0}
.twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
.twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
.twk-chips{display:flex;gap:6px}
.twk-chip{position:relative;appearance:none;flex:1;min-width:0;height:46px;
padding:0;border:0;border-radius:6px;overflow:hidden;cursor:default;
box-shadow:0 0 0 .5px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.06);
transition:transform .12s cubic-bezier(.3,.7,.4,1),box-shadow .12s}
.twk-chip:hover{transform:translateY(-1px);
box-shadow:0 0 0 .5px rgba(0,0,0,.18),0 4px 10px rgba(0,0,0,.12)}
.twk-chip[data-on="1"]{box-shadow:0 0 0 1.5px rgba(0,0,0,.85),
0 2px 6px rgba(0,0,0,.15)}
.twk-chip>span{position:absolute;top:0;bottom:0;right:0;width:34%;
display:flex;flex-direction:column;box-shadow:-1px 0 0 rgba(0,0,0,.1)}
.twk-chip>span>i{flex:1;box-shadow:0 -1px 0 rgba(0,0,0,.1)}
.twk-chip>span>i:first-child{box-shadow:none}
.twk-chip svg{position:absolute;top:6px;left:6px;width:13px;height:13px;
filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))}
`;
// ── useTweaks ───────────────────────────────────────────────────────────────
// Single source of truth for tweak values. setTweak persists via the host
// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk).
function useTweaks(defaults) {
const [values, setValues] = React.useState(defaults);
// Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a
// useState-style call doesn't write a "[object Object]" key into the persisted
// JSON block.
const setTweak = React.useCallback((keyOrEdits, val) => {
const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
? keyOrEdits : { [keyOrEdits]: val };
setValues((prev) => ({ ...prev, ...edits }));
window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
// Same-window signal so in-page listeners (deck-stage rail thumbnails)
// can react — the parent message only reaches the host, not peers.
window.dispatchEvent(new CustomEvent('tweakchange', { detail: edits }));
}, []);
return [values, setTweak];
}
// ── TweaksPanel ─────────────────────────────────────────────────────────────
// Floating shell. Registers the protocol listener BEFORE announcing
// availability — if the announce ran first, the host's activate could land
// before our handler exists and the toolbar toggle would silently no-op.
// The close button posts __edit_mode_dismissed so the host's toolbar toggle
// flips off in lockstep; the host echoes __deactivate_edit_mode back which
// is what actually hides the panel.
function TweaksPanel({ title = 'Tweaks', noDeckControls = false, children }) {
const [open, setOpen] = React.useState(false);
const dragRef = React.useRef(null);
// Auto-inject a rail toggle when a <deck-stage> is on the page. The
// toggle drives the deck's per-viewer _railVisible via window message;
// state is mirrored from the same localStorage key the deck reads so
// the control reflects reality across reloads. The mechanism is the
// message — authors who want custom placement can post it directly
// and pass noDeckControls to suppress this one.
const hasDeckStage = React.useMemo(
() => typeof document !== 'undefined' && !!document.querySelector('deck-stage'),
[],
);
// deck-stage enables its rail in connectedCallback, but this panel can
// mount before that element has upgraded. The initial read catches the
// common case; the listener covers mounting first. (Older deck-stage.js
// copies still wait for the host's __omelette_rail_enabled postMessage —
// same listener handles those.)
const [railEnabled, setRailEnabled] = React.useState(
() => hasDeckStage && !!document.querySelector('deck-stage')?._railEnabled,
);
React.useEffect(() => {
if (!hasDeckStage || railEnabled) return undefined;
const onMsg = (e) => {
if (e.data && e.data.type === '__omelette_rail_enabled') setRailEnabled(true);
};
window.addEventListener('message', onMsg);
return () => window.removeEventListener('message', onMsg);
}, [hasDeckStage, railEnabled]);
const [railVisible, setRailVisible] = React.useState(() => {
try { return localStorage.getItem('deck-stage.railVisible') !== '0'; } catch (e) { return true; }
});
const toggleRail = (on) => {
setRailVisible(on);
window.postMessage({ type: '__deck_rail_visible', on }, '*');
};
const offsetRef = React.useRef({ x: 16, y: 16 });
const PAD = 16;
const clampToViewport = React.useCallback(() => {
const panel = dragRef.current;
if (!panel) return;
const w = panel.offsetWidth, h = panel.offsetHeight;
const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
offsetRef.current = {
x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
};
panel.style.right = offsetRef.current.x + 'px';
panel.style.bottom = offsetRef.current.y + 'px';
}, []);
React.useEffect(() => {
if (!open) return;
clampToViewport();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', clampToViewport);
return () => window.removeEventListener('resize', clampToViewport);
}
const ro = new ResizeObserver(clampToViewport);
ro.observe(document.documentElement);
return () => ro.disconnect();
}, [open, clampToViewport]);
React.useEffect(() => {
const onMsg = (e) => {
const t = e?.data?.type;
if (t === '__activate_edit_mode') setOpen(true);
else if (t === '__deactivate_edit_mode') setOpen(false);
};
window.addEventListener('message', onMsg);
window.parent.postMessage({ type: '__edit_mode_available' }, '*');
return () => window.removeEventListener('message', onMsg);
}, []);
const dismiss = () => {
setOpen(false);
window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
};
const onDragStart = (e) => {
const panel = dragRef.current;
if (!panel) return;
const r = panel.getBoundingClientRect();
const sx = e.clientX, sy = e.clientY;
const startRight = window.innerWidth - r.right;
const startBottom = window.innerHeight - r.bottom;
const move = (ev) => {
offsetRef.current = {
x: startRight - (ev.clientX - sx),
y: startBottom - (ev.clientY - sy),
};
clampToViewport();
};
const up = () => {
window.removeEventListener('mousemove', move);
window.removeEventListener('mouseup', up);
};
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
};
if (!open) return null;
return (
<>
<style>{__TWEAKS_STYLE}</style>
<div ref={dragRef} className="twk-panel" data-noncommentable=""
style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
<div className="twk-hd" onMouseDown={onDragStart}>
<b>{title}</b>
<button className="twk-x" aria-label="Close tweaks"
onMouseDown={(e) => e.stopPropagation()}
onClick={dismiss}></button>
</div>
<div className="twk-body">
{children}
{hasDeckStage && railEnabled && !noDeckControls && (
<TweakSection label="Deck">
<TweakToggle label="Thumbnail rail" value={railVisible} onChange={toggleRail} />
</TweakSection>
)}
</div>
</div>
</>
);
}
// ── Layout helpers ──────────────────────────────────────────────────────────
function TweakSection({ label, children }) {
return (
<>
<div className="twk-sect">{label}</div>
{children}
</>
);
}
function TweakRow({ label, value, children, inline = false }) {
return (
<div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
<div className="twk-lbl">
<span>{label}</span>
{value != null && <span className="twk-val">{value}</span>}
</div>
{children}
</div>
);
}
// ── Controls ────────────────────────────────────────────────────────────────
function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
return (
<TweakRow label={label} value={`${value}${unit}`}>
<input type="range" className="twk-slider" min={min} max={max} step={step}
value={value} onChange={(e) => onChange(Number(e.target.value))} />
</TweakRow>
);
}
function TweakToggle({ label, value, onChange }) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
role="switch" aria-checked={!!value}
onClick={() => onChange(!value)}><i /></button>
</div>
);
}
function TweakRadio({ label, value, options, onChange }) {
const trackRef = React.useRef(null);
const [dragging, setDragging] = React.useState(false);
// The active value is read by pointer-move handlers attached for the lifetime
// of a drag — ref it so a stale closure doesn't fire onChange for every move.
const valueRef = React.useRef(value);
valueRef.current = value;
// Segments wrap mid-word once per-segment width runs out. The track is
// ~248px (280 panel 28 body pad 4 seg pad), each button loses 12px
// to its own padding, and 11.5px system-ui averages ~6.3px/char — so 2
// options fit ~16 chars each, 3 fit ~10. Past that (or >3 options), fall
// back to a dropdown rather than wrap.
const labelLen = (o) => String(typeof o === 'object' ? o.label : o).length;
const maxLen = options.reduce((m, o) => Math.max(m, labelLen(o)), 0);
const fitsAsSegments = maxLen <= ({ 2: 16, 3: 10 }[options.length] ?? 0);
if (!fitsAsSegments) {
// <select> emits strings — map back to the original option value so the
// fallback stays type-preserving (numbers, booleans) like the segment path.
const resolve = (s) => {
const m = options.find((o) => String(typeof o === 'object' ? o.value : o) === s);
return m === undefined ? s : typeof m === 'object' ? m.value : m;
};
return <TweakSelect label={label} value={value} options={options}
onChange={(s) => onChange(resolve(s))} />;
}
const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
const idx = Math.max(0, opts.findIndex((o) => o.value === value));
const n = opts.length;
const segAt = (clientX) => {
const r = trackRef.current.getBoundingClientRect();
const inner = r.width - 4;
const i = Math.floor(((clientX - r.left - 2) / inner) * n);
return opts[Math.max(0, Math.min(n - 1, i))].value;
};
const onPointerDown = (e) => {
setDragging(true);
const v0 = segAt(e.clientX);
if (v0 !== valueRef.current) onChange(v0);
const move = (ev) => {
if (!trackRef.current) return;
const v = segAt(ev.clientX);
if (v !== valueRef.current) onChange(v);
};
const up = () => {
setDragging(false);
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return (
<TweakRow label={label}>
<div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
<div className="twk-seg-thumb"
style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
width: `calc((100% - 4px) / ${n})` }} />
{opts.map((o) => (
<button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
{o.label}
</button>
))}
</div>
</TweakRow>
);
}
function TweakSelect({ label, value, options, onChange }) {
return (
<TweakRow label={label}>
<select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
{options.map((o) => {
const v = typeof o === 'object' ? o.value : o;
const l = typeof o === 'object' ? o.label : o;
return <option key={v} value={v}>{l}</option>;
})}
</select>
</TweakRow>
);
}
function TweakText({ label, value, placeholder, onChange }) {
return (
<TweakRow label={label}>
<input className="twk-field" type="text" value={value} placeholder={placeholder}
onChange={(e) => onChange(e.target.value)} />
</TweakRow>
);
}
function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
const clamp = (n) => {
if (min != null && n < min) return min;
if (max != null && n > max) return max;
return n;
};
const startRef = React.useRef({ x: 0, val: 0 });
const onScrubStart = (e) => {
e.preventDefault();
startRef.current = { x: e.clientX, val: value };
const decimals = (String(step).split('.')[1] || '').length;
const move = (ev) => {
const dx = ev.clientX - startRef.current.x;
const raw = startRef.current.val + dx * step;
const snapped = Math.round(raw / step) * step;
onChange(clamp(Number(snapped.toFixed(decimals))));
};
const up = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return (
<div className="twk-num">
<span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
<input type="number" value={value} min={min} max={max} step={step}
onChange={(e) => onChange(clamp(Number(e.target.value)))} />
{unit && <span className="twk-num-unit">{unit}</span>}
</div>
);
}
// Relative-luminance contrast pick — checkmarks drawn over a swatch need to
// read on both #111 and #fafafa without per-option configuration. Hex input
// only (#rgb / #rrggbb); named or rgb()/hsl() colors fall through to "light".
function __twkIsLight(hex) {
const h = String(hex).replace('#', '');
const x = h.length === 3 ? h.replace(/./g, (c) => c + c) : h.padEnd(6, '0');
const n = parseInt(x.slice(0, 6), 16);
if (Number.isNaN(n)) return true;
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
return r * 299 + g * 587 + b * 114 > 148000;
}
const __TwkCheck = ({ light }) => (
<svg viewBox="0 0 14 14" aria-hidden="true">
<path d="M3 7.2 5.8 10 11 4.2" fill="none" strokeWidth="2.2"
strokeLinecap="round" strokeLinejoin="round"
stroke={light ? 'rgba(0,0,0,.78)' : '#fff'} />
</svg>
);
// TweakColor — curated color/palette picker. Each option is either a single
// hex string or an array of 1-5 hex strings; the card adapts — a lone color
// renders solid, a palette renders colors[0] as the hero (left ~2/3) with the
// rest stacked in a sharp column on the right. onChange emits the
// option in the shape it was passed (string stays string, array stays array).
// Without options it falls back to the native color input for back-compat.
function TweakColor({ label, value, options, onChange }) {
if (!options || !options.length) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<input type="color" className="twk-swatch" value={value}
onChange={(e) => onChange(e.target.value)} />
</div>
);
}
// Native <input type=color> emits lowercase hex per the HTML spec, so
// compare case-insensitively. String() guards JSON.stringify(undefined),
// which returns the primitive undefined (no .toLowerCase).
const key = (o) => String(JSON.stringify(o)).toLowerCase();
const cur = key(value);
return (
<TweakRow label={label}>
<div className="twk-chips" role="radiogroup">
{options.map((o, i) => {
const colors = Array.isArray(o) ? o : [o];
const [hero, ...rest] = colors;
const sup = rest.slice(0, 4);
const on = key(o) === cur;
return (
<button key={i} type="button" className="twk-chip" role="radio"
aria-checked={on} data-on={on ? '1' : '0'}
aria-label={colors.join(', ')} title={colors.join(' · ')}
style={{ background: hero }}
onClick={() => onChange(o)}>
{sup.length > 0 && (
<span>
{sup.map((c, j) => <i key={j} style={{ background: c }} />)}
</span>
)}
{on && <__TwkCheck light={__twkIsLight(hero)} />}
</button>
);
})}
</div>
</TweakRow>
);
}
function TweakButton({ label, onClick, secondary = false }) {
return (
<button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
onClick={onClick}>{label}</button>
);
}
Object.assign(window, {
useTweaks, TweaksPanel, TweakSection, TweakRow,
TweakSlider, TweakToggle, TweakRadio, TweakSelect,
TweakText, TweakNumber, TweakColor, TweakButton,
});
@@ -13,10 +13,12 @@ logo_handoff/
├── demo.html ← open in a browser to see it in motion + in context
├── INTEGRATION.md ← this file
└── assets/
├── softlan-mark.svg static S, fill="currentColor" (inline use)
├── softlan-mark-blue.svg static S, fixed #3b82f6 (img/favicon use)
├── softlan-tile.svg rounded app-icon tile, gradient + white S
── favicon.svg = tile (drop-in favicon)
├── softlan-mark.svg static S, fill="currentColor" (inline use)
├── softlan-mark-blue.svg static S, fixed #3b82f6 (img/favicon use)
├── softlan-tile.svg rounded app-icon tile, gradient + white S
── softlan-lockup.svg full lockup (tile + SoftLAN + LAUNCHER), on dark
├── softlan-lockup-ink.svg full lockup for light backgrounds
└── favicon.svg = tile (drop-in favicon)
```
---
@@ -127,7 +129,66 @@ rsvg-convert -w 32 -h 32 assets/favicon.svg > favicon-32.png
---
## 4. Accessibility & motion
## 4. The logo lockup (icon + wordmark)
The full logo is the **gradient app-tile + the “SoftLAN” wordmark**, with a
tracked-out “LAUNCHER” beneath. This is the canonical lockup — use it for the
top bar, About screen, installer, store header, splash, etc.
### Exact spec
| part | value |
|------|-------|
| tile | rounded square, `radius = round(size·0.225)`, the accent gradient (`linear-gradient(155deg, mix(accent,white 22%) → accent 52% → mix(accent,black 28%))`), white pixel S at `round(size·0.62)` |
| gap tile → text | `size·0.32` |
| wordmark | system font (`-apple-system, "Segoe UI", system-ui`), **700**, `font-size ≈ tile·0.568` (25px at a 44px tile), `letter-spacing -0.01em` |
| “Soft” color | `#e6edf3` on dark UI · `#0a0e13` on light UI |
| “LAN” color | the accent (`#3b82f6`) — always |
| “LAUNCHER” | system font, **700**, `font-size = wordmark·0.42`, `letter-spacing 0.34em`, UPPERCASE, `#6b7785` (dark) / `#8b97a6` (light) |
> The wordmark is set in the **system UI sans** on purpose (it sits inline in the
> chrome). If you need a fixed, OS-independent render — store art, OG images,
> anywhere the system font isnt guaranteed — use the SVG assets below, which
> carry the same metrics. For pixel-perfect raster, set the wordmark in your
> design tool and export, since `system-ui` varies by platform.
### Drop-in React (from `pixel-live.jsx`)
```jsx
import { Lockup, Wordmark } from './pixel-live';
// full lockup, mark stays alive inside the tile:
<Lockup accent="#3b82f6" tile={44} /> // on dark (default)
<Lockup accent="#3b82f6" tile={64} light={false} /> // on light bg
<Lockup accent="#3b82f6" tile={30} sub={false} /> // compact, no "LAUNCHER"
<Lockup live={false} /> // static S in the tile
// just the lettering (e.g. next to the bare LiveLogo in a slim top bar):
<Wordmark accent="#3b82f6" size={19} />
```
`Lockup` props: `accent`, `tile` (px), `light` (true=dark UI), `sub` (show
“LAUNCHER”), `live` (animate the mark). `Wordmark` props: `accent`, `size`,
`light`, `sub`.
### Plain CSS/HTML (no React)
```html
<span class="sl-wm">Soft<b>LAN</b></span>
<style>
.sl-wm { font: 700 25px/1 -apple-system, "Segoe UI", system-ui, sans-serif;
letter-spacing: -0.01em; color: #e6edf3; } /* #0a0e13 on light */
.sl-wm b { color: #3b82f6; font-weight: 700; } /* the accent */
</style>
```
### Static SVG
- **`assets/softlan-lockup.svg`** — full lockup for **dark** backgrounds.
- **`assets/softlan-lockup-ink.svg`** — same lockup for **light** backgrounds
(“Soft” goes ink-dark; “LAN” stays accent).
Both are self-contained, baked to brand blue `#3b82f6`. To re-tint, edit the
three `linearGradient` stops and the “LAN” `fill`.
---
## 5. Accessibility & motion
- The static SVGs carry `role="img"` + `aria-label="SoftLAN"`. Give the live
component an accessible label in its host (e.g. wrap with `aria-label`), since
@@ -142,8 +203,10 @@ rsvg-convert -w 32 -h 32 assets/favicon.svg > favicon-32.png
---
## 5. Quick check
## 6. Quick check
Open `demo.html` in any browser: hover the big mark (or wait), use the trick
buttons, and try the accent swatches. The small mark in the mock top bar is the
same component at `size={30}` — thats exactly how it looks in the launcher.
The **logo lockup** card shows the full icon-plus-wordmark on dark and light,
at several sizes, and recolors live with the accent swatches.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

+43
View File
@@ -0,0 +1,43 @@
<svg xmlns="http://www.w3.org/2000/svg" width="296" height="64" viewBox="0 0 296 64" fill="none" role="img" aria-label="SoftLAN Launcher">
<title>SoftLAN Launcher</title>
<defs>
<linearGradient id="slTileInk" x1="0.16" y1="0" x2="0.84" y2="1">
<stop offset="0" stop-color="#669ef8"></stop>
<stop offset="0.52" stop-color="#3b82f6"></stop>
<stop offset="1" stop-color="#2b5eb1"></stop>
</linearGradient>
</defs>
<rect x="0" y="0" width="64" height="64" rx="14" fill="url(#slTileInk)"></rect>
<rect x="0.5" y="0.5" width="63" height="63" rx="13.5" fill="none" stroke="#000000" stroke-opacity="0.10"></rect>
<svg x="12" y="12" width="40" height="40" viewBox="0 0 100 100">
<g fill="#ffffff">
<rect x="8.7" y="8.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="25.7" y="8.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="42.7" y="8.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="59.7" y="8.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="76.7" y="8.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="8.7" y="25.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="8.7" y="42.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="25.7" y="42.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="42.7" y="42.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="59.7" y="42.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="76.7" y="42.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="76.7" y="59.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="8.7" y="76.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="25.7" y="76.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="42.7" y="76.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="59.7" y="76.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="76.7" y="76.7" width="14.6" height="14.6" rx="3"></rect>
</g>
</svg>
<text x="82" y="35" font-family="-apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, system-ui, sans-serif" font-size="32" font-weight="700" letter-spacing="-0.3">
<tspan fill="#0a0e13">Soft</tspan><tspan fill="#3b82f6">LAN</tspan>
</text>
<text x="83" y="52" font-family="-apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, system-ui, sans-serif" font-size="11.5" font-weight="700" letter-spacing="3.8" fill="#8b97a6">LAUNCHER</text>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

+43
View File
@@ -0,0 +1,43 @@
<svg xmlns="http://www.w3.org/2000/svg" width="296" height="64" viewBox="0 0 296 64" fill="none" role="img" aria-label="SoftLAN Launcher">
<title>SoftLAN Launcher</title>
<defs>
<linearGradient id="slTile" x1="0.16" y1="0" x2="0.84" y2="1">
<stop offset="0" stop-color="#669ef8"></stop>
<stop offset="0.52" stop-color="#3b82f6"></stop>
<stop offset="1" stop-color="#2b5eb1"></stop>
</linearGradient>
</defs>
<rect x="0" y="0" width="64" height="64" rx="14" fill="url(#slTile)"></rect>
<rect x="0.5" y="0.5" width="63" height="63" rx="13.5" fill="none" stroke="#ffffff" stroke-opacity="0.18"></rect>
<svg x="12" y="12" width="40" height="40" viewBox="0 0 100 100">
<g fill="#ffffff">
<rect x="8.7" y="8.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="25.7" y="8.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="42.7" y="8.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="59.7" y="8.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="76.7" y="8.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="8.7" y="25.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="8.7" y="42.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="25.7" y="42.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="42.7" y="42.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="59.7" y="42.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="76.7" y="42.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="76.7" y="59.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="8.7" y="76.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="25.7" y="76.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="42.7" y="76.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="59.7" y="76.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="76.7" y="76.7" width="14.6" height="14.6" rx="3"></rect>
</g>
</svg>
<text x="82" y="35" font-family="-apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, system-ui, sans-serif" font-size="32" font-weight="700" letter-spacing="-0.3">
<tspan fill="#e6edf3">Soft</tspan><tspan fill="#3b82f6">LAN</tspan>
</text>
<text x="83" y="52" font-family="-apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, system-ui, sans-serif" font-size="11.5" font-weight="700" letter-spacing="3.8" fill="#6b7785">LAUNCHER</text>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

+29
View File
@@ -0,0 +1,29 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100" role="img" aria-label="SoftLAN">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#7dabfb"></stop>
<stop offset="0.55" stop-color="#3b82f6"></stop>
<stop offset="1" stop-color="#2456a8"></stop>
</linearGradient>
</defs>
<rect x="0" y="0" width="100" height="100" rx="22.5" fill="url(#g)"></rect>
<g transform="translate(8 8) scale(0.84)">
<rect x="76.7" y="8.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="59.7" y="8.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="42.7" y="8.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="25.7" y="8.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="8.7" y="8.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="8.7" y="25.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="8.7" y="42.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="25.7" y="42.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="42.7" y="42.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="59.7" y="42.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="76.7" y="42.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="76.7" y="59.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="76.7" y="76.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="59.7" y="76.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="42.7" y="76.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="25.7" y="76.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="8.7" y="76.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -83,6 +83,19 @@
.swatches { display: flex; gap: 8px; align-items: center; margin-top: 2px; }
.swatches span { font-size: 12px; color: #6b7785; margin-right: 4px; }
.swatches button { width: 22px; height: 22px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.18); cursor: pointer; padding: 0; }
/* lockup card */
.lockcard {
width: min(960px, 100%); border-radius: 20px;
background: linear-gradient(180deg, #0e141b 0%, #0a0e13 100%);
border: 1px solid rgba(255,255,255,0.07);
padding: 40px 36px; display: flex; flex-direction: column; gap: 28px;
}
.lockcard h2 { margin: 0; font-family: 'Bebas Neue', sans-serif; font-weight: 400; font-size: 26px; letter-spacing: 0.03em; }
.lockrow { display: flex; align-items: center; gap: 40px; flex-wrap: wrap; }
.lockrow.light { background: #f0eee9; border-radius: 14px; padding: 24px 28px; }
.lab { font-size: 11px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: #5b6675; margin-bottom: 14px; }
.lab.dark { color: #94908a; }
</style>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
@@ -121,6 +134,26 @@ function App() {
<span className="pill">3 PEERS ONLINE</span>
</div>
{/* the canonical logo lockup */}
<div className="lockcard">
<h2>The logo lockup</h2>
<div>
<div className="lab">On dark — primary</div>
<div className="lockrow">
<Lockup accent={accent} tile={64} />
<Lockup accent={accent} tile={44} />
<Lockup accent={accent} tile={30} sub={false} />
</div>
</div>
<div>
<div className="lab dark">On light — ink variant</div>
<div className="lockrow light">
<Lockup accent={accent} tile={64} light={false} />
<Lockup accent={accent} tile={44} light={false} />
</div>
</div>
</div>
{/* hero stage */}
<div className="stage">
<h1>The mark, alive</h1>
@@ -76,12 +76,15 @@ function renderSnake(p, accent) {
const start = Math.max(0, i - L + 1);
const body = PATH.slice(start, i + 1);
const n = body.length;
// over the final stretch, ease the per-cell gradient/opacity/head-grow toward
// the flat, uniform static S so the hand-off to renderStatic is seamless.
const settle = Math.max(0, Math.min(1, (p - 0.82) / 0.18));
return body.map((cell, j) => {
const t = n > 1 ? j / (n - 1) : 1; // 0 tail 1 head
const fill = lshade(accent, (t - 0.45) * 0.5); // head brighter, tail darker
const opacity = 0.42 + 0.58 * t;
const fill = lshade(accent, (t - 0.45) * 0.5 * (1 - settle)); // head brighter, tail darker
const opacity = (0.42 + 0.58 * t) + (1 - (0.42 + 0.58 * t)) * settle;
const isHead = j === n - 1;
const grow = isHead ? 1.06 : 1;
const grow = (isHead ? 1.06 : 1) * (1 - settle) + 1 * settle;
const s = LSIZE * grow, d = (LSIZE - s) / 2;
return <Cell key={j} col={cell[0]} row={cell[1]} fill={fill} opacity={opacity} dx={d} dy={d} w={s} h={s} />;
});
@@ -173,4 +176,50 @@ const LiveLogo = forwardRef(function LiveLogo(
);
});
Object.assign(window, { LiveLogo, renderSnake, renderGlitch, renderRGB, renderStatic });
// Wordmark: "SoftLAN" + "LAUNCHER", the exact brand lettering
// `light` controls the "Soft" ink: true #e6edf3 (dark UI), false #0a0e13 (light UI).
// "LAN" is always the accent. Scales off `size` (the cap-height-ish wordmark px).
function Wordmark({ accent = '#3b82f6', size = 25, light = true, sub = true }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: size * 0.08, lineHeight: 1 }}>
<div style={{
fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif',
fontSize: size, fontWeight: 700, letterSpacing: '-0.01em', lineHeight: 1,
}}>
<span style={{ color: light ? '#e6edf3' : '#0a0e13' }}>Soft</span>
<span style={{ color: accent }}>LAN</span>
</div>
{sub && (
<div style={{
fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif',
fontSize: size * 0.42, fontWeight: 700, letterSpacing: '0.34em',
textTransform: 'uppercase', color: light ? '#6b7785' : '#8b97a6',
}}>Launcher</div>
)}
</div>
);
}
// Lockup: the gradient app-tile (with the live or static S) + the Wordmark
// This is the canonical horizontal logo lockup. `tile` is the tile px size; the
// wordmark scales with it. `live` keeps the mark animated inside the tile.
function Lockup({ accent = '#3b82f6', tile = 44, light = true, sub = true, live = true }) {
const rad = Math.round(tile * 0.225);
const markSize = Math.round(tile * 0.62);
return (
<div style={{ display: 'inline-flex', alignItems: 'center', gap: tile * 0.32 }}>
<div style={{
width: tile, height: tile, borderRadius: rad, display: 'grid', placeItems: 'center',
background: `linear-gradient(155deg, color-mix(in srgb, ${accent} 78%, white) 0%, ${accent} 52%, color-mix(in srgb, ${accent} 72%, black) 100%)`,
boxShadow: `0 ${tile * 0.14}px ${tile * 0.34}px -${tile * 0.12}px color-mix(in srgb, ${accent} 70%, black), inset 0 1px 0 rgba(255,255,255,0.35), inset 0 0 0 1px rgba(255,255,255,0.08)`,
}}>
{live
? <LiveLogo accent="#ffffff" size={markSize} idleAuto={true} />
: <svg viewBox="0 0 100 100" width={markSize} height={markSize}>{renderStatic('#ffffff')}</svg>}
</div>
<Wordmark accent={accent} size={tile * 0.568} light={light} sub={sub} />
</div>
);
}
Object.assign(window, { LiveLogo, Wordmark, Lockup, renderSnake, renderGlitch, renderRGB, renderStatic });
+3
View File
@@ -47,6 +47,9 @@ peer-cli-image:
mkdir -p "$DOCKER_CONFIG"
docker build -f crates/lanspread-peer-cli/Dockerfile -t lanspread-peer-cli:dev .
peer-cli-tests *SCENARIOS: peer-cli-image
python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py {{ SCENARIOS }}
# macvlan: each peer container gets its own MAC/IP on the real LAN.
# Override on the command line if your LAN differs, e.g.
# just LANSPREAD_PARENT_IFACE=enp4s0 LANSPREAD_SUBNET=10.0.0.0/24 LANSPREAD_GATEWAY=10.0.0.1 peer-cli-net