Compare commits

...

4 Commits

46 changed files with 969 additions and 905 deletions
+8 -92
View File
@@ -122,7 +122,7 @@ pub fn to_pdf(puzzle: &Puzzle, title: &str, variant: &str) -> Vec<u8> {
c.push_str(&format!("BT /F2 6 Tf 0.6 0.55 0.5 rg {} {} Td (cruciverb) Tj ET\n", margin, margin - 10.0)); c.push_str(&format!("BT /F2 6 Tf 0.6 0.55 0.5 rg {} {} Td (cruciverb) Tj ET\n", margin, margin - 10.0));
build_pdf_with_fonts(&c, pw, ph, bold_font.as_deref(), regular_font.as_deref()) build_pdf_clean(&c, pw, ph, regular_font.as_deref(), bold_font.as_deref())
} }
fn esc(s: &str) -> String { fn esc(s: &str) -> String {
@@ -130,100 +130,16 @@ fn esc(s: &str) -> String {
} }
fn truncate(s: &str, max: usize) -> String { fn truncate(s: &str, max: usize) -> String {
if s.len() <= max { s.to_string() } else { format!("{}...", &s[..max - 3]) } if s.chars().count() <= max {
s.to_string()
} else {
let keep = max.saturating_sub(3);
let cut: String = s.chars().take(keep).collect();
format!("{}...", cut)
}
} }
fn build_pdf_with_fonts(content: &str, pw: f64, ph: f64, bold_ttf: Option<&[u8]>, regular_ttf: Option<&[u8]>) -> Vec<u8> {
let mut pdf = Vec::new();
let mut offsets = Vec::new();
let mut obj_count = 0;
pdf.extend_from_slice(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n");
macro_rules! obj {
($data:expr) => {{
obj_count += 1;
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n", obj_count).as_bytes());
pdf.extend_from_slice($data);
pdf.extend_from_slice(b"\nendobj\n");
obj_count
}};
}
// obj 1: catalog
obj!(b"<< /Type /Catalog /Pages 2 0 R >>");
// obj 2: pages
obj!(b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
// build font objects
let (f1_ref, f2_ref);
if let Some(reg_data) = regular_ttf {
// embedded TrueType font for F1 (regular/body)
let stream_id = {
obj_count += 1;
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n", obj_count).as_bytes());
pdf.extend_from_slice(format!("<< /Length {} /Length1 {} >>\nstream\n", reg_data.len(), reg_data.len()).as_bytes());
pdf.extend_from_slice(reg_data);
pdf.extend_from_slice(b"\nendstream\nendobj\n");
obj_count
};
let desc_id = obj!(format!("<< /Type /FontDescriptor /FontName /DMSans /Flags 32 /FontFile2 {} 0 R /ItalicAngle 0 /Ascent 1000 /Descent -200 /CapHeight 700 /StemV 80 >>", stream_id).as_bytes());
f1_ref = obj!(format!("<< /Type /Font /Subtype /TrueType /BaseFont /DMSans /FirstChar 32 /LastChar 255 /Encoding /WinAnsiEncoding /FontDescriptor {} 0 R >>", desc_id).as_bytes());
} else {
f1_ref = obj!(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>");
}
if let Some(bold_data) = bold_ttf {
let stream_id = {
obj_count += 1;
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n", obj_count).as_bytes());
pdf.extend_from_slice(format!("<< /Length {} /Length1 {} >>\nstream\n", bold_data.len(), bold_data.len()).as_bytes());
pdf.extend_from_slice(bold_data);
pdf.extend_from_slice(b"\nendstream\nendobj\n");
obj_count
};
let desc_id = obj!(format!("<< /Type /FontDescriptor /FontName /PlayfairDisplay-Bold /Flags 32 /FontFile2 {} 0 R /ItalicAngle 0 /Ascent 1000 /Descent -200 /CapHeight 700 /StemV 120 >>", stream_id).as_bytes());
f2_ref = obj!(format!("<< /Type /Font /Subtype /TrueType /BaseFont /PlayfairDisplay-Bold /FirstChar 32 /LastChar 255 /Encoding /WinAnsiEncoding /FontDescriptor {} 0 R >>", desc_id).as_bytes());
} else {
f2_ref = obj!(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>");
}
// obj: content stream
let content_id = {
obj_count += 1;
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n", obj_count).as_bytes());
pdf.extend_from_slice(format!("<< /Length {} >>\nstream\n", content.len()).as_bytes());
pdf.extend_from_slice(content.as_bytes());
pdf.extend_from_slice(b"\nendstream\nendobj\n");
obj_count
};
// obj 3: page (deferred - write now)
let page_data = format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {} {}] /Contents {} 0 R /Resources << /Font << /F1 {} 0 R /F2 {} 0 R >> >> >>",
pw, ph, content_id, f1_ref, f2_ref
);
// we need to insert page as obj 3, but we already wrote objects 1,2 then font objects
// so obj 3 wasn't written yet. let's write it now
obj_count += 1;
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n{}\nendobj\n", obj_count, page_data).as_bytes());
let _page_obj = obj_count;
// fix pages to reference our page object
// this is hacky but works - we'll just rebuild the whole thing properly
// actually let's just rebuild cleanly
drop(pdf);
drop(offsets);
build_pdf_clean(content, pw, ph, regular_ttf, bold_ttf)
}
fn build_pdf_clean(content: &str, pw: f64, ph: f64, regular_ttf: Option<&[u8]>, bold_ttf: Option<&[u8]>) -> Vec<u8> { fn build_pdf_clean(content: &str, pw: f64, ph: f64, regular_ttf: Option<&[u8]>, bold_ttf: Option<&[u8]>) -> Vec<u8> {
// collect all objects first, then serialize // collect all objects first, then serialize
+12 -2
View File
@@ -241,6 +241,16 @@ pub fn generate_themed_puzzle(
) -> Result<Puzzle> { ) -> Result<Puzzle> {
let start = Instant::now(); let start = Instant::now();
// Bias the fill toward theme words when a theme is requested.
let theme_boost = theme_words
.filter(|w| !w.is_empty())
.map(|tw| {
let mut d = dict.clone();
d.boost_theme(tw);
d
});
let solve_dict = theme_boost.as_ref().unwrap_or(dict);
let cores = std::thread::available_parallelism() let cores = std::thread::available_parallelism()
.map(|n| n.get()) .map(|n| n.get())
.unwrap_or(1) .unwrap_or(1)
@@ -250,7 +260,7 @@ pub fn generate_themed_puzzle(
if cores > 1 && config.width > 9 { if cores > 1 && config.width > 9 {
return parallel_generate( return parallel_generate(
config, config,
dict, solve_dict,
clue_db, clue_db,
difficulty, difficulty,
theme, theme,
@@ -260,7 +270,7 @@ pub fn generate_themed_puzzle(
); );
} }
sequential_generate(config, dict, clue_db, difficulty, theme, theme_words, start) sequential_generate(config, solve_dict, clue_db, difficulty, theme, theme_words, start)
} }
struct SolvedGrid { struct SolvedGrid {
-46
View File
@@ -1,49 +1,3 @@
use crate::dict::Dictionary;
pub fn score_candidate(
word_idx: usize,
dict: &Dictionary,
used_words: &[bool],
placed_texts: &[String],
) -> f64 {
if used_words[word_idx] {
return -1.0;
}
let word = dict.get(word_idx);
let text = &word.text;
let mut score = word.frequency;
// reject words that are too similar to already-placed words
for placed in placed_texts {
if is_related(text, placed) {
return -1.0;
}
}
// bonus for letter variety
let bytes = text.as_bytes();
let mut seen = [false; 26];
let mut unique = 0;
for &b in bytes {
let idx = (b - b'A') as usize;
if !seen[idx] {
seen[idx] = true;
unique += 1;
}
}
let variety = unique as f64 / bytes.len() as f64;
score += variety * 0.1;
// bonus for common letters
let common = b"ETAOINSHRDLU";
let common_count = bytes.iter().filter(|b| common.contains(b)).count();
let common_ratio = common_count as f64 / bytes.len() as f64;
score += common_ratio * 0.05;
score
}
pub fn is_related(a: &str, b: &str) -> bool { pub fn is_related(a: &str, b: &str) -> bool {
// one is a prefix of the other (SEAL / SEALS, RUN / RUNNING) // one is a prefix of the other (SEAL / SEALS, RUN / RUNNING)
if a.len() >= 3 && b.len() >= 3 { if a.len() >= 3 && b.len() >= 3 {
+1 -1
View File
@@ -890,7 +890,7 @@ async fn handle_leaderboard(state: &Arc<AppState>, activity: &serde_json::Value,
// check instance reputation // check instance reputation
let reputation: f64 = dbi.db.query_row( let reputation: f64 = dbi.db.query_row(
"SELECT reputation FROM ap_instance_reputation WHERE domain = ?1", "SELECT score FROM ap_instance_reputation WHERE domain = ?1",
[origin], |row| row.get(0), [origin], |row| row.get(0),
).unwrap_or(100.0); ).unwrap_or(100.0);
if reputation < 30.0 { if reputation < 30.0 {
+26 -11
View File
@@ -252,10 +252,12 @@ pub async fn admin_setup(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Json(req): Json<serde_json::Value>, Json(req): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, Response> { ) -> Result<Json<serde_json::Value>, Response> {
let dbi = state.db.lock().await; // Optional first-run gate: if ADMIN_SETUP_KEY is set, a matching key must
// be supplied so a random visitor cannot claim the instance first.
if db::has_admin_account(&dbi.db) { if let Ok(key) = std::env::var("ADMIN_SETUP_KEY") {
return Err((StatusCode::FORBIDDEN, "admin already configured").into_response()); if req.get("key").and_then(|v| v.as_str()) != Some(key.as_str()) {
return Err((StatusCode::UNAUTHORIZED, "invalid setup key").into_response());
}
} }
let username = req.get("username").and_then(|v| v.as_str()).unwrap_or(""); let username = req.get("username").and_then(|v| v.as_str()).unwrap_or("");
@@ -281,7 +283,20 @@ pub async fn admin_setup(
} }
}; };
db::create_admin_account(&dbi.db, username, &stored); // Atomic check-and-create inside a transaction so two concurrent setup
// requests cannot both pass the "no admin yet" check and create two admins.
let dbi = state.db.lock().await;
let tx = match dbi.db.unchecked_transaction() {
Ok(tx) => tx,
Err(_) => return Err((StatusCode::INTERNAL_SERVER_ERROR, "database error").into_response()),
};
if db::has_admin_account(&tx) {
return Err((StatusCode::FORBIDDEN, "admin already configured").into_response());
}
db::create_admin_account(&tx, username, &stored);
if tx.commit().is_err() {
return Err((StatusCode::INTERNAL_SERVER_ERROR, "database error").into_response());
}
db::log_admin_action(&dbi.db, "admin_setup", Some(username), None); db::log_admin_action(&dbi.db, "admin_setup", Some(username), None);
Ok(Json(serde_json::json!({ "ok": true }))) Ok(Json(serde_json::json!({ "ok": true })))
@@ -388,7 +403,7 @@ pub async fn stats(
if let Ok(mut stmt) = conn.prepare( if let Ok(mut stmt) = conn.prepare(
"SELECT cc.submitted_by, CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, COUNT(*), SUM(CASE WHEN cc.status = 'approved' THEN 1 ELSE 0 END) "SELECT cc.submitted_by, CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, COUNT(*), SUM(CASE WHEN cc.status = 'approved' THEN 1 ELSE 0 END)
FROM community_clues cc FROM community_clues cc
LEFT JOIN sessions s ON cc.submitted_by = s.token LEFT JOIN sessions s ON cc.submitted_by = s.account_id
LEFT JOIN settings st ON cc.submitted_by = st.session_token LEFT JOIN settings st ON cc.submitted_by = st.session_token
GROUP BY cc.submitted_by ORDER BY COUNT(*) DESC LIMIT 10" GROUP BY cc.submitted_by ORDER BY COUNT(*) DESC LIMIT 10"
) { ) {
@@ -468,9 +483,9 @@ pub async fn list_sessions(
check_admin(&headers).await?; check_admin(&headers).await?;
let dbi = state.db.lock().await; let dbi = state.db.lock().await;
let mut stmt = dbi.db.prepare( let mut stmt = dbi.db.prepare(
"SELECT s.token, s.display_name, s.created_at, s.last_seen, "SELECT s.account_id, s.display_name, s.created_at, s.last_seen,
(SELECT COUNT(*) FROM solve_records sr WHERE sr.session_token = s.token AND sr.is_complete = 1), (SELECT COUNT(*) FROM solve_records sr WHERE sr.session_token = s.account_id AND sr.is_complete = 1),
(SELECT current_streak FROM streaks st WHERE st.session_token = s.token), (SELECT current_streak FROM streaks st WHERE st.session_token = s.account_id),
COALESCE(s.banned, 0) COALESCE(s.banned, 0)
FROM sessions s ORDER BY s.last_seen DESC LIMIT 100" FROM sessions s ORDER BY s.last_seen DESC LIMIT 100"
).unwrap(); ).unwrap();
@@ -495,7 +510,7 @@ pub async fn delete_session(
) -> Result<Json<serde_json::Value>, Response> { ) -> Result<Json<serde_json::Value>, Response> {
check_admin(&headers).await?; check_admin(&headers).await?;
let dbi = state.db.lock().await; let dbi = state.db.lock().await;
dbi.db.execute("DELETE FROM sessions WHERE token = ?1", [&token]).ok(); dbi.db.execute("DELETE FROM sessions WHERE account_id = ?1", [&token]).ok();
dbi.db.execute("DELETE FROM settings WHERE session_token = ?1", [&token]).ok(); dbi.db.execute("DELETE FROM settings WHERE session_token = ?1", [&token]).ok();
db::log_admin_action(&dbi.db, "delete_session", Some(&token), None); db::log_admin_action(&dbi.db, "delete_session", Some(&token), None);
invalidate_stats_cache(); invalidate_stats_cache();
@@ -1395,7 +1410,7 @@ pub async fn list_community_puzzles(
let dbi = state.db.lock().await; let dbi = state.db.lock().await;
let mut stmt = dbi.db.prepare( let mut stmt = dbi.db.prepare(
"SELECT up.id, up.title, s.display_name, up.width, up.height, up.created_at, up.updated_at "SELECT up.id, up.title, s.display_name, up.width, up.height, up.created_at, up.updated_at
FROM user_puzzles up LEFT JOIN sessions s ON up.session_token = s.token FROM user_puzzles up LEFT JOIN sessions s ON up.session_token = s.account_id
WHERE up.status = 'published' ORDER BY up.updated_at DESC LIMIT 200" WHERE up.status = 'published' ORDER BY up.updated_at DESC LIMIT 200"
).unwrap(); ).unwrap();
let puzzles: Vec<serde_json::Value> = stmt.query_map([], |row| { let puzzles: Vec<serde_json::Value> = stmt.query_map([], |row| {
+2 -2
View File
@@ -751,7 +751,7 @@ pub async fn v1_create_pack(
let id = Uuid::new_v4().to_string(); let id = Uuid::new_v4().to_string();
let dbi = state.db.lock().await; let dbi = state.db.lock().await;
let (_, author_name) = db::get_or_create_session(&dbi.db, &auth.session_token); let author_name = db::account_display_name(&dbi.db, &auth.session_token);
let ok = db::create_pack(&dbi.db, &id, let ok = db::create_pack(&dbi.db, &id,
&crate::security::sanitize_html(name), &crate::security::sanitize_html(name),
desc.map(|d| crate::security::sanitize_html(d)).as_deref(), desc.map(|d| crate::security::sanitize_html(d)).as_deref(),
@@ -774,7 +774,7 @@ pub async fn v1_me(
let dbi = state.db.lock().await; let dbi = state.db.lock().await;
let (current, best, last_date) = db::get_streak(&dbi.db, &auth.session_token); let (current, best, last_date) = db::get_streak(&dbi.db, &auth.session_token);
let has_passkey = db::has_passkey(&dbi.db, &auth.session_token); let has_passkey = db::has_passkey(&dbi.db, &auth.session_token);
let (_, name) = db::get_or_create_session(&dbi.db, &auth.session_token); let name = db::account_display_name(&dbi.db, &auth.session_token);
api_ok(serde_json::json!({ api_ok(serde_json::json!({
"display_name": name, "display_name": name,
+161 -80
View File
@@ -490,22 +490,73 @@ pub fn init_db(path: &Path) -> Connection {
db.execute("ALTER TABLE solve_records ADD COLUMN cell_results TEXT", []) db.execute("ALTER TABLE solve_records ADD COLUMN cell_results TEXT", [])
.ok(); .ok();
// additive identity-refactor scaffolding (safe, behavior-unchanged):
// stable accounts alongside session credentials, so existing installs
// upgrade automatically on startup.
db.execute_batch(
"CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY
);",
)
.unwrap();
let has_account_col: bool = db
.query_row(
"SELECT COUNT(*) FROM pragma_table_info('sessions') WHERE name = 'account_id'",
[],
|row| row.get::<_, i64>(0),
)
.unwrap_or(0)
> 0;
if !has_account_col {
db.execute("ALTER TABLE sessions ADD COLUMN account_id TEXT", []).ok();
}
db.execute_batch(
"UPDATE sessions SET account_id = token WHERE account_id IS NULL;",
)
.unwrap();
db.execute_batch(
"INSERT OR IGNORE INTO accounts (id) SELECT DISTINCT account_id FROM sessions WHERE account_id IS NOT NULL;",
)
.unwrap();
// one-time: hash legacy plaintext session tokens at rest. A session whose
// token still equals its account_id has not been hashed yet (after hashing
// they diverge), so this is idempotent across restarts and installs.
let legacy: Vec<String> = {
let mut stmt = db
.prepare("SELECT token FROM sessions WHERE token = account_id")
.unwrap();
let rows = stmt.query_map([], |row| row.get::<_, String>(0)).unwrap();
rows.filter_map(Result::ok).collect()
};
if !legacy.is_empty() {
if let Ok(tx) = db.unchecked_transaction() {
for t in legacy {
let _ = tx.execute(
"UPDATE sessions SET token = ?1 WHERE token = ?2",
params![token_hash(&t), &t],
);
}
let _ = tx.commit();
}
}
db db
} }
pub fn ban_session(db: &Connection, token: &str) { pub fn ban_session(db: &Connection, token: &str) {
db.execute("UPDATE sessions SET banned = 1 WHERE token = ?1", [token]) db.execute("UPDATE sessions SET banned = 1 WHERE account_id = ?1", [token])
.ok(); .ok();
} }
pub fn unban_session(db: &Connection, token: &str) { pub fn unban_session(db: &Connection, token: &str) {
db.execute("UPDATE sessions SET banned = 0 WHERE token = ?1", [token]) db.execute("UPDATE sessions SET banned = 0 WHERE account_id = ?1", [token])
.ok(); .ok();
} }
pub fn is_banned(db: &Connection, token: &str) -> bool { pub fn is_banned(db: &Connection, token: &str) -> bool {
db.query_row( db.query_row(
"SELECT banned FROM sessions WHERE token = ?1", "SELECT COUNT(*) FROM sessions WHERE account_id = ?1 AND banned = 1",
[token], [token],
|r| r.get::<_, i64>(0), |r| r.get::<_, i64>(0),
) )
@@ -557,7 +608,7 @@ pub fn cleanup_user_content(db: &Connection, token: &str) {
.ok(); .ok();
// clear display name so deleted/banned users can't be looked up // clear display name so deleted/banned users can't be looked up
db.execute( db.execute(
"UPDATE sessions SET display_name = NULL WHERE token = ?1", "UPDATE sessions SET display_name = NULL WHERE account_id = ?1",
[token], [token],
) )
.ok(); .ok();
@@ -845,30 +896,79 @@ pub fn get_puzzle_daily_info(db: &Connection, puzzle_id: &str) -> Option<(String
} }
// sessions // sessions
pub fn token_hash(token: &str) -> String {
use sha2::{Digest, Sha256};
hex::encode(Sha256::digest(token.as_bytes()))
}
fn new_account_id() -> String {
use rand::Rng;
let mut rng = rand::rng();
let bytes: Vec<u8> = (0..32).map(|_| rng.random::<u8>()).collect();
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
// Resolve a bearer token to its account id, creating a fresh account+session
// when unknown. Returns (account id, display name).
pub fn get_or_create_session(db: &Connection, token: &str) -> (String, Option<String>) { pub fn get_or_create_session(db: &Connection, token: &str) -> (String, Option<String>) {
let now = chrono::Utc::now().to_rfc3339(); let now = chrono::Utc::now().to_rfc3339();
let key = token_hash(token);
let existing = db.query_row( let existing = db.query_row(
"SELECT token, display_name FROM sessions WHERE token = ?1", "SELECT account_id, display_name FROM sessions WHERE token = ?1",
[token], [&key],
|row| Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?)), |row| Ok((row.get::<_, Option<String>>(0)?, row.get::<_, Option<String>>(1)?)),
); );
if let Ok((t, name)) = existing { if let Ok((account_id, name)) = existing {
let account_id = account_id.unwrap_or_else(|| key.clone());
db.execute( db.execute(
"UPDATE sessions SET last_seen = ?1 WHERE token = ?2", "UPDATE sessions SET last_seen = ?1 WHERE token = ?2",
params![now, token], params![now, &key],
) )
.ok(); .ok();
return (t, name); return (account_id, name);
} }
let account_id = new_account_id();
db.execute("INSERT OR IGNORE INTO accounts (id) VALUES (?1)", [&account_id]).ok();
db.execute( db.execute(
"INSERT INTO sessions (token, created_at, last_seen) VALUES (?1, ?2, ?2)", "INSERT INTO sessions (token, account_id, created_at, last_seen) VALUES (?1, ?2, ?3, ?3)",
params![token, now], params![&key, &account_id, now],
) )
.unwrap(); .unwrap();
(token.to_string(), None) (account_id, None)
}
// Mint a fresh session (new bearer) attached to an existing account, used by
// passkey login to resume an account on a new device.
pub fn create_session_for_account(db: &Connection, token: &str, account_id: &str) -> (String, Option<String>) {
let now = chrono::Utc::now().to_rfc3339();
let key = token_hash(token);
let name: Option<String> = db
.query_row(
"SELECT display_name FROM sessions WHERE account_id = ?1 ORDER BY last_seen DESC LIMIT 1",
[account_id],
|r| r.get(0),
)
.ok()
.flatten();
db.execute(
"INSERT INTO sessions (token, account_id, display_name, created_at, last_seen) VALUES (?1, ?2, ?3, ?4, ?4)",
params![&key, account_id, name, now],
)
.unwrap();
(account_id.to_string(), name)
}
pub fn account_display_name(db: &Connection, account_id: &str) -> Option<String> {
db.query_row(
"SELECT display_name FROM sessions WHERE account_id = ?1 ORDER BY last_seen DESC LIMIT 1",
[account_id],
|r| r.get(0),
)
.ok()
.flatten()
} }
pub fn get_user_agent_hash(db: &Connection, token: &str) -> Option<String> { pub fn get_user_agent_hash(db: &Connection, token: &str) -> Option<String> {
@@ -891,7 +991,7 @@ pub fn set_user_agent_hash(db: &Connection, token: &str, hash: &str) {
pub fn set_display_name(db: &Connection, token: &str, name: &str) { pub fn set_display_name(db: &Connection, token: &str, name: &str) {
db.execute( db.execute(
"UPDATE sessions SET display_name = ?1 WHERE token = ?2", "UPDATE sessions SET display_name = ?1 WHERE account_id = ?2",
params![name, token], params![name, token],
) )
.ok(); .ok();
@@ -1123,8 +1223,13 @@ pub fn get_community_clues(
} }
pub fn vote_clue(db: &Connection, token: &str, clue_id: i64, vote: i32) -> bool { pub fn vote_clue(db: &Connection, token: &str, clue_id: i64, vote: i32) -> bool {
let tx = match db.unchecked_transaction() {
Ok(tx) => tx,
Err(_) => return false,
};
// check if already voted // check if already voted
let existing: Option<i32> = db let existing: Option<i32> = tx
.query_row( .query_row(
"SELECT vote FROM clue_votes WHERE session_token = ?1 AND clue_id = ?2", "SELECT vote FROM clue_votes WHERE session_token = ?1 AND clue_id = ?2",
params![token, clue_id], params![token, clue_id],
@@ -1134,65 +1239,57 @@ pub fn vote_clue(db: &Connection, token: &str, clue_id: i64, vote: i32) -> bool
if let Some(old_vote) = existing { if let Some(old_vote) = existing {
if old_vote == vote { if old_vote == vote {
return false; return false; // same vote, no change; tx rolls back on drop
} // same vote, no change }
// undo old vote // undo old vote
if old_vote > 0 { if old_vote > 0 {
db.execute( let _ = tx.execute(
"UPDATE community_clues SET upvotes = upvotes - 1 WHERE id = ?1", "UPDATE community_clues SET upvotes = upvotes - 1 WHERE id = ?1",
[clue_id], [clue_id],
) );
.ok();
} else { } else {
db.execute( let _ = tx.execute(
"UPDATE community_clues SET downvotes = downvotes - 1 WHERE id = ?1", "UPDATE community_clues SET downvotes = downvotes - 1 WHERE id = ?1",
[clue_id], [clue_id],
) );
.ok();
} }
// apply new vote // apply new vote
if vote > 0 { if vote > 0 {
db.execute( let _ = tx.execute(
"UPDATE community_clues SET upvotes = upvotes + 1 WHERE id = ?1", "UPDATE community_clues SET upvotes = upvotes + 1 WHERE id = ?1",
[clue_id], [clue_id],
) );
.ok();
} else { } else {
db.execute( let _ = tx.execute(
"UPDATE community_clues SET downvotes = downvotes + 1 WHERE id = ?1", "UPDATE community_clues SET downvotes = downvotes + 1 WHERE id = ?1",
[clue_id], [clue_id],
) );
.ok();
} }
db.execute( let _ = tx.execute(
"UPDATE clue_votes SET vote = ?1 WHERE session_token = ?2 AND clue_id = ?3", "UPDATE clue_votes SET vote = ?1 WHERE session_token = ?2 AND clue_id = ?3",
params![vote, token, clue_id], params![vote, token, clue_id],
) );
.ok();
} else { } else {
// new vote // new vote
if vote > 0 { if vote > 0 {
db.execute( let _ = tx.execute(
"UPDATE community_clues SET upvotes = upvotes + 1 WHERE id = ?1", "UPDATE community_clues SET upvotes = upvotes + 1 WHERE id = ?1",
[clue_id], [clue_id],
) );
.ok();
} else { } else {
db.execute( let _ = tx.execute(
"UPDATE community_clues SET downvotes = downvotes + 1 WHERE id = ?1", "UPDATE community_clues SET downvotes = downvotes + 1 WHERE id = ?1",
[clue_id], [clue_id],
) );
.ok();
} }
db.execute( let _ = tx.execute(
"INSERT INTO clue_votes (session_token, clue_id, vote) VALUES (?1, ?2, ?3)", "INSERT INTO clue_votes (session_token, clue_id, vote) VALUES (?1, ?2, ?3)",
params![token, clue_id, vote], params![token, clue_id, vote],
) );
.ok();
} }
// auto-approve clues with net +3 votes, auto-reject at net -3 // auto-approve clues with net +3 votes, auto-reject at net -3
let (up, down): (i64, i64) = db let (up, down): (i64, i64) = tx
.query_row( .query_row(
"SELECT upvotes, downvotes FROM community_clues WHERE id = ?1", "SELECT upvotes, downvotes FROM community_clues WHERE id = ?1",
[clue_id], [clue_id],
@@ -1201,20 +1298,18 @@ pub fn vote_clue(db: &Connection, token: &str, clue_id: i64, vote: i32) -> bool
.unwrap_or((0, 0)); .unwrap_or((0, 0));
if up - down >= 3 { if up - down >= 3 {
db.execute( let _ = tx.execute(
"UPDATE community_clues SET status = 'approved' WHERE id = ?1 AND status = 'pending'", "UPDATE community_clues SET status = 'approved' WHERE id = ?1 AND status = 'pending'",
[clue_id], [clue_id],
) );
.ok();
} else if down - up >= 3 { } else if down - up >= 3 {
db.execute( let _ = tx.execute(
"UPDATE community_clues SET status = 'rejected' WHERE id = ?1 AND status = 'pending'", "UPDATE community_clues SET status = 'rejected' WHERE id = ?1 AND status = 'pending'",
[clue_id], [clue_id],
) );
.ok();
} }
true tx.commit().is_ok()
} }
pub fn get_user_votes(db: &Connection, token: &str, clue_ids: &[i64]) -> Vec<(i64, i32)> { pub fn get_user_votes(db: &Connection, token: &str, clue_ids: &[i64]) -> Vec<(i64, i32)> {
@@ -1538,7 +1633,7 @@ pub fn list_published_puzzles(
let mut stmt = db.prepare( let mut stmt = db.prepare(
"SELECT up.id, up.title, CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, up.width, up.height, up.updated_at, up.origin_domain "SELECT up.id, up.title, CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, up.width, up.height, up.updated_at, up.origin_domain
FROM user_puzzles up FROM user_puzzles up
LEFT JOIN sessions s ON up.session_token = s.token LEFT JOIN sessions s ON up.session_token = s.account_id
LEFT JOIN settings st ON up.session_token = st.session_token LEFT JOIN settings st ON up.session_token = st.session_token
WHERE up.status = 'published' AND up.clues_json IS NOT NULL WHERE up.status = 'published' AND up.clues_json IS NOT NULL
ORDER BY up.updated_at DESC LIMIT ?1 OFFSET ?2" ORDER BY up.updated_at DESC LIMIT ?1 OFFSET ?2"
@@ -2025,23 +2120,9 @@ pub fn import_user_data(
imported += 1; imported += 1;
} }
// streak (only if better than current) // Ignore imported streak values: a client-supplied streak is untrusted and
if let Some(streak) = data.get("streak") { // would let anyone inflate the streak leaderboard. Streaks are re-derived
let imp_best = streak.get("best").and_then(|v| v.as_i64()).unwrap_or(0); // from actual solves by update_streak.
let imp_current = streak.get("current").and_then(|v| v.as_i64()).unwrap_or(0);
let imp_last = streak
.get("last_date")
.and_then(|v| v.as_str())
.unwrap_or("");
let (cur_current, cur_best, _) = get_streak(db, token);
if imp_best > cur_best as i64 || imp_current > cur_current as i64 {
db.execute(
"INSERT OR REPLACE INTO streaks (session_token, current_streak, best_streak, last_solve_date) VALUES (?1, ?2, ?3, ?4)",
params![token, std::cmp::max(imp_current, cur_current as i64), std::cmp::max(imp_best, cur_best as i64), imp_last],
).ok();
imported += 1;
}
}
// achievements (merge - only add ones not already unlocked) // achievements (merge - only add ones not already unlocked)
if let Some(achievements) = data.get("achievements").and_then(|v| v.as_array()) { if let Some(achievements) = data.get("achievements").and_then(|v| v.as_array()) {
@@ -2199,7 +2280,7 @@ pub fn delete_account(db: &Connection, token: &str) {
[token], [token],
) )
.ok(); .ok();
db.execute("DELETE FROM sessions WHERE token = ?1", [token]) db.execute("DELETE FROM sessions WHERE account_id = ?1", [token])
.ok(); .ok();
} }
@@ -2207,7 +2288,7 @@ pub fn delete_account(db: &Connection, token: &str) {
pub fn export_user_data(db: &Connection, token: &str) -> serde_json::Value { pub fn export_user_data(db: &Connection, token: &str) -> serde_json::Value {
let session: serde_json::Value = db let session: serde_json::Value = db
.query_row( .query_row(
"SELECT display_name, created_at, last_seen FROM sessions WHERE token = ?1", "SELECT display_name, created_at, last_seen FROM sessions WHERE account_id = ?1",
[token], [token],
|row| { |row| {
Ok(serde_json::json!({ Ok(serde_json::json!({
@@ -2295,7 +2376,7 @@ pub fn get_leaderboard_filtered(
let mut stmt = db.prepare( let mut stmt = db.prepare(
"SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, sr.solve_time_secs, sr.hints_used "SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, sr.solve_time_secs, sr.hints_used
FROM solve_records sr FROM solve_records sr
LEFT JOIN sessions s ON sr.session_token = s.token LEFT JOIN sessions s ON sr.session_token = s.account_id
LEFT JOIN settings st ON sr.session_token = st.session_token LEFT JOIN settings st ON sr.session_token = st.session_token
WHERE sr.puzzle_id = ?1 AND sr.is_complete = 1 AND sr.flagged = 0 WHERE sr.puzzle_id = ?1 AND sr.is_complete = 1 AND sr.flagged = 0
AND (st.settings_json IS NULL OR json_extract(st.settings_json, '$.hideFromLeaderboards') IS NOT 1) AND (st.settings_json IS NULL OR json_extract(st.settings_json, '$.hideFromLeaderboards') IS NOT 1)
@@ -2325,7 +2406,7 @@ pub fn get_daily_leaderboard_filtered(
let mut stmt = db.prepare( let mut stmt = db.prepare(
"SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, sr.solve_time_secs, sr.hints_used "SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, sr.solve_time_secs, sr.hints_used
FROM solve_records sr FROM solve_records sr
LEFT JOIN sessions s ON sr.session_token = s.token LEFT JOIN sessions s ON sr.session_token = s.account_id
LEFT JOIN puzzles p ON sr.puzzle_id = p.id LEFT JOIN puzzles p ON sr.puzzle_id = p.id
LEFT JOIN settings st ON sr.session_token = st.session_token LEFT JOIN settings st ON sr.session_token = st.session_token
WHERE p.daily_date = ?1 AND p.preset = ?2 AND sr.is_complete = 1 AND sr.flagged = 0 WHERE p.daily_date = ?1 AND p.preset = ?2 AND sr.is_complete = 1 AND sr.flagged = 0
@@ -2405,8 +2486,8 @@ pub fn cleanup_ephemeral_sessions(db: &Connection) -> i64 {
let sessions: Vec<(String, String, String)> = { let sessions: Vec<(String, String, String)> = {
let mut stmt = db let mut stmt = db
.prepare( .prepare(
"SELECT s.token, st.settings_json, s.last_seen FROM sessions s "SELECT s.account_id, st.settings_json, s.last_seen FROM sessions s
JOIN settings st ON st.session_token = s.token", JOIN settings st ON st.session_token = s.account_id",
) )
.unwrap(); .unwrap();
stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))) stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
@@ -2738,7 +2819,7 @@ pub fn get_alltime_leaderboard(
"SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, MIN(sr.solve_time_secs) as best_time, sr.hints_used, "SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, MIN(sr.solve_time_secs) as best_time, sr.hints_used,
p.daily_date p.daily_date
FROM solve_records sr FROM solve_records sr
LEFT JOIN sessions s ON sr.session_token = s.token LEFT JOIN sessions s ON sr.session_token = s.account_id
LEFT JOIN puzzles p ON sr.puzzle_id = p.id LEFT JOIN puzzles p ON sr.puzzle_id = p.id
LEFT JOIN settings st ON sr.session_token = st.session_token LEFT JOIN settings st ON sr.session_token = st.session_token
WHERE p.preset = ?1 AND sr.is_complete = 1 AND sr.flagged = 0 WHERE p.preset = ?1 AND sr.is_complete = 1 AND sr.flagged = 0
@@ -2771,7 +2852,7 @@ pub fn get_weekly_leaderboard(
let mut stmt = db.prepare( let mut stmt = db.prepare(
"SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, MIN(sr.solve_time_secs) as best_time, sr.hints_used "SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, MIN(sr.solve_time_secs) as best_time, sr.hints_used
FROM solve_records sr FROM solve_records sr
LEFT JOIN sessions s ON sr.session_token = s.token LEFT JOIN sessions s ON sr.session_token = s.account_id
LEFT JOIN puzzles p ON sr.puzzle_id = p.id LEFT JOIN puzzles p ON sr.puzzle_id = p.id
LEFT JOIN settings st ON sr.session_token = st.session_token LEFT JOIN settings st ON sr.session_token = st.session_token
WHERE p.preset = ?1 AND sr.is_complete = 1 AND sr.flagged = 0 WHERE p.preset = ?1 AND sr.is_complete = 1 AND sr.flagged = 0
@@ -2800,7 +2881,7 @@ pub fn get_streak_leaderboard(db: &Connection, limit: usize) -> Vec<(String, i64
let mut stmt = db.prepare( let mut stmt = db.prepare(
"SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, st2.current_streak, st2.best_streak "SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, st2.current_streak, st2.best_streak
FROM streaks st2 FROM streaks st2
LEFT JOIN sessions s ON st2.session_token = s.token LEFT JOIN sessions s ON st2.session_token = s.account_id
LEFT JOIN settings st ON st2.session_token = st.session_token LEFT JOIN settings st ON st2.session_token = st.session_token
WHERE st2.current_streak > 0 WHERE st2.current_streak > 0
AND (st.settings_json IS NULL OR json_extract(st.settings_json, '$.hideFromLeaderboards') IS NOT 1) AND (st.settings_json IS NULL OR json_extract(st.settings_json, '$.hideFromLeaderboards') IS NOT 1)
@@ -2826,7 +2907,7 @@ pub fn get_completionist_leaderboard(db: &Connection, limit: usize) -> Vec<(Stri
let mut stmt = db.prepare( let mut stmt = db.prepare(
"SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, COUNT(*) as total "SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, COUNT(*) as total
FROM solve_records sr FROM solve_records sr
LEFT JOIN sessions s ON sr.session_token = s.token LEFT JOIN sessions s ON sr.session_token = s.account_id
LEFT JOIN settings st ON sr.session_token = st.session_token LEFT JOIN settings st ON sr.session_token = st.session_token
WHERE sr.is_complete = 1 AND sr.flagged = 0 WHERE sr.is_complete = 1 AND sr.flagged = 0
AND (st.settings_json IS NULL OR json_extract(st.settings_json, '$.hideFromLeaderboards') IS NOT 1) AND (st.settings_json IS NULL OR json_extract(st.settings_json, '$.hideFromLeaderboards') IS NOT 1)
@@ -2857,7 +2938,7 @@ pub fn get_clean_leaderboard(
let mut stmt = db.prepare( let mut stmt = db.prepare(
"SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, sr.solve_time_secs "SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, sr.solve_time_secs
FROM solve_records sr FROM solve_records sr
LEFT JOIN sessions s ON sr.session_token = s.token LEFT JOIN sessions s ON sr.session_token = s.account_id
LEFT JOIN puzzles p ON sr.puzzle_id = p.id LEFT JOIN puzzles p ON sr.puzzle_id = p.id
LEFT JOIN settings st ON sr.session_token = st.session_token LEFT JOIN settings st ON sr.session_token = st.session_token
WHERE p.daily_date = ?1 AND p.preset = ?2 WHERE p.daily_date = ?1 AND p.preset = ?2
@@ -3505,7 +3586,7 @@ pub fn get_daily_leaderboard_by_date(
let sql = if clean_only { let sql = if clean_only {
"SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, sr.solve_time_secs, sr.hints_used "SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, sr.solve_time_secs, sr.hints_used
FROM solve_records sr FROM solve_records sr
LEFT JOIN sessions s ON sr.session_token = s.token LEFT JOIN sessions s ON sr.session_token = s.account_id
LEFT JOIN puzzles p ON sr.puzzle_id = p.id LEFT JOIN puzzles p ON sr.puzzle_id = p.id
LEFT JOIN settings st ON sr.session_token = st.session_token LEFT JOIN settings st ON sr.session_token = st.session_token
WHERE p.daily_date = ?1 AND p.preset = ?2 AND sr.is_complete = 1 AND sr.flagged = 0 WHERE p.daily_date = ?1 AND p.preset = ?2 AND sr.is_complete = 1 AND sr.flagged = 0
@@ -3516,7 +3597,7 @@ pub fn get_daily_leaderboard_by_date(
} else { } else {
"SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, sr.solve_time_secs, sr.hints_used "SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, sr.solve_time_secs, sr.hints_used
FROM solve_records sr FROM solve_records sr
LEFT JOIN sessions s ON sr.session_token = s.token LEFT JOIN sessions s ON sr.session_token = s.account_id
LEFT JOIN puzzles p ON sr.puzzle_id = p.id LEFT JOIN puzzles p ON sr.puzzle_id = p.id
LEFT JOIN settings st ON sr.session_token = st.session_token LEFT JOIN settings st ON sr.session_token = st.session_token
WHERE p.daily_date = ?1 AND p.preset = ?2 AND sr.is_complete = 1 AND sr.flagged = 0 WHERE p.daily_date = ?1 AND p.preset = ?2 AND sr.is_complete = 1 AND sr.flagged = 0
+2 -2
View File
@@ -540,8 +540,8 @@ async fn main() {
.allow_methods([axum::http::Method::GET, axum::http::Method::POST, axum::http::Method::PUT, axum::http::Method::DELETE]) .allow_methods([axum::http::Method::GET, axum::http::Method::POST, axum::http::Method::PUT, axum::http::Method::DELETE])
.allow_headers([axum::http::header::CONTENT_TYPE, axum::http::header::HeaderName::from_static("x-admin-session")]) .allow_headers([axum::http::header::CONTENT_TYPE, axum::http::header::HeaderName::from_static("x-admin-session")])
} else { } else {
tracing::warn!("CORS_ORIGIN not set - using permissive CORS (set CORS_ORIGIN for production)"); tracing::warn!("CORS_ORIGIN not set - allowing same-origin requests only (set CORS_ORIGIN to allow cross-origin)");
CorsLayer::permissive() CorsLayer::new()
}; };
let app = app let app = app
+26 -1
View File
@@ -961,7 +961,7 @@ pub async fn create_room(
RoomMode::Party => 20, RoomMode::Party => 20,
}; };
let (tx, _) = broadcast::channel(256); let (tx, _) = broadcast::channel(1024);
let grid = vec![vec![String::new(); width]; height]; let grid = vec![vec![String::new(); width]; height];
let party_sub_str = req.get("party_sub").and_then(|v| v.as_str()).unwrap_or("wrong"); let party_sub_str = req.get("party_sub").and_then(|v| v.as_str()).unwrap_or("wrong");
@@ -1374,6 +1374,31 @@ async fn handle_ws(
break; break;
} }
} }
Err(broadcast::error::RecvError::Lagged(_)) => {
// A slow consumer fell behind: re-send full state so the
// client resyncs instead of being silently dropped or
// disconnected.
let rooms = state.rooms.read().await;
if let Some(room) = rooms.get(&code) {
let resync = serde_json::to_string(&WsMessage::FullState {
grid: room.grid.clone(),
players: room.players.values().map(|p| PlayerInfo {
id: p.id.clone(),
name: p.name.clone(),
color: p.color.clone(),
cursor: p.cursor,
direction: p.direction.clone(),
order: p.order,
}).collect(),
current_turn: room.current_player_id().map(|s| s.to_string()),
started: room.started,
your_id: token.clone(),
}).unwrap();
if send_text(&mut socket, resync).await.is_err() {
break;
}
}
}
Err(_) => break, Err(_) => break,
} }
} }
+11 -6
View File
@@ -2701,17 +2701,22 @@ pub async fn passkey_auth_finish(
} }
} }
// create a session with the original token // mint a fresh session attached to the account this passkey belongs to
let dbi = state.db.lock().await; let dbi = state.db.lock().await;
let (token, name) = db::get_or_create_session(&dbi.db, &session_token); let new_plain = crate::session::generate_token();
let settings_json = db::get_settings(&dbi.db, &token); let (account_id, name) = db::create_session_for_account(&dbi.db, &new_plain, &session_token);
let settings_json = db::get_settings(&dbi.db, &account_id);
let cookie_max_age = serde_json::from_str::<serde_json::Value>(&settings_json) let cookie_max_age = serde_json::from_str::<serde_json::Value>(&settings_json)
.ok() .ok()
.and_then(|v| v.get("cookieLifetime")?.as_u64()) .and_then(|v| v.get("cookieLifetime")?.as_u64())
.unwrap_or(2_592_000); .unwrap_or(2_592_000);
let session = Session { let session = Session {
token, display_name: name, is_new: false, cookie_max_age, token: account_id,
cookie_token: new_plain,
display_name: name,
is_new: true,
cookie_max_age,
}; };
Ok(with_session_cookie(&session, Json(serde_json::json!({ Ok(with_session_cookie(&session, Json(serde_json::json!({
@@ -2740,7 +2745,7 @@ pub async fn get_session_info(
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
let dbi = state.db.lock().await; let dbi = state.db.lock().await;
let info = dbi.db.query_row( let info = dbi.db.query_row(
"SELECT created_at, last_seen FROM sessions WHERE token = ?1", "SELECT created_at, last_seen FROM sessions WHERE account_id = ?1",
[&session.token], [&session.token],
|row| Ok(serde_json::json!({ |row| Ok(serde_json::json!({
"created_at": row.get::<_, String>(0)?, "created_at": row.get::<_, String>(0)?,
@@ -3446,7 +3451,7 @@ pub async fn create_pack(
let id = Uuid::new_v4().to_string(); let id = Uuid::new_v4().to_string();
let dbi = state.db.lock().await; let dbi = state.db.lock().await;
let (_, author_name) = db::get_or_create_session(&dbi.db, &session.token); let author_name = db::account_display_name(&dbi.db, &session.token);
let success = db::create_pack( let success = db::create_pack(
&dbi.db, &id, &dbi.db, &id,
&crate::security::sanitize_html(name), &crate::security::sanitize_html(name),
+44 -2
View File
@@ -113,7 +113,7 @@ pub fn is_internal_url(url: &str) -> bool {
if host.contains('@') { return true; } if host.contains('@') { return true; }
// loopback // loopback
host == "localhost" let blocked = host == "localhost"
|| host.starts_with("127.") // full 127.0.0.0/8 range || host.starts_with("127.") // full 127.0.0.0/8 range
|| host == "::1" || host == "::1"
|| host.starts_with("::ffff:127.") // IPv4-mapped IPv6 loopback || host.starts_with("::ffff:127.") // IPv4-mapped IPv6 loopback
@@ -146,7 +146,8 @@ pub fn is_internal_url(url: &str) -> bool {
// cloud metadata endpoints // cloud metadata endpoints
|| host == "169.254.169.254" || host == "169.254.169.254"
|| host == "metadata.google.internal" || host == "metadata.google.internal"
|| host == "100.100.100.200" // Alibaba cloud metadata || host == "100.100.100.200"; // Alibaba cloud metadata
blocked || resolves_to_internal_ip(host)
} }
fn is_172_private(host: &str) -> bool { fn is_172_private(host: &str) -> bool {
@@ -163,6 +164,47 @@ fn is_cgnat(host: &str) -> bool {
(64..=127).contains(&second) (64..=127).contains(&second)
} }
fn is_internal_ip(ip: &std::net::IpAddr) -> bool {
match ip {
std::net::IpAddr::V4(v4) => {
let o = v4.octets();
o[0] == 0
|| o[0] == 10
|| o[0] == 127
|| (o[0] == 169 && o[1] == 254)
|| (o[0] == 172 && (16..=31).contains(&o[1]))
|| (o[0] == 192 && o[1] == 168)
|| (o[0] == 100 && (64..=127).contains(&o[1]))
|| (o[0] == 198 && (o[1] == 18 || o[1] == 19))
|| o[0] >= 224
}
std::net::IpAddr::V6(v6) => {
v6.is_loopback()
|| v6.is_unspecified()
|| v6.is_multicast()
|| (v6.segments()[0] & 0xfe00 == 0xfc00)
|| (v6.segments()[0] & 0xffc0 == 0xfe80)
}
}
}
fn resolves_to_internal_ip(host: &str) -> bool {
let host = host.strip_prefix('[').and_then(|h| h.strip_suffix(']')).unwrap_or(host);
if host.is_empty() || host.parse::<std::net::IpAddr>().is_ok() {
return false;
}
use std::net::ToSocketAddrs;
let Ok(addrs) = (host, 0_u16).to_socket_addrs() else {
return false;
};
for addr in addrs {
if is_internal_ip(&addr.ip()) {
return true;
}
}
false
}
pub fn sanitize_html(input: &str) -> String { pub fn sanitize_html(input: &str) -> String {
input input
.replace('&', "&amp;") .replace('&', "&amp;")
+29 -18
View File
@@ -9,6 +9,7 @@ use crate::state::AppState;
pub struct Session { pub struct Session {
pub token: String, pub token: String,
pub cookie_token: String,
pub display_name: Option<String>, pub display_name: Option<String>,
pub is_new: bool, pub is_new: bool,
pub cookie_max_age: u64, pub cookie_max_age: u64,
@@ -16,16 +17,16 @@ pub struct Session {
impl Session { impl Session {
pub fn cookie_header(&self) -> String { pub fn cookie_header(&self) -> String {
let secure = std::env::var("SECURE_COOKIES").as_deref() == Ok("true"); let secure = std::env::var("SECURE_COOKIES").as_deref() != Ok("false");
let same_site = if secure { "Strict" } else { "Lax" }; let same_site = if secure { "Strict" } else { "Lax" };
let secure_flag = if secure { "; Secure" } else { "" }; let secure_flag = if secure { "; Secure" } else { "" };
if self.cookie_max_age == 0 { if self.cookie_max_age == 0 {
format!("cruciverb_session={}; Path=/; HttpOnly; SameSite={}{}", self.token, same_site, secure_flag) format!("cruciverb_session={}; Path=/; HttpOnly; SameSite={}{}", self.cookie_token, same_site, secure_flag)
} else { } else {
format!( format!(
"cruciverb_session={}; Path=/; HttpOnly; SameSite={}; Max-Age={}{}", "cruciverb_session={}; Path=/; HttpOnly; SameSite={}; Max-Age={}{}",
self.token, same_site, self.cookie_max_age, secure_flag self.cookie_token, same_site, self.cookie_max_age, secure_flag
) )
} }
} }
@@ -38,7 +39,7 @@ impl FromRequestParts<Arc<AppState>> for Session {
parts: &mut Parts, parts: &mut Parts,
state: &Arc<AppState>, state: &Arc<AppState>,
) -> Result<Self, Self::Rejection> { ) -> Result<Self, Self::Rejection> {
let token = parts let cookie_token = parts
.headers .headers
.get(header::COOKIE) .get(header::COOKIE)
.and_then(|v| v.to_str().ok()) .and_then(|v| v.to_str().ok())
@@ -57,15 +58,15 @@ impl FromRequestParts<Arc<AppState>> for Session {
.unwrap_or_default(); .unwrap_or_default();
let dbi = state.db.lock().await; let dbi = state.db.lock().await;
let (mut token, mut name, mut is_new) = match token { let (mut cookie_token, mut token, mut name, mut is_new) = match cookie_token {
Some(t) if !t.is_empty() => { Some(t) if is_valid_token(&t) => {
let (token, name) = crate::db::get_or_create_session(&dbi.db, &t); let (acct, name) = crate::db::get_or_create_session(&dbi.db, &t);
(token, name, false) (t, acct, name, false)
} }
_ => { _ => {
let new_token = generate_token(); let new_token = generate_token();
let (token, name) = crate::db::get_or_create_session(&dbi.db, &new_token); let (acct, name) = crate::db::get_or_create_session(&dbi.db, &new_token);
(token, name, true) (new_token, acct, name, true)
} }
}; };
@@ -82,14 +83,20 @@ impl FromRequestParts<Arc<AppState>> for Session {
.unwrap_or(false) .unwrap_or(false)
&& crate::db::has_passkey(&dbi.db, &token); && crate::db::has_passkey(&dbi.db, &token);
if binding_enabled && !user_agent_hash.is_empty() { if binding_enabled && !user_agent_hash.is_empty() {
let stored = crate::db::get_user_agent_hash(&dbi.db, &token); let key = crate::db::token_hash(&cookie_token);
let stored = crate::db::get_user_agent_hash(&dbi.db, &key);
match stored { match stored {
None => crate::db::set_user_agent_hash(&dbi.db, &token, &user_agent_hash), None => crate::db::set_user_agent_hash(&dbi.db, &key, &user_agent_hash),
Some(s) if s != user_agent_hash => { Some(s) if s != user_agent_hash => {
let new_token = generate_token(); let new_plain = generate_token();
let (t2, n2) = crate::db::get_or_create_session(&dbi.db, &new_token); let (a2, n2) = crate::db::get_or_create_session(&dbi.db, &new_plain);
crate::db::set_user_agent_hash(&dbi.db, &t2, &user_agent_hash); crate::db::set_user_agent_hash(
token = t2; &dbi.db,
&crate::db::token_hash(&new_plain),
&user_agent_hash,
);
token = a2;
cookie_token = new_plain;
name = n2; name = n2;
is_new = true; is_new = true;
settings_json = crate::db::get_settings(&dbi.db, &token); settings_json = crate::db::get_settings(&dbi.db, &token);
@@ -105,11 +112,15 @@ impl FromRequestParts<Arc<AppState>> for Session {
.and_then(|v| v.get("cookieLifetime")?.as_u64()) .and_then(|v| v.get("cookieLifetime")?.as_u64())
.unwrap_or(2_592_000); .unwrap_or(2_592_000);
Ok(Session { token, display_name: name, is_new, cookie_max_age }) Ok(Session { token, cookie_token, display_name: name, is_new, cookie_max_age })
} }
} }
fn generate_token() -> String { fn is_valid_token(t: &str) -> bool {
t.len() == 64 && t.chars().all(|c| c.is_ascii_hexdigit())
}
pub(crate) fn generate_token() -> String {
let mut rng = rand::rng(); let mut rng = rand::rng();
let bytes: Vec<u8> = (0..32).map(|_| rng.random::<u8>()).collect(); let bytes: Vec<u8> = (0..32).map(|_| rng.random::<u8>()).collect();
bytes.iter().map(|b| format!("{:02x}", b)).collect() bytes.iter().map(|b| format!("{:02x}", b)).collect()
+8 -1
View File
@@ -131,7 +131,7 @@ pub fn create_registration_challenge(
authenticator_attachment: None, authenticator_attachment: None,
resident_key: "preferred".into(), resident_key: "preferred".into(),
require_resident_key: false, require_resident_key: false,
user_verification: "preferred".into(), user_verification: "required".into(),
}, },
attestation: "none".into(), attestation: "none".into(),
} }
@@ -204,6 +204,10 @@ pub fn verify_registration(
if flags & 0x01 == 0 { if flags & 0x01 == 0 {
return Err("user not present".into()); return Err("user not present".into());
} }
// UV flag (bit 2): user verification (PIN/biometric) is required
if flags & 0x04 == 0 {
return Err("user verification required".into());
}
if flags & 0x40 == 0 { if flags & 0x40 == 0 {
return Err("no attested credential data".into()); return Err("no attested credential data".into());
} }
@@ -301,6 +305,9 @@ pub fn verify_authentication(
if auth_data[32] & 0x01 == 0 { if auth_data[32] & 0x01 == 0 {
return Err("user not present".into()); return Err("user not present".into());
} }
if auth_data[32] & 0x04 == 0 {
return Err("user verification required".into());
}
let counter = u32::from_be_bytes([auth_data[33], auth_data[34], auth_data[35], auth_data[36]]); let counter = u32::from_be_bytes([auth_data[33], auth_data[34], auth_data[35], auth_data[36]]);
+2
View File
@@ -4,6 +4,7 @@ import { motion, AnimatePresence, MotionConfig } from 'motion/react'
import { IconShare2 } from '@tabler/icons-react' import { IconShare2 } from '@tabler/icons-react'
import Grid from './components/Grid' import Grid from './components/Grid'
import CluePanel from './components/CluePanel' import CluePanel from './components/CluePanel'
import ClueNav from './components/ClueNav'
import ActiveClue from './components/ActiveClue' import ActiveClue from './components/ActiveClue'
import Header from './components/Header' import Header from './components/Header'
import InfoBar from './components/InfoBar' import InfoBar from './components/InfoBar'
@@ -309,6 +310,7 @@ export default function App() {
<div className="grid-column"> <div className="grid-column">
<h2 className="visually-hidden">{t('app.crosswordGrid')}</h2> <h2 className="visually-hidden">{t('app.crosswordGrid')}</h2>
<Grid /> <Grid />
<ClueNav />
<div className="game-input-mode" role="group" aria-label={t('settings.display.inputMode')}> <div className="game-input-mode" role="group" aria-label={t('settings.display.inputMode')}>
{(['auto', 'keyboard', 'stylus'] as const).map(m => ( {(['auto', 'keyboard', 'stylus'] as const).map(m => (
<button key={m} type="button" <button key={m} type="button"
@@ -106,7 +106,7 @@ export default function AchievementsPanel({ onClose }: { onClose: () => void })
<div <div
key={a.id} key={a.id}
className={`ach-badge ${a.unlocked ? 'ach-unlocked' : 'ach-locked'}`} className={`ach-badge ${a.unlocked ? 'ach-unlocked' : 'ach-locked'}`}
title={`${a.name}: ${a.description}`} aria-label={`${a.name}: ${a.unlocked ? 'Unlocked' : 'Locked'}. ${a.description}`}
> >
<div <div
className="ach-icon" className="ach-icon"
@@ -114,7 +114,7 @@ export default function AchievementsPanel({ onClose }: { onClose: () => void })
background: `linear-gradient(135deg, ${a.color1}, ${a.color2})`, background: `linear-gradient(135deg, ${a.color1}, ${a.color2})`,
} : undefined} } : undefined}
> >
<Icon size={20} /> <Icon size={20} aria-hidden="true" />
</div> </div>
<div className="ach-info"> <div className="ach-info">
<div className="ach-name">{a.name}</div> <div className="ach-name">{a.name}</div>
+36 -31
View File
@@ -249,7 +249,8 @@ function Dialog({ title, children, onClose, actions }: {
}) { }) {
return ( return (
<div className="settings-overlay" onClick={onClose}> <div className="settings-overlay" onClick={onClose}>
<div className="admin-dialog" onClick={e => e.stopPropagation()} role="dialog" aria-modal="true" aria-label={title}> <div className="admin-dialog" onClick={e => e.stopPropagation()} role="dialog" aria-modal="true" aria-label={title}
onKeyDown={e => { if (e.key === 'Escape') onClose() }}>
<div className="admin-dialog-header"> <div className="admin-dialog-header">
<h3>{title}</h3> <h3>{title}</h3>
<button className="btn btn-sm btn-ghost" onClick={onClose} aria-label="Close"><IconX size={14} aria-hidden="true" /></button> <button className="btn btn-sm btn-ghost" onClick={onClose} aria-label="Close"><IconX size={14} aria-hidden="true" /></button>
@@ -729,18 +730,18 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
<div className="admin-login-form"> <div className="admin-login-form">
<h3 style={{ margin: '0 0 4px', fontSize: '0.85rem' }}>{t('admin.setup.title')}</h3> <h3 style={{ margin: '0 0 4px', fontSize: '0.85rem' }}>{t('admin.setup.title')}</h3>
<p style={{ margin: '0 0 12px', fontSize: '0.7rem', opacity: 0.7 }}>{t('admin.setup.desc')}</p> <p style={{ margin: '0 0 12px', fontSize: '0.7rem', opacity: 0.7 }}>{t('admin.setup.desc')}</p>
<input type="text" value={username} onChange={e => setUsername(e.target.value)} placeholder={t('admin.username')} className="admin-input" autoComplete="username" /> <input type="text" value={username} onChange={e => setUsername(e.target.value)} placeholder={t('admin.username')} aria-label={t('admin.username')} className="admin-input" autoComplete="username" />
<input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder={t('admin.password')} className="admin-input" autoComplete="new-password" /> <input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder={t('admin.password')} aria-label={t('admin.password')} className="admin-input" autoComplete="new-password" />
<input type="password" value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSetup()} placeholder={t('admin.setup.confirmPassword')} className="admin-input" autoComplete="new-password" /> <input type="password" value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSetup()} placeholder={t('admin.setup.confirmPassword')} aria-label={t('admin.setup.confirmPassword')} className="admin-input" autoComplete="new-password" />
{setupError && <p className="admin-login-error">{setupError}</p>} {setupError && <p className="admin-login-error" role="alert">{setupError}</p>}
<button className="btn btn-primary" onClick={handleSetup} style={{ width: '100%' }}>{t('admin.setup.createAccount')}</button> <button className="btn btn-primary" onClick={handleSetup} style={{ width: '100%' }}>{t('admin.setup.createAccount')}</button>
</div> </div>
) : ( ) : (
<div className="admin-login-form"> <div className="admin-login-form">
{setupSuccess && <p style={{ color: 'var(--color-correct)', fontSize: '0.7rem', margin: '0 0 8px' }}>{setupSuccess}</p>} {setupSuccess && <p style={{ color: 'var(--color-correct)', fontSize: '0.7rem', margin: '0 0 8px' }} role="status">{setupSuccess}</p>}
<input type="text" value={username} onChange={e => setUsername(e.target.value)} onKeyDown={e => e.key === 'Enter' && login()} placeholder={t('admin.username')} className="admin-input" autoComplete="username" /> <input type="text" value={username} onChange={e => setUsername(e.target.value)} onKeyDown={e => e.key === 'Enter' && login()} placeholder={t('admin.username')} aria-label={t('admin.username')} className="admin-input" autoComplete="username" />
<input type="password" value={password} onChange={e => setPassword(e.target.value)} onKeyDown={e => e.key === 'Enter' && login()} placeholder={t('admin.password')} className="admin-input" autoComplete="current-password" /> <input type="password" value={password} onChange={e => setPassword(e.target.value)} onKeyDown={e => e.key === 'Enter' && login()} placeholder={t('admin.password')} aria-label={t('admin.password')} className="admin-input" autoComplete="current-password" />
{loginError && <p className="admin-login-error">{loginError}</p>} {loginError && <p className="admin-login-error" role="alert">{loginError}</p>}
<button className="btn btn-primary" onClick={login} style={{ width: '100%' }}>{t('common.signIn')}</button> <button className="btn btn-primary" onClick={login} style={{ width: '100%' }}>{t('common.signIn')}</button>
</div> </div>
)} )}
@@ -772,12 +773,12 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
</div> </div>
</div> </div>
{notification && <div className="admin-notification">{notification}</div>} {notification && <div className="admin-notification" role="status" aria-live="polite">{notification}</div>}
<div className="admin-body"> <div className="admin-body">
<nav className="admin-nav" aria-label="Admin sections"> <nav className="admin-nav" aria-label="Admin sections" role="tablist">
{tabs.map(([key, label, Icon]) => ( {tabs.map(([key, label, Icon]) => (
<button key={key} className={`admin-nav-item ${tab === key ? 'admin-nav-active' : ''}`} onClick={() => setTab(key)}> <button key={key} role="tab" aria-selected={tab === key} aria-current={tab === key ? 'page' : undefined} className={`admin-nav-item ${tab === key ? 'admin-nav-active' : ''}`} onClick={() => setTab(key)}>
<Icon size={14} /> {label} <Icon size={14} /> {label}
{key === 'content' && stats && (stats.community_clues?.pending > 0 || stats.pending_reports > 0) && ( {key === 'content' && stats && (stats.community_clues?.pending > 0 || stats.pending_reports > 0) && (
<span className="admin-badge">{(stats.community_clues?.pending || 0) + (stats.pending_reports || 0)}</span> <span className="admin-badge">{(stats.community_clues?.pending || 0) + (stats.pending_reports || 0)}</span>
@@ -843,7 +844,7 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
<div className="admin-toolbar"> <div className="admin-toolbar">
<div style={{ display: 'flex', gap: 4 }}> <div style={{ display: 'flex', gap: 4 }}>
{(['all', 'daily', 'custom'] as const).map(f => ( {(['all', 'daily', 'custom'] as const).map(f => (
<button key={f} className={`btn btn-sm ${puzzleFilter === f ? 'btn-primary' : ''}`} onClick={() => setPuzzleFilter(f)}> <button key={f} className={`btn btn-sm ${puzzleFilter === f ? 'btn-primary' : ''}`} aria-pressed={puzzleFilter === f} onClick={() => setPuzzleFilter(f)}>
{t(`admin.content.filter_${f}`)} {t(`admin.content.filter_${f}`)}
</button> </button>
))} ))}
@@ -854,7 +855,7 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
</button> </button>
)} )}
</div> </div>
<table className="admin-table"><thead><tr><th style={{ width: 24 }}><input type="checkbox" checked={puzzles.length > 0 && selectedPuzzles.size === puzzles.length} onChange={e => { if (e.target.checked) setSelectedPuzzles(new Set(puzzles.map(p => p.id))); else setSelectedPuzzles(new Set()) }} /></th><th>{t('admin.content.id')}</th><th>{t('admin.content.type')}</th><th>{t('admin.content.size')}</th><th>{t('admin.overview.solves')}</th><th>{t('admin.content.rating')}</th><th>{t('admin.content.created')}</th><th></th></tr></thead><tbody> <table className="admin-table"><thead><tr><th style={{ width: 24 }}><input type="checkbox" aria-label="Select all" checked={puzzles.length > 0 && selectedPuzzles.size === puzzles.length} onChange={e => { if (e.target.checked) setSelectedPuzzles(new Set(puzzles.map(p => p.id))); else setSelectedPuzzles(new Set()) }} /></th><th>{t('admin.content.id')}</th><th>{t('admin.content.type')}</th><th>{t('admin.content.size')}</th><th>{t('admin.overview.solves')}</th><th>{t('admin.content.rating')}</th><th>{t('admin.content.created')}</th><th></th></tr></thead><tbody>
{puzzles.map(p => ( {puzzles.map(p => (
<tr key={p.id}> <tr key={p.id}>
<td><input type="checkbox" checked={selectedPuzzles.has(p.id)} onChange={() => togglePuzzleSelect(p.id)} /></td> <td><input type="checkbox" checked={selectedPuzzles.has(p.id)} onChange={() => togglePuzzleSelect(p.id)} /></td>
@@ -864,7 +865,7 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
<td>{p.solves}</td> <td>{p.solves}</td>
<td>{p.rating > 0 ? `${p.rating}/5` : '-'}</td> <td>{p.rating > 0 ? `${p.rating}/5` : '-'}</td>
<td className="admin-mono">{new Date(p.created_at).toLocaleDateString()}</td> <td className="admin-mono">{new Date(p.created_at).toLocaleDateString()}</td>
<td><button className="cc-delete" onClick={() => deletePuzzle(p.id)}><IconTrashFilled size={11} /></button></td> <td><button className="cc-delete" aria-label="Delete puzzle" onClick={() => deletePuzzle(p.id)}><IconTrashFilled size={11} aria-hidden="true" /></button></td>
</tr> </tr>
))} ))}
</tbody></table> </tbody></table>
@@ -879,7 +880,7 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
</button> </button>
</div> </div>
)} )}
<table className="admin-table"><thead><tr><th style={{ width: 24 }}><input type="checkbox" checked={communityPuzzles.length > 0 && selectedCommunity.size === communityPuzzles.length} onChange={e => { if (e.target.checked) setSelectedCommunity(new Set(communityPuzzles.map(p => p.id))); else setSelectedCommunity(new Set()) }} /></th><th>{t('admin.content.title')}</th><th>{t('admin.content.author')}</th><th>{t('admin.content.size')}</th><th>{t('admin.content.created')}</th><th></th></tr></thead><tbody> <table className="admin-table"><thead><tr><th style={{ width: 24 }}><input type="checkbox" aria-label="Select all" checked={communityPuzzles.length > 0 && selectedCommunity.size === communityPuzzles.length} onChange={e => { if (e.target.checked) setSelectedCommunity(new Set(communityPuzzles.map(p => p.id))); else setSelectedCommunity(new Set()) }} /></th><th>{t('admin.content.title')}</th><th>{t('admin.content.author')}</th><th>{t('admin.content.size')}</th><th>{t('admin.content.created')}</th><th></th></tr></thead><tbody>
{communityPuzzles.map(p => ( {communityPuzzles.map(p => (
<tr key={p.id}> <tr key={p.id}>
<td><input type="checkbox" checked={selectedCommunity.has(p.id)} onChange={() => toggleCommunitySelect(p.id)} /></td> <td><input type="checkbox" checked={selectedCommunity.has(p.id)} onChange={() => toggleCommunitySelect(p.id)} /></td>
@@ -887,7 +888,7 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
<td>{p.author}</td> <td>{p.author}</td>
<td>{p.width}x{p.height}</td> <td>{p.width}x{p.height}</td>
<td className="admin-mono">{new Date(p.created_at).toLocaleDateString()}</td> <td className="admin-mono">{new Date(p.created_at).toLocaleDateString()}</td>
<td><button className="cc-delete" onClick={() => deleteCommunityPuzzle(p.id)}><IconTrashFilled size={11} /></button></td> <td><button className="cc-delete" aria-label="Delete community puzzle" onClick={() => deleteCommunityPuzzle(p.id)}><IconTrashFilled size={11} aria-hidden="true" /></button></td>
</tr> </tr>
))} ))}
</tbody></table> </tbody></table>
@@ -929,7 +930,7 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
<h3 className="admin-section-heading">{t('admin.content.blockedWords')}</h3> <h3 className="admin-section-heading">{t('admin.content.blockedWords')}</h3>
<p style={{ fontSize: '0.625rem', color: 'var(--text-tertiary)', marginBottom: 8 }}>{t('admin.content.blockedWordsDesc')}</p> <p style={{ fontSize: '0.625rem', color: 'var(--text-tertiary)', marginBottom: 8 }}>{t('admin.content.blockedWordsDesc')}</p>
<div className="admin-webhook-form"> <div className="admin-webhook-form">
<input placeholder={t('admin.content.blockedWordPlaceholder')} value={blockedWord} <input placeholder={t('admin.content.blockedWordPlaceholder')} aria-label={t('admin.content.blockedWordPlaceholder')} value={blockedWord}
onChange={e => setBlockedWord(e.target.value)} onChange={e => setBlockedWord(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addBlockedWord()} onKeyDown={e => e.key === 'Enter' && addBlockedWord()}
className="admin-input" style={{ maxWidth: '16rem' }} /> className="admin-input" style={{ maxWidth: '16rem' }} />
@@ -941,7 +942,7 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
{tab === 'users' && tabLoading.users && <AdminSpinner label={t('common.loading')} />} {tab === 'users' && tabLoading.users && <AdminSpinner label={t('common.loading')} />}
{tab === 'users' && !tabLoading.users && (<> {tab === 'users' && !tabLoading.users && (<>
<div className="admin-toolbar"> <div className="admin-toolbar">
<input placeholder={t('admin.users.searchPlaceholder')} value={sessionSearch} onChange={e => setSessionSearch(e.target.value)} className="admin-input" style={{ maxWidth: '16rem' }} /> <input placeholder={t('admin.users.searchPlaceholder')} aria-label={t('admin.users.searchPlaceholder')} value={sessionSearch} onChange={e => setSessionSearch(e.target.value)} className="admin-input" style={{ maxWidth: '16rem' }} />
<button className="btn btn-sm" onClick={purgeSessions}>{t('admin.users.purgeInactive')}</button> <button className="btn btn-sm" onClick={purgeSessions}>{t('admin.users.purgeInactive')}</button>
<span className="admin-toolbar-count">{t('admin.users.userCount', { count: filteredSessions.length })}</span> <span className="admin-toolbar-count">{t('admin.users.userCount', { count: filteredSessions.length })}</span>
</div> </div>
@@ -956,10 +957,10 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
<td className="admin-mono">{new Date(s.created_at).toLocaleDateString()}</td> <td className="admin-mono">{new Date(s.created_at).toLocaleDateString()}</td>
<td style={{ display: 'flex', gap: 4 }}> <td style={{ display: 'flex', gap: 4 }}>
{s.banned {s.banned
? <Tooltip text={t('admin.users.unban')}><button className="btn btn-sm" onClick={() => unbanUser(s.token)}><IconCircleCheckFilled size={11} /></button></Tooltip> ? <Tooltip text={t('admin.users.unban')}><button className="btn btn-sm" aria-label={t('admin.users.unban')} onClick={() => unbanUser(s.token)}><IconCircleCheckFilled size={11} aria-hidden="true" /></button></Tooltip>
: <Tooltip text={t('admin.users.ban')}><button className="cc-delete" onClick={() => banUser(s.token, s.display_name ?? '')}><IconBan size={11} /></button></Tooltip> : <Tooltip text={t('admin.users.ban')}><button className="cc-delete" aria-label={t('admin.users.ban')} onClick={() => banUser(s.token, s.display_name ?? '')}><IconBan size={11} aria-hidden="true" /></button></Tooltip>
} }
<Tooltip text={t('common.delete')}><button className="cc-delete" onClick={() => deleteSession(s.token)}><IconTrashFilled size={11} /></button></Tooltip> <Tooltip text={t('common.delete')}><button className="cc-delete" aria-label={t('common.delete')} onClick={() => deleteSession(s.token)}><IconTrashFilled size={11} aria-hidden="true" /></button></Tooltip>
</td> </td>
</tr> </tr>
))} ))}
@@ -979,7 +980,7 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
{tab === 'integrations' && !tabLoading.integrations && (<> {tab === 'integrations' && !tabLoading.integrations && (<>
<h3 className="admin-section-heading">{t('admin.integrations.webhooks')}</h3> <h3 className="admin-section-heading">{t('admin.integrations.webhooks')}</h3>
<div className="admin-webhook-form"> <div className="admin-webhook-form">
<input placeholder={t('admin.integrations.webhookUrl')} value={newWebhook.url} onChange={e => setNewWebhook({ ...newWebhook, url: e.target.value })} className="admin-input" /> <input placeholder={t('admin.integrations.webhookUrl')} aria-label={t('admin.integrations.webhookUrl')} value={newWebhook.url} onChange={e => setNewWebhook({ ...newWebhook, url: e.target.value })} className="admin-input" />
<Select value={newWebhook.events} onChange={v => setNewWebhook({ ...newWebhook, events: String(v) })} options={[ <Select value={newWebhook.events} onChange={v => setNewWebhook({ ...newWebhook, events: String(v) })} options={[
{ value: 'daily_puzzle.published', label: t('admin.integrations.dailyPublished') }, { value: 'daily_puzzle.published', label: t('admin.integrations.dailyPublished') },
{ value: 'puzzle.completed', label: t('admin.integrations.puzzleCompleted') }, { value: 'puzzle.completed', label: t('admin.integrations.puzzleCompleted') },
@@ -1034,6 +1035,7 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
<textarea <textarea
ref={rulesRef} ref={rulesRef}
className="admin-input" className="admin-input"
aria-label="Instance rules"
rows={6} rows={6}
value={String(config?.instance_rules ?? '')} value={String(config?.instance_rules ?? '')}
onChange={e => setConfig(c => ({ ...(c ?? {}), instance_rules: e.target.value }))} onChange={e => setConfig(c => ({ ...(c ?? {}), instance_rules: e.target.value }))}
@@ -1078,7 +1080,7 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
<label style={{ fontSize: '0.625rem', color: 'var(--text-secondary)' }}>{t('admin.federation.actorUsername')}</label> <label style={{ fontSize: '0.625rem', color: 'var(--text-secondary)' }}>{t('admin.federation.actorUsername')}</label>
<input className="admin-input" value={profile.ap_actor_name} onChange={e => updateProfile('ap_actor_name', e.target.value)} placeholder="crossword" /> <input className="admin-input" value={profile.ap_actor_name} onChange={e => updateProfile('ap_actor_name', e.target.value)} placeholder="crossword" />
<label style={{ fontSize: '0.625rem', color: 'var(--text-secondary)' }}>{t('admin.federation.bio')}</label> <label style={{ fontSize: '0.625rem', color: 'var(--text-secondary)' }}>{t('admin.federation.bio')}</label>
<textarea className="admin-input" rows={4} value={profile.ap_bio} onChange={e => updateProfile('ap_bio', e.target.value)} placeholder={t('admin.federation.bioPlaceholder')} style={{ resize: 'vertical', fontFamily: 'inherit' }} /> <textarea className="admin-input" rows={4} value={profile.ap_bio} onChange={e => updateProfile('ap_bio', e.target.value)} aria-label={t('admin.federation.bioPlaceholder')} placeholder={t('admin.federation.bioPlaceholder')} style={{ resize: 'vertical', fontFamily: 'inherit' }} />
<label style={{ fontSize: '0.625rem', color: 'var(--text-secondary)' }}>{t('admin.federation.avatar')}</label> <label style={{ fontSize: '0.625rem', color: 'var(--text-secondary)' }}>{t('admin.federation.avatar')}</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{avatarPreview && <img src={avatarPreview} alt="avatar" style={{ width: 48, height: 48, borderRadius: 4, objectFit: 'cover' }} />} {avatarPreview && <img src={avatarPreview} alt="avatar" style={{ width: 48, height: 48, borderRadius: 4, objectFit: 'cover' }} />}
@@ -1122,7 +1124,7 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
<tr key={item.id}> <tr key={item.id}>
<td className="admin-mono" style={{ maxWidth: '24rem', overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</td> <td className="admin-mono" style={{ maxWidth: '24rem', overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</td>
<td className="admin-mono">{new Date(item.published).toLocaleString()}</td> <td className="admin-mono">{new Date(item.published).toLocaleString()}</td>
<td><button className="cc-delete" onClick={() => { <td><button className="cc-delete" aria-label="Delete post" onClick={() => {
setDialog({ title: 'Delete post', message: `Delete "${label}" and send Delete activity to all followers?`, onConfirm: async () => { setDialog({ title: 'Delete post', message: `Delete "${label}" and send Delete activity to all followers?`, onConfirm: async () => {
await adminFetch(`/outbox/${i}`, { method: 'DELETE' }) await adminFetch(`/outbox/${i}`, { method: 'DELETE' })
setOutboxItems(prev => prev.filter((_, j) => j !== i)) setOutboxItems(prev => prev.filter((_, j) => j !== i))
@@ -1137,7 +1139,7 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
)} )}
<div className="admin-webhook-form" style={{ marginTop: 8 }}> <div className="admin-webhook-form" style={{ marginTop: 8 }}>
<input placeholder="Post URI to delete from remote instances" value={deleteUri} <input placeholder="Post URI to delete from remote instances" aria-label="Post URI to delete from remote instances" value={deleteUri}
onChange={e => setDeleteUri(e.target.value)} onChange={e => setDeleteUri(e.target.value)}
onKeyDown={e => e.key === 'Enter' && deleteUri.trim() && (async () => { onKeyDown={e => e.key === 'Enter' && deleteUri.trim() && (async () => {
await adminFetch('/outbox/delete-remote', { method: 'POST', body: JSON.stringify({ uri: deleteUri.trim() }) }) await adminFetch('/outbox/delete-remote', { method: 'POST', body: JSON.stringify({ uri: deleteUri.trim() }) })
@@ -1154,7 +1156,7 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
{/* Peers */} {/* Peers */}
<h3 className="admin-section-heading">{t('admin.federation.peersTitle')}</h3> <h3 className="admin-section-heading">{t('admin.federation.peersTitle')}</h3>
<div className="admin-webhook-form"> <div className="admin-webhook-form">
<input placeholder={t('admin.federation.peerDomainPlaceholder')} value={newPeerDomain} <input placeholder={t('admin.federation.peerDomainPlaceholder')} aria-label={t('admin.federation.peerDomainPlaceholder')} value={newPeerDomain}
onChange={e => setNewPeerDomain(e.target.value)} onChange={e => setNewPeerDomain(e.target.value)}
onKeyDown={e => e.key === 'Enter' && connectPeer()} onKeyDown={e => e.key === 'Enter' && connectPeer()}
className="admin-input" /> className="admin-input" />
@@ -1167,7 +1169,7 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
<tr key={p.domain}> <tr key={p.domain}>
<td className="admin-mono">{p.domain}</td> <td className="admin-mono">{p.domain}</td>
<td className="admin-mono">{p.last_seen ? new Date(p.last_seen).toLocaleString() : 'never'}</td> <td className="admin-mono">{p.last_seen ? new Date(p.last_seen).toLocaleString() : 'never'}</td>
<td><button className="cc-delete" onClick={() => disconnectPeer(p.domain)}><IconTrashFilled size={11} /></button></td> <td><button className="cc-delete" aria-label="Disconnect peer" onClick={() => disconnectPeer(p.domain)}><IconTrashFilled size={11} aria-hidden="true" /></button></td>
</tr> </tr>
))}</tbody> ))}</tbody>
</table> </table>
@@ -1185,8 +1187,8 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
<td className="admin-mono" style={{ maxWidth: '20rem', overflow: 'hidden', textOverflow: 'ellipsis' }}>{f.actor_uri}</td> <td className="admin-mono" style={{ maxWidth: '20rem', overflow: 'hidden', textOverflow: 'ellipsis' }}>{f.actor_uri}</td>
<td className="admin-mono">{new Date(f.created_at).toLocaleDateString()}</td> <td className="admin-mono">{new Date(f.created_at).toLocaleDateString()}</td>
<td style={{ display: 'flex', gap: 4 }}> <td style={{ display: 'flex', gap: 4 }}>
<button className="btn btn-sm" onClick={() => approveFollower(f.actor_uri)}><IconCheckFilled size={11} /></button> <button className="btn btn-sm" aria-label="Approve follower" onClick={() => approveFollower(f.actor_uri)}><IconCheckFilled size={11} aria-hidden="true" /></button>
<button className="cc-delete" onClick={() => rejectFollower(f.actor_uri)}><IconSquareXFilled size={11} /></button> <button className="cc-delete" aria-label="Reject follower" onClick={() => rejectFollower(f.actor_uri)}><IconSquareXFilled size={11} aria-hidden="true" /></button>
</td> </td>
</tr> </tr>
))}</tbody> ))}</tbody>
@@ -1196,7 +1198,7 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
{/* Security */} {/* Security */}
<h3 className="admin-section-heading">{t('admin.federation.security')}</h3> <h3 className="admin-section-heading">{t('admin.federation.security')}</h3>
<div className="admin-webhook-form"> <div className="admin-webhook-form">
<input placeholder={t('admin.federation.domainToBlock')} value={blockDomain} onChange={e => setBlockDomain(e.target.value)} className="admin-input" /> <input placeholder={t('admin.federation.domainToBlock')} aria-label={t('admin.federation.domainToBlock')} value={blockDomain} onChange={e => setBlockDomain(e.target.value)} className="admin-input" />
<input placeholder={t('admin.federation.reasonOptional')} value={blockReason} onChange={e => setBlockReason(e.target.value)} className="admin-input" style={{ maxWidth: '12rem' }} /> <input placeholder={t('admin.federation.reasonOptional')} value={blockReason} onChange={e => setBlockReason(e.target.value)} className="admin-input" style={{ maxWidth: '12rem' }} />
<button className="btn btn-sm" onClick={blockInstance} disabled={!blockDomain.trim()}>{t('common.block')}</button> <button className="btn btn-sm" onClick={blockInstance} disabled={!blockDomain.trim()}>{t('common.block')}</button>
</div> </div>
@@ -1265,6 +1267,7 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
{t('admin.federation.dailyTaglines')} ({apTaglines.trim() ? apTaglines.trim().split('\n').filter(Boolean).length : 0} {t('admin.federation.entries')}) {t('admin.federation.dailyTaglines')} ({apTaglines.trim() ? apTaglines.trim().split('\n').filter(Boolean).length : 0} {t('admin.federation.entries')})
</label> </label>
<textarea className="admin-input" rows={4} value={apTaglines} onChange={e => setApTaglines(e.target.value)} <textarea className="admin-input" rows={4} value={apTaglines} onChange={e => setApTaglines(e.target.value)}
aria-label={t('admin.federation.dailyTaglines')}
placeholder={t('admin.federation.onePerLine')} placeholder={t('admin.federation.onePerLine')}
style={{ resize: 'vertical', fontFamily: 'var(--font-mono)', fontSize: '0.6rem' }} /> style={{ resize: 'vertical', fontFamily: 'var(--font-mono)', fontSize: '0.6rem' }} />
@@ -1272,6 +1275,7 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
{t('admin.federation.dailySignoffs')} ({apSignoffs.trim() ? apSignoffs.trim().split('\n').filter(Boolean).length : 0} {t('admin.federation.entries')}) {t('admin.federation.dailySignoffs')} ({apSignoffs.trim() ? apSignoffs.trim().split('\n').filter(Boolean).length : 0} {t('admin.federation.entries')})
</label> </label>
<textarea className="admin-input" rows={4} value={apSignoffs} onChange={e => setApSignoffs(e.target.value)} <textarea className="admin-input" rows={4} value={apSignoffs} onChange={e => setApSignoffs(e.target.value)}
aria-label={t('admin.federation.dailySignoffs')}
placeholder={t('admin.federation.onePerLine')} placeholder={t('admin.federation.onePerLine')}
style={{ resize: 'vertical', fontFamily: 'var(--font-mono)', fontSize: '0.6rem' }} /> style={{ resize: 'vertical', fontFamily: 'var(--font-mono)', fontSize: '0.6rem' }} />
@@ -1279,6 +1283,7 @@ export default function AdminPanel({ onClose }: { onClose: () => void }) {
{t('admin.federation.communityMiddles')} ({apCommunityMiddles.trim() ? apCommunityMiddles.trim().split('\n').filter(Boolean).length : 0} {t('admin.federation.entries')}) {t('admin.federation.communityMiddles')} ({apCommunityMiddles.trim() ? apCommunityMiddles.trim().split('\n').filter(Boolean).length : 0} {t('admin.federation.entries')})
</label> </label>
<textarea className="admin-input" rows={4} value={apCommunityMiddles} onChange={e => setApCommunityMiddles(e.target.value)} <textarea className="admin-input" rows={4} value={apCommunityMiddles} onChange={e => setApCommunityMiddles(e.target.value)}
aria-label={t('admin.federation.communityMiddles')}
placeholder={t('admin.federation.onePerLine')} placeholder={t('admin.federation.onePerLine')}
style={{ resize: 'vertical', fontFamily: 'var(--font-mono)', fontSize: '0.6rem' }} /> style={{ resize: 'vertical', fontFamily: 'var(--font-mono)', fontSize: '0.6rem' }} />
+18 -6
View File
@@ -174,7 +174,9 @@ function SmoothLineChart({ data, width, height, formatY, label, maxYOverride }:
const gradId = `smooth-grad-${label || 'default'}` const gradId = `smooth-grad-${label || 'default'}`
return ( return (
<svg width="100%" viewBox={`0 0 ${width} ${height}`} className="analytics-chart-svg"> <>
<svg width="100%" viewBox={`0 0 ${width} ${height}`} className="analytics-chart-svg"
role="img" aria-label={label ? `Line chart of ${label} over time` : 'Line chart'}>
{label && ( {label && (
<text x={pad.left} y={10} className="analytics-chart-label">{label}</text> <text x={pad.left} y={10} className="analytics-chart-label">{label}</text>
)} )}
@@ -205,6 +207,10 @@ function SmoothLineChart({ data, width, height, formatY, label, maxYOverride }:
</text> </text>
))} ))}
</svg> </svg>
<span className="visually-hidden">
{data.map(d => d.label ? `${d.label}: ${formatY(d.y)}` : formatY(d.y)).join('; ')}
</span>
</>
) )
} }
@@ -232,7 +238,9 @@ function BarChart({ data, labels, width, height, highlightMax, highlightMin, for
const gap = w / data.length const gap = w / data.length
return ( return (
<svg width="100%" viewBox={`0 0 ${width} ${height}`} className="analytics-chart-svg"> <>
<svg width="100%" viewBox={`0 0 ${width} ${height}`} className="analytics-chart-svg"
role="img" aria-label="Bar chart of values">
{data.map((val, i) => { {data.map((val, i) => {
const barH = (val / maxVal) * h const barH = (val / maxVal) * h
const x = pad.left + i * gap + (gap - barW) / 2 const x = pad.left + i * gap + (gap - barW) / 2
@@ -257,6 +265,10 @@ function BarChart({ data, labels, width, height, highlightMax, highlightMin, for
) )
})} })}
</svg> </svg>
<span className="visually-hidden">
{data.map((val, i) => labels[i] ? `${labels[i]}: ${val}` : String(val)).join('; ')}
</span>
</>
) )
} }
@@ -279,7 +291,7 @@ function Histogram({ buckets, minVal, maxVal, width, height }: {
const gap = w / buckets.length const gap = w / buckets.length
return ( return (
<svg width="100%" viewBox={`0 0 ${width} ${height}`} className="analytics-chart-svg"> <svg width="100%" viewBox={`0 0 ${width} ${height}`} className="analytics-chart-svg" role="img" aria-label="Distribution of solve times">
{buckets.map((val, i) => { {buckets.map((val, i) => {
const barH = (val / peak) * h const barH = (val / peak) * h
const x = pad.left + i * gap + (gap - barW) / 2 const x = pad.left + i * gap + (gap - barW) / 2
@@ -336,7 +348,7 @@ function DonutChart({ segments, size }: {
return ( return (
<div className="analytics-donut"> <div className="analytics-donut">
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}> <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} role="img" aria-label="Proportional distribution chart">
{arcs.map((a, i) => ( {arcs.map((a, i) => (
<circle key={i} cx={cx} cy={cy} r={innerR} <circle key={i} cx={cx} cy={cy} r={innerR}
fill="none" stroke={a.color} strokeWidth={strokeW} fill="none" stroke={a.color} strokeWidth={strokeW}
@@ -386,7 +398,7 @@ function GaugeChart({ value, size }: { value: number; size: number }) {
return ( return (
<div className="analytics-gauge"> <div className="analytics-gauge">
<svg width={size} height={size * 0.7} viewBox={`0 0 ${size} ${size * 0.7}`}> <svg width={size} height={size * 0.7} viewBox={`0 0 ${size} ${size * 0.7}`} role="img" aria-label="Completion gauge">
<path d={arcPath(startAngle, endAngle)} fill="none" <path d={arcPath(startAngle, endAngle)} fill="none"
stroke="var(--border)" strokeWidth={8} strokeLinecap="round" /> stroke="var(--border)" strokeWidth={8} strokeLinecap="round" />
{value > 0 && ( {value > 0 && (
@@ -450,7 +462,7 @@ function ActivityHeatmap({ calendar }: { calendar: Record<string, number> }) {
return ( return (
<div> <div>
<svg width="100%" viewBox={`0 0 ${svgW} ${svgH}`} className="analytics-chart-svg"> <svg width="100%" viewBox={`0 0 ${svgW} ${svgH}`} className="analytics-chart-svg" role="img" aria-label="Solves per day heatmap">
{months.map((m, i) => ( {months.map((m, i) => (
<text key={i} x={24 + m.col * total} y={8} <text key={i} x={24 + m.col * total} y={8}
className="analytics-chart-tick">{m.label}</text> className="analytics-chart-tick">{m.label}</text>
+7 -4
View File
@@ -17,6 +17,9 @@ export default function CellFlip({ svgRef }: { svgRef: React.RefObject<SVGSVGEle
const puzzle = useStore((s) => s.puzzle) const puzzle = useStore((s) => s.puzzle)
const [flips, setFlips] = useState<FlipItem[]>([]) const [flips, setFlips] = useState<FlipItem[]>([])
const prevEntries = useRef<string[][]>([]) const prevEntries = useRef<string[][]>([])
const timeouts = useRef<ReturnType<typeof setTimeout>[]>([])
useEffect(() => () => { timeouts.current.forEach(id => clearTimeout(id)) }, [])
useEffect(() => { useEffect(() => {
if (!puzzle) return if (!puzzle) return
@@ -50,7 +53,7 @@ export default function CellFlip({ svgRef }: { svgRef: React.RefObject<SVGSVGEle
} }
setFlips(f => [...f, ...newFlips]) setFlips(f => [...f, ...newFlips])
setTimeout(() => { timeouts.current.push(setTimeout(() => {
// restore SVG letter visibility // restore SVG letter visibility
if (svgRef.current) { if (svgRef.current) {
for (const flip of newFlips) { for (const flip of newFlips) {
@@ -59,7 +62,7 @@ export default function CellFlip({ svgRef }: { svgRef: React.RefObject<SVGSVGEle
} }
} }
setFlips(f => f.filter(fl => !newFlips.some(n => n.id === fl.id))) setFlips(f => f.filter(fl => !newFlips.some(n => n.id === fl.id)))
}, 600) }, 600))
} }
}, [entries, puzzle, svgRef]) }, [entries, puzzle, svgRef])
@@ -92,13 +95,13 @@ export default function CellFlip({ svgRef }: { svgRef: React.RefObject<SVGSVGEle
{flip.clueNumber && ( {flip.clueNumber && (
<span className="flip-num" style={{ fontSize: numSize }}>{flip.clueNumber}</span> <span className="flip-num" style={{ fontSize: numSize }}>{flip.clueNumber}</span>
)} )}
{flip.oldLetter && <span className="flip-letter" style={{ fontSize }}>{flip.oldLetter}</span>} {flip.oldLetter && <span className="flip-letter" style={{ fontSize }} aria-hidden="true">{flip.oldLetter}</span>}
</div> </div>
<div className="flip-face flip-back"> <div className="flip-face flip-back">
{flip.clueNumber && ( {flip.clueNumber && (
<span className="flip-num" style={{ fontSize: numSize }}>{flip.clueNumber}</span> <span className="flip-num" style={{ fontSize: numSize }}>{flip.clueNumber}</span>
)} )}
<span className="flip-letter" style={{ fontSize }}>{flip.newLetter}</span> <span className="flip-letter" style={{ fontSize }} aria-hidden="true">{flip.newLetter}</span>
</div> </div>
</div> </div>
</div> </div>
+29
View File
@@ -0,0 +1,29 @@
import { useTranslation } from 'react-i18next'
import { IconArrowLeft, IconArrowRight } from '@tabler/icons-react'
import { useStore } from '../store'
export default function ClueNav() {
const { t } = useTranslation()
const puzzle = useStore((s) => s.puzzle)
const activeClue = useStore((s) => s.activeClue)
const direction = useStore((s) => s.direction)
const tabClue = useStore((s) => s.tabClue)
if (!puzzle || activeClue === null) return null
const clues = direction === 'across' ? puzzle.clues_across : puzzle.clues_down
const clue = clues.find((c) => c.number === activeClue)
const current = clue ? `${activeClue} ${t(direction === 'across' ? 'game.across' : 'game.down')}` : ''
return (
<div className="clue-nav" role="group" aria-label={t('a11y.clueNavigation')}>
<button type="button" className="btn btn-sm btn-ghost" onClick={() => tabClue(false)} aria-label={t('a11y.previousClue')}>
<IconArrowLeft size={14} aria-hidden="true" /> {t('a11y.previousClue')}
</button>
<span className="clue-nav-current">{current}</span>
<button type="button" className="btn btn-sm btn-ghost" onClick={() => tabClue(true)} aria-label={t('a11y.nextClue')}>
{t('a11y.nextClue')} <IconArrowRight size={14} aria-hidden="true" />
</button>
</div>
)
}
+1 -1
View File
@@ -87,7 +87,7 @@ export default function CluePanel() {
} }
return ( return (
<div className={`clue-panel ${solved ? 'clue-panel-solved' : ''}`} role="region" aria-label="Clues" aria-live="polite"> <div className={`clue-panel ${solved ? 'clue-panel-solved' : ''}`} role="region" aria-label="Clues">
<div className="clue-tabs" role="tablist"> <div className="clue-tabs" role="tablist">
<button <button
className={`clue-tab ${viewTab === 'across' ? 'active' : ''}`} className={`clue-tab ${viewTab === 'across' ? 'active' : ''}`}
+3 -2
View File
@@ -90,7 +90,7 @@ export default function ClueTrainer({ onClose }: Props) {
)} )}
</div> </div>
<button className="btn btn-sm btn-ghost" onClick={onClose} aria-label={t('common.close')}> <button className="btn btn-sm btn-ghost" onClick={onClose} aria-label={t('common.close')}>
<IconX size={16} /> <IconX size={16} aria-hidden="true" />
</button> </button>
</div> </div>
@@ -98,6 +98,7 @@ export default function ClueTrainer({ onClose }: Props) {
{(['easy', 'medium', 'hard'] satisfies Difficulty[]).map(d => ( {(['easy', 'medium', 'hard'] satisfies Difficulty[]).map(d => (
<button key={d} <button key={d}
className={`trainer-diff-btn ${difficulty === d ? 'trainer-diff-active' : ''}`} className={`trainer-diff-btn ${difficulty === d ? 'trainer-diff-active' : ''}`}
aria-pressed={difficulty === d}
onClick={() => changeDifficulty(d)}> onClick={() => changeDifficulty(d)}>
{t(`trainer.${d}`)} {t(`trainer.${d}`)}
</button> </button>
@@ -143,7 +144,7 @@ export default function ClueTrainer({ onClose }: Props) {
</div> </div>
{result && ( {result && (
<div className={`trainer-result ${result.correct ? 'trainer-correct' : 'trainer-wrong'}`}> <div className={`trainer-result ${result.correct ? 'trainer-correct' : 'trainer-wrong'}`} role="status">
{result.correct ? ( {result.correct ? (
<><IconCheck size={16} /> {t('trainer.correctMsg')}</> <><IconCheck size={16} /> {t('trainer.correctMsg')}</>
) : ( ) : (
@@ -104,8 +104,8 @@ export default function CommunityCluesPanel({ onClose }: { onClose: () => void }
onChange={e => setWord(e.target.value.toUpperCase().replace(/[^A-Z]/g, ''))} onChange={e => setWord(e.target.value.toUpperCase().replace(/[^A-Z]/g, ''))}
onKeyDown={e => e.key === 'Enter' && search()} onKeyDown={e => e.key === 'Enter' && search()}
/> />
<button className="btn btn-sm btn-primary" onClick={search} disabled={!word.trim()}> <button className="btn btn-sm btn-primary" onClick={search} disabled={!word.trim()} aria-label="Search">
<IconSearch size={14} /> <IconSearch size={14} aria-hidden="true" />
</button> </button>
</div> </div>
@@ -128,8 +128,8 @@ export default function CommunityCluesPanel({ onClose }: { onClose: () => void }
maxLength={500} maxLength={500}
onKeyDown={e => e.key === 'Enter' && submitClue()} onKeyDown={e => e.key === 'Enter' && submitClue()}
/> />
<button className="btn btn-sm btn-primary" onClick={submitClue} disabled={submitting || !newClue.trim()}> <button className="btn btn-sm btn-primary" onClick={submitClue} disabled={submitting || !newClue.trim()} aria-label={t('game.submit')}>
<IconSend2 size={14} /> <IconSend2 size={14} aria-hidden="true" />
</button> </button>
</div> </div>
+5 -3
View File
@@ -43,14 +43,16 @@ export default function CommunityToday() {
<div className="ed-puzzle-grid"> <div className="ed-puzzle-grid">
{puzzles.map(p => ( {puzzles.map(p => (
<button key={p.id} type="button" className="ed-puzzle-card" onClick={() => play(p.id)}> <div key={p.id} className="ed-puzzle-card" role="button" tabIndex={0}
onClick={() => play(p.id)}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); play(p.id) } }}>
<span className="ed-puzzle-size">{p.width}x{p.height}</span> <span className="ed-puzzle-size">{p.width}x{p.height}</span>
<span className="ed-puzzle-title">{p.title}</span> <span className="ed-puzzle-title">{p.title}</span>
<span className="ed-puzzle-by"> <span className="ed-puzzle-by">
{p.origin ? `from ${p.origin}` : p.author ? `by ${p.author}` : ''} {p.origin ? `from ${p.origin}` : p.author ? `by ${p.author}` : ''}
</span> </span>
<span className="ed-puzzle-report"><ReportButton contentType="puzzle" contentId={p.id} /></span> <span className="ed-puzzle-report" onClick={e => e.stopPropagation()}><ReportButton contentType="puzzle" contentId={p.id} /></span>
</button> </div>
))} ))}
</div> </div>
</div> </div>
@@ -45,12 +45,24 @@ export default function DisambiguationPopup({ svgRef }: Props) {
} }
}, [show]) }, [show])
useEffect(() => {
if (!show) return
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') dismissDisambiguation()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [show, dismissDisambiguation])
if (!show || !result || !geom) return null if (!show || !result || !geom) return null
return ( return (
<div <div
ref={popupRef} ref={popupRef}
className="disambiguation" className="disambiguation"
role="dialog"
aria-modal="true"
aria-label="Choose a handwriting match"
style={{ style={{
position: 'absolute', position: 'absolute',
top: geom.top, top: geom.top,
@@ -62,6 +74,7 @@ export default function DisambiguationPopup({ svgRef }: Props) {
<button <button
className="disambiguation-btn" className="disambiguation-btn"
onClick={() => acceptCandidate(c.letter)} onClick={() => acceptCandidate(c.letter)}
aria-label={`Select ${c.letter}`}
> >
{c.letter} {c.letter}
</button> </button>
@@ -71,6 +84,7 @@ export default function DisambiguationPopup({ svgRef }: Props) {
<button <button
className="disambiguation-btn dismiss" className="disambiguation-btn dismiss"
onClick={dismissDisambiguation} onClick={dismissDisambiguation}
aria-label={t('common.dismiss')}
> >
x x
</button> </button>
+110 -33
View File
@@ -414,7 +414,10 @@ export default function EditorPanel({ onClose }: { onClose: () => void }) {
for (const [mr, mc] of mirrorCoords(r, c)) { for (const [mr, mc] of mirrorCoords(r, c)) {
if (mr >= 0 && mr < height && mc >= 0 && mc < width) { next[mr][mc] = newType; if (newType === 'black') nextLetters[mr][mc] = null } if (mr >= 0 && mr < height && mc >= 0 && mc < width) { next[mr][mc] = newType; if (newType === 'black') nextLetters[mr][mc] = null }
} }
if (hasOffensivePattern(next)) return if (hasOffensivePattern(next)) {
setWarnings([t('editor.offensivePattern')])
return
}
pushUndo() pushUndo()
setCells(next); setLetters(nextLetters); setClues([]) setCells(next); setLetters(nextLetters); setClues([])
} else if (mode === 'fill') { } else if (mode === 'fill') {
@@ -575,6 +578,62 @@ export default function EditorPanel({ onClose }: { onClose: () => void }) {
const displayLetters = isTest ? testEntries : letters const displayLetters = isTest ? testEntries : letters
const displaySelected = isTest ? testSelected : selectedCell const displaySelected = isTest ? testSelected : selectedCell
// Screen-reader grid: a parallel interactive layer over the visual SVG so
// blind constructors can design, fill, and test by keyboard.
const srCellRefs = useRef<(HTMLButtonElement | null)[]>([])
const selectCellAt = (r: number, c: number) => {
if (isTest) setTestSelected({ row: r, col: c })
else setSelectedCell({ row: r, col: c })
}
const moveGridSelection = (dr: number, dc: number) => {
const sel = displaySelected
let r = sel ? sel.row + dr : 0
let c = sel ? sel.col + dc : 0
if (r < 0 || c < 0 || r >= height || c >= width) return
if (mode !== 'design') {
while (r >= 0 && c >= 0 && r < height && c < width && cells[r]?.[c] === 'black') {
r += dr; c += dc
}
if (r < 0 || c < 0 || r >= height || c >= width) return
}
if (mode === 'fill' || mode === 'clues') {
const wordCells = getWordCells(cells, r, c, editorDirection, width, height)
setHighlightedCells(new Set(wordCells.map(([wr, wc]) => `${wr},${wc}`)))
}
selectCellAt(r, c)
srCellRefs.current[r * width + c]?.focus()
}
const onSrGridKeyDown = (e: { key: string; preventDefault(): void }) => {
if (e.key === 'ArrowUp') { e.preventDefault(); moveGridSelection(-1, 0) }
else if (e.key === 'ArrowDown') { e.preventDefault(); moveGridSelection(1, 0) }
else if (e.key === 'ArrowLeft') { e.preventDefault(); moveGridSelection(0, -1) }
else if (e.key === 'ArrowRight') { e.preventDefault(); moveGridSelection(0, 1) }
}
const srGridLabel = (r: number, c: number): string => {
const isBlackCell = cells[r]?.[c] === 'black'
const num = cellNumbers[`${r},${c}`]
const letter = displayLetters[r]?.[c] || ''
let label = isBlackCell ? t('grid.blackCell') : t('grid.cellLabel', { row: r + 1, col: c + 1 })
if (num && !isBlackCell) label += t('grid.cellNumber', { num })
if (letter) label += t('grid.cellLetter', { letter })
return label
}
const focusableIdx = displaySelected
? displaySelected.row * width + displaySelected.col
: (() => {
for (let r = 0; r < height; r++) {
for (let c = 0; c < width; c++) {
if (cells[r]?.[c] !== 'black') return r * width + c
}
}
return 0
})()
if (view === 'list') { if (view === 'list') {
return ( return (
<div className="editor-fullscreen" role="main" aria-label={t('editor.title')}> <div className="editor-fullscreen" role="main" aria-label={t('editor.title')}>
@@ -590,9 +649,9 @@ export default function EditorPanel({ onClose }: { onClose: () => void }) {
<div className="editor-custom-size"> <div className="editor-custom-size">
<label className="settings-label" style={{ marginTop: 8 }}>{t('editor.customSize')}</label> <label className="settings-label" style={{ marginTop: 8 }}>{t('editor.customSize')}</label>
<div className="settings-row"> <div className="settings-row">
<NumberInput value={customW} min={3} max={25} onChange={setCustomW} className="editor-size-input" /> <NumberInput value={customW} min={3} max={25} onChange={setCustomW} className="editor-size-input" aria-label={t('editor.width')} />
<span style={{ color: 'var(--text-tertiary)', fontSize: '0.75rem', display: 'flex', alignItems: 'center' }}>x</span> <span aria-hidden="true" style={{ color: 'var(--text-tertiary)', fontSize: '0.75rem', display: 'flex', alignItems: 'center' }}>x</span>
<NumberInput value={customH} min={3} max={25} onChange={setCustomH} className="editor-size-input" /> <NumberInput value={customH} min={3} max={25} onChange={setCustomH} className="editor-size-input" aria-label={t('editor.height')} />
<button className="btn btn-sm" onClick={() => newPuzzle(customW, customH)}><IconPlus size={11} style={{ verticalAlign: -1, marginRight: 3 }} /> {t('common.create')}</button> <button className="btn btn-sm" onClick={() => newPuzzle(customW, customH)}><IconPlus size={11} style={{ verticalAlign: -1, marginRight: 3 }} /> {t('common.create')}</button>
</div> </div>
</div> </div>
@@ -607,8 +666,8 @@ export default function EditorPanel({ onClose }: { onClose: () => void }) {
<span className={`cc-status cc-status-${p.status}`}>{p.status}</span> <span className={`cc-status cc-status-${p.status}`}>{p.status}</span>
{p.status === 'published' && ( {p.status === 'published' && (
<Tooltip text={t('constructorAnalytics.title')}> <Tooltip text={t('constructorAnalytics.title')}>
<button className="btn btn-sm btn-ghost" onClick={(e) => { e.stopPropagation(); toggleAnalytics(p.id) }} style={{ padding: '0.125rem 0.25rem' }}> <button className="btn btn-sm btn-ghost" aria-label={t('constructorAnalytics.title')} onClick={(e) => { e.stopPropagation(); toggleAnalytics(p.id) }} style={{ padding: '0.125rem 0.25rem' }}>
<IconChartAreaFilled size={11} /> <IconChartAreaFilled size={11} aria-hidden="true" />
</button> </button>
</Tooltip> </Tooltip>
)} )}
@@ -620,7 +679,7 @@ export default function EditorPanel({ onClose }: { onClose: () => void }) {
<button className="btn btn-sm btn-ghost" style={{ fontSize: '0.5rem', padding: '0.125rem 0.375rem' }} onClick={() => setDeleteConfirmId(null)}>{t('common.cancel')}</button> <button className="btn btn-sm btn-ghost" style={{ fontSize: '0.5rem', padding: '0.125rem 0.375rem' }} onClick={() => setDeleteConfirmId(null)}>{t('common.cancel')}</button>
</> </>
) : ( ) : (
<button className="cc-delete" onClick={() => confirmDelete(p.id)}><IconTrashFilled size={11} /></button> <button className="cc-delete" aria-label={t('editor.delete')} onClick={() => confirmDelete(p.id)}><IconTrashFilled size={11} aria-hidden="true" /></button>
)} )}
</div> </div>
</div> </div>
@@ -711,14 +770,14 @@ export default function EditorPanel({ onClose }: { onClose: () => void }) {
<div className="editor-header-left"> <div className="editor-header-left">
<button type="button" className="btn btn-sm btn-ghost" onClick={() => setView('list')} aria-label={t('common.back')}><IconArrowBack size={16} aria-hidden="true" /></button> <button type="button" className="btn btn-sm btn-ghost" onClick={() => setView('list')} aria-label={t('common.back')}><IconArrowBack size={16} aria-hidden="true" /></button>
<button type="button" className="header-brand" onClick={onClose}>{t('app.name')}</button> <button type="button" className="header-brand" onClick={onClose}>{t('app.name')}</button>
<input className="editor-title-input" value={title} onChange={e => setTitle(e.target.value)} placeholder={t('editor.puzzleTitle')} /> <input className="editor-title-input" value={title} onChange={e => setTitle(e.target.value)} placeholder={t('editor.puzzleTitle')} aria-label={t('editor.puzzleTitle')} />
</div> </div>
<div className="editor-header-actions"> <div className="editor-header-actions">
<div className="editor-mode-toggle"> <div className="editor-mode-toggle">
<button className={`btn btn-sm ${mode === 'design' ? 'btn-primary' : ''}`} onClick={() => { setMode('design'); setHighlightedCells(new Set()) }}><IconPointerFilled size={12} style={{ verticalAlign: -1, marginRight: 3 }} /> {t('editor.design')}</button> <button className={`btn btn-sm ${mode === 'design' ? 'btn-primary' : ''}`} aria-pressed={mode === 'design'} onClick={() => { setMode('design'); setHighlightedCells(new Set()) }}><IconPointerFilled size={12} aria-hidden="true" style={{ verticalAlign: -1, marginRight: 3 }} /> {t('editor.design')}</button>
<button className={`btn btn-sm ${mode === 'fill' ? 'btn-primary' : ''}`} onClick={() => { setMode('fill'); setHighlightedCells(new Set()) }}><IconPencilFilled size={12} style={{ verticalAlign: -1, marginRight: 3 }} /> {t('editor.fill')}</button> <button className={`btn btn-sm ${mode === 'fill' ? 'btn-primary' : ''}`} aria-pressed={mode === 'fill'} onClick={() => { setMode('fill'); setHighlightedCells(new Set()) }}><IconPencilFilled size={12} aria-hidden="true" style={{ verticalAlign: -1, marginRight: 3 }} /> {t('editor.fill')}</button>
<button className={`btn btn-sm ${mode === 'clues' ? 'btn-primary' : ''}`} onClick={generateClues}><IconListFilled size={12} style={{ verticalAlign: -1, marginRight: 3 }} /> {t('editor.clues')}</button> <button className={`btn btn-sm ${mode === 'clues' ? 'btn-primary' : ''}`} aria-pressed={mode === 'clues'} onClick={generateClues}><IconListFilled size={12} aria-hidden="true" style={{ verticalAlign: -1, marginRight: 3 }} /> {t('editor.clues')}</button>
{clues.length > 0 && <button className={`btn btn-sm ${mode === 'test' ? 'btn-primary' : ''}`} onClick={isTest ? exitTest : startTest}><IconPlayerPlayFilled size={12} style={{ verticalAlign: -1, marginRight: 3 }} />{isTest ? t('editor.exitTest') : t('common.test')}</button>} {clues.length > 0 && <button className={`btn btn-sm ${mode === 'test' ? 'btn-primary' : ''}`} aria-pressed={mode === 'test'} onClick={isTest ? exitTest : startTest}><IconPlayerPlayFilled size={12} aria-hidden="true" style={{ verticalAlign: -1, marginRight: 3 }} />{isTest ? t('editor.exitTest') : t('common.test')}</button>}
</div> </div>
<button className="btn btn-sm btn-primary" onClick={autofill} disabled={filling}><IconSparklesFilled size={13} style={{ verticalAlign: -2, marginRight: 3 }} />{filling ? t('editor.filling') : t('editor.autoFill')}</button> <button className="btn btn-sm btn-primary" onClick={autofill} disabled={filling}><IconSparklesFilled size={13} style={{ verticalAlign: -2, marginRight: 3 }} />{filling ? t('editor.filling') : t('editor.autoFill')}</button>
<Tooltip text={t('editor.undoCtrlZ')}><button className="btn btn-sm" onClick={undo} disabled={undoStack.length === 0}><IconArrowBack size={13} style={{ verticalAlign: -2, marginRight: 3 }} /> {t('common.undo')}</button></Tooltip> <Tooltip text={t('editor.undoCtrlZ')}><button className="btn btn-sm" onClick={undo} disabled={undoStack.length === 0}><IconArrowBack size={13} style={{ verticalAlign: -2, marginRight: 3 }} /> {t('common.undo')}</button></Tooltip>
@@ -736,12 +795,12 @@ export default function EditorPanel({ onClose }: { onClose: () => void }) {
]} /> ]} />
{clues.length > 0 && <button className="btn btn-sm" onClick={tryPublish} disabled={saving}><IconWorldFilled size={13} style={{ verticalAlign: -2, marginRight: 3 }} /> {t('common.publish')}</button>} {clues.length > 0 && <button className="btn btn-sm" onClick={tryPublish} disabled={saving}><IconWorldFilled size={13} style={{ verticalAlign: -2, marginRight: 3 }} /> {t('common.publish')}</button>}
{lastSaved && <span className="editor-autosave-indicator">saved {lastSaved}</span>} {lastSaved && <span className="editor-autosave-indicator">saved {lastSaved}</span>}
<button className="btn btn-sm btn-ghost" onClick={onClose}><IconX size={16} /></button> <button className="btn btn-sm btn-ghost" onClick={onClose} aria-label={t('common.close')}><IconX size={16} aria-hidden="true" /></button>
</div> </div>
</div> </div>
{(warnings.length > 0 || twoLetterWarnings.length > 0) && ( {(warnings.length > 0 || twoLetterWarnings.length > 0) && (
<div className="editor-warnings" style={{ justifyContent: 'center' }}> <div className="editor-warnings" role="status" aria-live="polite" style={{ justifyContent: 'center' }}>
<IconAlertTriangleFilled size={14} /> <IconAlertTriangleFilled size={14} />
{warnings.map((w, i) => <span key={`w${i}`}>{w}</span>)} {warnings.map((w, i) => <span key={`w${i}`}>{w}</span>)}
{twoLetterWarnings.length > 0 && <span>{twoLetterWarnings.length} two-letter slot{twoLetterWarnings.length > 1 ? 's' : ''} (min 3 letters)</span>} {twoLetterWarnings.length > 0 && <span>{twoLetterWarnings.length} two-letter slot{twoLetterWarnings.length > 1 ? 's' : ''} (min 3 letters)</span>}
@@ -750,22 +809,38 @@ export default function EditorPanel({ onClose }: { onClose: () => void }) {
<div className="editor-workspace"> <div className="editor-workspace">
<div className="editor-grid-area"> <div className="editor-grid-area">
<svg viewBox={`0 0 ${width * cellSize} ${height * cellSize}`} style={{ maxWidth: width * cellSize, width: '100%' }}> <div className="editor-grid-wrap">
{cells.map((row, r) => row.map((cell, c) => { <svg viewBox={`0 0 ${width * cellSize} ${height * cellSize}`} aria-hidden="true" style={{ maxWidth: width * cellSize, width: '100%' }}>
const isSel = displaySelected?.row === r && displaySelected?.col === c {cells.map((row, r) => row.map((cell, c) => {
const isHighlighted = highlightedCells.has(`${r},${c}`) const isSel = displaySelected?.row === r && displaySelected?.col === c
const val = displayLetters[r]?.[c] const isHighlighted = highlightedCells.has(`${r},${c}`)
return ( const val = displayLetters[r]?.[c]
<g key={`${r}-${c}`} onClick={() => onCellClick(r, c)} style={{ cursor: 'pointer' }}> return (
<rect x={c * cellSize} y={r * cellSize} width={cellSize} height={cellSize} <g key={`${r}-${c}`} onClick={() => onCellClick(r, c)} style={{ cursor: 'pointer' }}>
fill={cell === 'black' ? 'var(--text)' : isSel ? 'var(--cell-selected, #bfdbfe)' : isHighlighted ? 'var(--accent-soft, #e0e7ff)' : 'var(--surface)'} <rect x={c * cellSize} y={r * cellSize} width={cellSize} height={cellSize}
stroke="var(--border)" strokeWidth={0.5} /> fill={cell === 'black' ? 'var(--text)' : isSel ? 'var(--cell-selected, #bfdbfe)' : isHighlighted ? 'var(--accent-soft, #e0e7ff)' : 'var(--surface)'}
{cellNumbers[`${r},${c}`] && <text x={c * cellSize + 2} y={r * cellSize + cellSize * 0.3} fontSize={cellSize * 0.25} fill="var(--text-tertiary)" fontFamily="var(--font-mono)">{cellNumbers[`${r},${c}`]}</text>} stroke="var(--border)" strokeWidth={0.5} />
{val && <text x={c * cellSize + cellSize / 2} y={r * cellSize + cellSize * 0.75} fontSize={cellSize * 0.5} fill="var(--text)" fontFamily="var(--font-display)" fontWeight="700" textAnchor="middle">{val}</text>} {cellNumbers[`${r},${c}`] && <text x={c * cellSize + 2} y={r * cellSize + cellSize * 0.3} fontSize={cellSize * 0.25} fill="var(--text-tertiary)" fontFamily="var(--font-mono)">{cellNumbers[`${r},${c}`]}</text>}
</g> {val && <text x={c * cellSize + cellSize / 2} y={r * cellSize + cellSize * 0.75} fontSize={cellSize * 0.5} fill="var(--text)" fontFamily="var(--font-display)" fontWeight="700" textAnchor="middle">{val}</text>}
) </g>
}))} )
</svg> }))}
</svg>
<div className="editor-sr-grid" role="grid" aria-label={t('editor.title')}
aria-rowcount={height} aria-colcount={width}
style={{ gridTemplateColumns: `repeat(${width}, 1fr)` }}
onKeyDown={onSrGridKeyDown}>
{cells.map((row, r) => row.map((_, c) => (
<button key={`sr-${r}-${c}`} ref={el => { srCellRefs.current[r * width + c] = el }}
type="button" className="editor-sr-cell" role="gridcell"
aria-rowindex={r + 1} aria-colindex={c + 1}
tabIndex={r * width + c === focusableIdx ? 0 : -1}
aria-label={srGridLabel(r, c)}
onFocus={() => { if (displaySelected?.row !== r || displaySelected?.col !== c) selectCellAt(r, c) }}
onClick={() => onCellClick(r, c)} />
)))}
</div>
</div>
<div className="editor-grid-hint"> <div className="editor-grid-hint">
{mode === 'design' ? t('editor.designHint') {mode === 'design' ? t('editor.designHint')
: mode === 'fill' ? t('editor.fillHint', { dir: editorDirection }) : mode === 'fill' ? t('editor.fillHint', { dir: editorDirection })
@@ -773,7 +848,7 @@ export default function EditorPanel({ onClose }: { onClose: () => void }) {
: t('editor.clueHint')} : t('editor.clueHint')}
</div> </div>
{mode === 'design' && ( {mode === 'design' && (
<Select value={symmetry} onChange={v => { const s = String(v); if (s === 'rotational180' || s === 'mirror' || s === 'none') setSymmetry(s) }} className="btn btn-sm" style={{ marginTop: 8 }} options={[ <Select value={symmetry} onChange={v => { const s = String(v); if (s === 'rotational180' || s === 'mirror' || s === 'none') setSymmetry(s) }} aria-label={t('editor.symmetry')} className="btn btn-sm" style={{ marginTop: 8 }} options={[
{ value: 'rotational180', label: t('editor.rotationalSymmetry') }, { value: 'rotational180', label: t('editor.rotationalSymmetry') },
{ value: 'mirror', label: t('editor.mirrorSymmetry') }, { value: 'mirror', label: t('editor.mirrorSymmetry') },
{ value: 'none', label: t('editor.noSymmetry') }, { value: 'none', label: t('editor.noSymmetry') },
@@ -826,7 +901,9 @@ export default function EditorPanel({ onClose }: { onClose: () => void }) {
{showPublishConfirm && ( {showPublishConfirm && (
<div className="settings-overlay" onClick={() => setShowPublishConfirm(false)}> <div className="settings-overlay" onClick={() => setShowPublishConfirm(false)}>
<div className="editor-publish-dialog" onClick={e => e.stopPropagation()}> <div className="editor-publish-dialog" role="dialog" aria-modal="true" aria-label={t('editor.publishTitle')}
onClick={e => e.stopPropagation()}
onKeyDown={e => { if (e.key === 'Escape') setShowPublishConfirm(false) }}>
<h3>{t('editor.publishTitle')}</h3> <h3>{t('editor.publishTitle')}</h3>
<p>{t('editor.publishDesc')}</p> <p>{t('editor.publishDesc')}</p>
<p>{t('editor.publishNote')}</p> <p>{t('editor.publishNote')}</p>
@@ -835,7 +912,7 @@ export default function EditorPanel({ onClose }: { onClose: () => void }) {
{t('editor.isThemed')} {t('editor.isThemed')}
</label> </label>
{isThemed && ( {isThemed && (
<input type="text" placeholder={t('editor.themePlaceholder')} <input type="text" placeholder={t('editor.themePlaceholder')} aria-label={t('editor.themePlaceholder')}
value={themeName} onChange={e => setThemeName(e.target.value)} value={themeName} onChange={e => setThemeName(e.target.value)}
style={{ width: '100%', marginTop: 6, marginBottom: 6, padding: '0.375rem 0.5rem', style={{ width: '100%', marginTop: 6, marginBottom: 6, padding: '0.375rem 0.5rem',
fontFamily: 'var(--font-body)', fontSize: '0.75rem', fontFamily: 'var(--font-body)', fontSize: '0.75rem',
+7 -3
View File
@@ -4,6 +4,8 @@ import Grid from './Grid'
import CluePanel from './CluePanel' import CluePanel from './CluePanel'
import ActiveClue from './ActiveClue' import ActiveClue from './ActiveClue'
import { useKeyboard } from '../hooks/useKeyboard' import { useKeyboard } from '../hooks/useKeyboard'
import { useNarrator } from '../hooks/useNarrator'
import { useSounds } from '../hooks/useSounds'
import { useStore } from '../store' import { useStore } from '../store'
import type { ClientPuzzle } from '../types' import type { ClientPuzzle } from '../types'
@@ -53,6 +55,8 @@ export default function EmbedPlayer({ puzzleId, showTimer, showHints }: Props) {
const paused = useStore(s => s.paused) const paused = useStore(s => s.paused)
const tick = useStore(s => s.tick) const tick = useStore(s => s.tick)
useKeyboard() useKeyboard()
useNarrator()
useSounds()
// timer // timer
useEffect(() => { useEffect(() => {
@@ -102,8 +106,8 @@ export default function EmbedPlayer({ puzzleId, showTimer, showHints }: Props) {
} }
}, [solved, elapsed]) }, [solved, elapsed])
if (loading) return <div className="embed-loading"><div className="spinner" /></div> if (loading) return <div className="embed-loading" role="status"><div className="spinner" /></div>
if (error) return <div className="embed-error">{error}</div> if (error) return <div className="embed-error" role="alert">{error}</div>
if (!puzzle) return null if (!puzzle) return null
const mins = Math.floor(elapsed / 60) const mins = Math.floor(elapsed / 60)
@@ -111,7 +115,7 @@ export default function EmbedPlayer({ puzzleId, showTimer, showHints }: Props) {
return ( return (
<div className="embed-player"> <div className="embed-player">
{solved && <div className="embed-solved">{t('embed.solved')}</div>} {solved && <div className="embed-solved" role="status">{t('embed.solved')}</div>}
<div className="embed-header"> <div className="embed-header">
<span className="embed-brand">{t('embed.brand')}</span> <span className="embed-brand">{t('embed.brand')}</span>
{showTimer && <span className="embed-timer">{mins}:{secs.toString().padStart(2, '0')}</span>} {showTimer && <span className="embed-timer">{mins}:{secs.toString().padStart(2, '0')}</span>}
+60 -7
View File
@@ -55,20 +55,58 @@ export default function Grid() {
activeSpan?.cells.map(([r, c]) => `${r},${c}`) ?? [], activeSpan?.cells.map(([r, c]) => `${r},${c}`) ?? [],
) )
// on touch devices, move focus into the hidden typing field so the // Build each cell's accessible name so a screen reader in browse mode reads
// on-screen keyboard appears, with the tapped cell announced as its label // the word it belongs to (and the clue on the starting cell), not just empty.
const buildCellLabel = (r: number, c: number): string => { const buildCellLabel = (r: number, c: number): string => {
const st = useStore.getState() const st = useStore.getState()
if (!st.puzzle) return t('grid.cellEmpty') const puzzle = st.puzzle
if (st.puzzle.cells[r][c] === 'black') return t('grid.blackCell') if (!puzzle) return t('grid.cellEmpty')
const num = st.puzzle.cell_numbers[`${r},${c}`] if (puzzle.cells[r][c] === 'black') return t('grid.blackCell')
const num = puzzle.cell_numbers[`${r},${c}`]
const letter = st.entries[r]?.[c] || '' const letter = st.entries[r]?.[c] || ''
const pencilLetter = !letter && st.pencilEntries[r]?.[c] ? st.pencilEntries[r][c] : '' const pencilLetter = !letter && st.pencilEntries[r]?.[c] ? st.pencilEntries[r][c] : ''
const status: CellStatus = st.cellStatus[r]?.[c] || 'default' const status: CellStatus = st.cellStatus[r]?.[c] || 'default'
return t('grid.cellLabel', { row: r + 1, col: c + 1 }) +
let label = t('grid.cellLabel', { row: r + 1, col: c + 1 }) +
(num ? t('grid.cellNumber', { num }) : '') + (num ? t('grid.cellNumber', { num }) : '') +
(letter ? t('grid.cellLetter', { letter }) : t('grid.cellEmpty')) + (letter ? t('grid.cellLetter', { letter }) : t('grid.cellEmpty')) +
(status === 'correct' ? t('grid.cellCorrect') : status === 'incorrect' ? t('grid.cellIncorrect') : status === 'revealed' ? t('grid.cellRevealed') : pencilLetter ? t('grid.cellPencil', { letter: pencilLetter }) : '') (status === 'correct' ? t('grid.cellCorrect') : status === 'incorrect' ? t('grid.cellIncorrect') : status === 'revealed' ? t('grid.cellRevealed') : pencilLetter ? t('grid.cellPencil', { letter: pencilLetter }) : '')
const spansHere = st.wordSpans.filter((s) => s.cells.some(([sr, sc]) => sr === r && sc === c))
for (const s of spansHere) {
const dir = t(s.direction === 'across' ? 'game.across' : 'game.down')
const isStart = s.cells[0][0] === r && s.cells[0][1] === c
if (isStart) {
const clues = s.direction === 'across' ? puzzle.clues_across : puzzle.clues_down
const clue = clues.find((c) => c.number === s.number)
label += clue
? t('grid.cellClue', { number: s.number, dir, clue: clue.text })
: t('grid.cellWord', { number: s.number, dir })
} else {
label += t('grid.cellWord', { number: s.number, dir })
}
}
return label
}
// A compact form for braille displays: the spoken label above is verbose, and
// braille users read faster with short symbolic codes.
const buildCellBrailleLabel = (r: number, c: number): string => {
const st = useStore.getState()
const puzzle = st.puzzle
if (!puzzle) return ''
if (puzzle.cells[r][c] === 'black') return 'black'
const num = puzzle.cell_numbers[`${r},${c}`]
const letter = st.entries[r]?.[c] || ''
const status: CellStatus = st.cellStatus[r]?.[c] || 'default'
let out = `r${r + 1}c${c + 1}`
if (num) out += ` n${num}`
out += letter ? ` ${letter}` : ' blank'
if (status === 'correct') out += ' ok'
else if (status === 'incorrect') out += ' wrong'
else if (status === 'revealed') out += ' revealed'
return out
} }
const focusTypingField = (r: number, c: number) => { const focusTypingField = (r: number, c: number) => {
@@ -80,6 +118,18 @@ export default function Grid() {
} }
} }
// Exactly one cell is a Tab stop so the grid can receive keyboard focus even
// before a cell has been selected.
const focusableIdx = (() => {
if (selectedCell) return selectedCell.row * width + selectedCell.col
for (let r = 0; r < height; r++) {
for (let c = 0; c < width; c++) {
if (puzzle.cells[r][c] !== 'black') return r * width + c
}
}
return 0
})()
return ( return (
<div <div
ref={wrapperRef} ref={wrapperRef}
@@ -239,6 +289,7 @@ export default function Grid() {
const isBlack = puzzle.cells[r][c] === 'black' const isBlack = puzzle.cells[r][c] === 'black'
const isSelected = selectedCell?.row === r && selectedCell?.col === c const isSelected = selectedCell?.row === r && selectedCell?.col === c
const label = buildCellLabel(r, c) const label = buildCellLabel(r, c)
const braille = buildCellBrailleLabel(r, c)
return ( return (
<button <button
key={`sr-${r}-${c}`} key={`sr-${r}-${c}`}
@@ -248,8 +299,10 @@ export default function Grid() {
role="gridcell" role="gridcell"
aria-rowindex={r + 1} aria-rowindex={r + 1}
aria-colindex={c + 1} aria-colindex={c + 1}
tabIndex={isSelected ? 0 : -1} tabIndex={r * width + c === focusableIdx ? 0 : -1}
aria-label={label} aria-label={label}
aria-braillelabel={braille}
onFocus={() => { if (!isSelected && !isBlack) selectCell(r, c) }}
onClick={() => !isBlack && selectCell(r, c)} onClick={() => !isBlack && selectCell(r, c)}
/> />
) )
+6 -26
View File
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'
import { motion, AnimatePresence } from 'motion/react' import { motion, AnimatePresence } from 'motion/react'
import { IconFlameFilled, IconShare, IconSettings, IconPlus, IconTrophyFilled, import { IconFlameFilled, IconShare, IconSettings, IconPlus, IconTrophyFilled,
IconMessageFilled, IconPencilFilled, IconDotsFilled, IconKeyFilled, IconWorldFilled, IconMessageFilled, IconPencilFilled, IconDotsFilled, IconKeyFilled, IconWorldFilled,
IconGraphFilled, IconChartAreaLineFilled, IconMessagesFilled, IconDeviceSpeakerFilled, IconGraphFilled, IconChartAreaLineFilled, IconMessagesFilled,
IconKeyboardFilled, IconX, IconClockFilled, IconArchiveFilled, IconSnowflake, IconKeyboardFilled, IconX, IconClockFilled, IconArchiveFilled, IconSnowflake,
IconTargetArrow } from '@tabler/icons-react' IconTargetArrow } from '@tabler/icons-react'
import { useStore } from '../store' import { useStore } from '../store'
@@ -29,7 +29,6 @@ export default function Header() {
const shortId = useStore((s) => s.shortId) const shortId = useStore((s) => s.shortId)
const streak = useStore((s) => s.streak) const streak = useStore((s) => s.streak)
const streakFreezes = useStore((s) => s.streakFreezes) const streakFreezes = useStore((s) => s.streakFreezes)
const narratorEnabled = useStore((s) => s.narratorEnabled)
const mpRoomCode = useStore((s) => s.mpRoomCode) const mpRoomCode = useStore((s) => s.mpRoomCode)
const mpPlayers = useStore((s) => s.mpPlayers) const mpPlayers = useStore((s) => s.mpPlayers)
const mpConnected = useStore((s) => s.mpConnected) const mpConnected = useStore((s) => s.mpConnected)
@@ -135,13 +134,6 @@ export default function Header() {
<span className="header-freeze"><IconSnowflake size={12} aria-hidden="true" /> {streakFreezes}</span> <span className="header-freeze"><IconSnowflake size={12} aria-hidden="true" /> {streakFreezes}</span>
</Tooltip> </Tooltip>
)} )}
{narratorEnabled && (
<Tooltip text={t('header.narratorActive')}>
<span className="header-narrator-tag" aria-label={t('header.narratorActive')}>
<IconDeviceSpeakerFilled size={12} aria-hidden="true" /> {t('header.narrator')}
</span>
</Tooltip>
)}
{mpConnected && mpRoomCode && ( {mpConnected && mpRoomCode && (
<button type="button" className="header-room-tag" onClick={() => setShowMultiplayer(true)}> <button type="button" className="header-room-tag" onClick={() => setShowMultiplayer(true)}>
<span className="mp-status-dot mp-connected" /> {mpRoomCode} - {mpPlayers.length}p <span className="mp-status-dot mp-connected" /> {mpRoomCode} - {mpPlayers.length}p
@@ -344,12 +336,8 @@ export default function Header() {
<span className="sk-desc">{t('shortcuts.arrows')}</span> <span className="sk-desc">{t('shortcuts.arrows')}</span>
</div> </div>
<div className="sk-row"> <div className="sk-row">
<div className="sk-keys"><kbd>Tab</kbd></div> <div className="sk-keys"><kbd>Home</kbd> / <kbd>End</kbd></div>
<span className="sk-desc">{t('shortcuts.tabNext')}</span> <span className="sk-desc">{t('shortcuts.homeEnd')}</span>
</div>
<div className="sk-row">
<div className="sk-keys"><kbd>Shift</kbd> + <kbd>Tab</kbd></div>
<span className="sk-desc">{t('shortcuts.tabPrev')}</span>
</div> </div>
<div className="sk-row"> <div className="sk-row">
<div className="sk-keys"><kbd>Space</kbd></div> <div className="sk-keys"><kbd>Space</kbd></div>
@@ -379,19 +367,11 @@ export default function Header() {
</div> </div>
</div> </div>
<div className="sk-section"> <div className="sk-section">
<h4 className="sk-section-title">{t('shortcuts.narrator')}</h4> <h4 className="sk-section-title">{t('shortcuts.reading')}</h4>
<div className="sk-grid"> <div className="sk-grid">
<div className="sk-row"> <div className="sk-row">
<div className="sk-keys"><kbd>?</kbd></div> <div className="sk-keys"><kbd>Alt</kbd> + <kbd>W</kbd></div>
<span className="sk-desc">{t('shortcuts.question')}</span> <span className="sk-desc">{t('shortcuts.readWord')}</span>
</div>
<div className="sk-row">
<div className="sk-keys"><kbd>Ctrl</kbd> + <kbd>H</kbd></div>
<span className="sk-desc">{t('shortcuts.ctrlH')}</span>
</div>
<div className="sk-row">
<div className="sk-keys"><kbd>F1</kbd></div>
<span className="sk-desc">{t('shortcuts.f1')}</span>
</div> </div>
</div> </div>
</div> </div>
+1
View File
@@ -49,6 +49,7 @@ export default function InfoBar() {
return ( return (
<div className="info-bar"> <div className="info-bar">
<span className={`timer ${timeWarning ? 'timer-warning' : ''}`}> <span className={`timer ${timeWarning ? 'timer-warning' : ''}`}>
{timeWarning && <span className="visually-hidden">Time is running low</span>}
{isSpeedRun && <IconBoltFilled size={11} style={{ color: '#fbbf24', marginRight: 2 }} aria-hidden="true" />} {isSpeedRun && <IconBoltFilled size={11} style={{ color: '#fbbf24', marginRight: 2 }} aria-hidden="true" />}
{isMarathon && <IconPlayerTrackNextFilled size={11} style={{ color: '#60a5fa', marginRight: 2 }} aria-hidden="true" />} {isMarathon && <IconPlayerTrackNextFilled size={11} style={{ color: '#60a5fa', marginRight: 2 }} aria-hidden="true" />}
<IconClockFilled size={12} style={{ opacity: 0.4, marginRight: 3 }} aria-hidden="true" /> <IconClockFilled size={12} style={{ opacity: 0.4, marginRight: 3 }} aria-hidden="true" />
+9 -4
View File
@@ -178,20 +178,23 @@ export default function MultiplayerPanel({ onClose }: { onClose: () => void }) {
<div className="mp-mode-picker mp-mode-picker-3"> <div className="mp-mode-picker mp-mode-picker-3">
<button <button
className={`mp-mode-btn ${mode === 'coop' ? 'mp-mode-active' : ''}`} className={`mp-mode-btn ${mode === 'coop' ? 'mp-mode-active' : ''}`}
aria-pressed={mode === 'coop'}
onClick={() => setMode('coop')} onClick={() => setMode('coop')}
> >
<IconHeartFilled size={16} /> <IconHeartFilled size={16} aria-hidden="true" />
<span>{t('multiplayer.coop')}</span> <span>{t('multiplayer.coop')}</span>
</button> </button>
<button <button
className={`mp-mode-btn ${mode === 'competitive' ? 'mp-mode-active' : ''}`} className={`mp-mode-btn ${mode === 'competitive' ? 'mp-mode-active' : ''}`}
aria-pressed={mode === 'competitive'}
onClick={() => setMode('competitive')} onClick={() => setMode('competitive')}
> >
<IconBoltFilled size={16} /> <IconBoltFilled size={16} aria-hidden="true" />
<span>{t('multiplayer.race')}</span> <span>{t('multiplayer.race')}</span>
</button> </button>
<button <button
className={`mp-mode-btn ${mode === 'party' ? 'mp-mode-active' : ''}`} className={`mp-mode-btn ${mode === 'party' ? 'mp-mode-active' : ''}`}
aria-pressed={mode === 'party'}
onClick={(e) => { onClick={(e) => {
const wasParty = mode === 'party' const wasParty = mode === 'party'
setMode('party') setMode('party')
@@ -217,13 +220,14 @@ export default function MultiplayerPanel({ onClose }: { onClose: () => void }) {
} }
}} }}
> >
<IconConfettiFilled size={16} /> <IconConfettiFilled size={16} aria-hidden="true" />
<span>{t('multiplayer.partyLabel')}</span> <span>{t('multiplayer.partyLabel')}</span>
</button> </button>
</div> </div>
<AnimatePresence> <AnimatePresence>
{mode === 'party' && ( {mode === 'party' && (
<motion.button type="button" key="party-sub" ref={partyBtnRef} className="mp-party-sub-selected" <motion.button type="button" key="party-sub" ref={partyBtnRef} className="mp-party-sub-selected"
aria-haspopup="listbox" aria-expanded={partyDropOpen}
onClick={() => setPartyDropOpen(!partyDropOpen)} onClick={() => setPartyDropOpen(!partyDropOpen)}
initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }} transition={{ duration: 0.15 }} exit={{ opacity: 0, height: 0 }} transition={{ duration: 0.15 }}
@@ -287,8 +291,9 @@ export default function MultiplayerPanel({ onClose }: { onClose: () => void }) {
className="btn btn-primary" className="btn btn-primary"
onClick={() => handleJoin()} onClick={() => handleJoin()}
disabled={busy || joinCode.length < 4} disabled={busy || joinCode.length < 4}
aria-label={t('multiplayer.joinRoom')}
> >
<IconArrowBigRightFilled size={14} /> <IconArrowBigRightFilled size={14} aria-hidden="true" />
</button> </button>
</div> </div>
</div> </div>
+4 -2
View File
@@ -73,6 +73,7 @@ export default function OnboardingModal() {
type="text" type="text"
className="onboard-username" className="onboard-username"
placeholder={t('common.anonymous')} placeholder={t('common.anonymous')}
aria-label={t('onboarding.chooseName')}
value={username} value={username}
onChange={e => setUsername(e.target.value)} onChange={e => setUsername(e.target.value)}
maxLength={30} maxLength={30}
@@ -96,6 +97,7 @@ export default function OnboardingModal() {
<button <button
key={opt.value} key={opt.value}
className={`btn btn-sm ${lifetime === opt.value ? 'btn-primary' : ''}`} className={`btn btn-sm ${lifetime === opt.value ? 'btn-primary' : ''}`}
aria-pressed={lifetime === opt.value}
onClick={() => setLifetime(opt.value)} onClick={() => setLifetime(opt.value)}
> >
{t(opt.key)} {t(opt.key)}
@@ -120,7 +122,7 @@ export default function OnboardingModal() {
{registering ? t('onboarding.settingUp') : t('onboarding.setupPasskey')} {registering ? t('onboarding.settingUp') : t('onboarding.setupPasskey')}
</button> </button>
)} )}
{passkeyDone && <span className="onboard-success" style={{ display: 'block', marginBottom: 8 }}>{t('onboarding.passkeySaved')}</span>} {passkeyDone && <span className="onboard-success" role="status" style={{ display: 'block', marginBottom: 8 }}>{t('onboarding.passkeySaved')}</span>}
{error && <div className="onboard-error" role="alert">{error}</div>} {error && <div className="onboard-error" role="alert">{error}</div>}
@@ -154,7 +156,7 @@ export default function OnboardingModal() {
<IconArrowRight size={14} /> <IconArrowRight size={14} />
</button> </button>
</div> </div>
{importResult && <p className="settings-hint" style={{ marginTop: 8 }}>{importResult}</p>} {importResult && <p className="settings-hint" role="status" style={{ marginTop: 8 }}>{importResult}</p>}
</motion.div> </motion.div>
</motion.div> </motion.div>
) )
+5 -4
View File
@@ -45,7 +45,7 @@ function Scoreboard({ scores, myId }: { scores: { id: string; name: string; colo
const { t } = useTranslation() const { t } = useTranslation()
const sorted = [...scores].sort((a, b) => b.score - a.score) const sorted = [...scores].sort((a, b) => b.score - a.score)
return ( return (
<div className="party-scoreboard"> <div className="party-scoreboard" aria-live="polite">
<div className="party-scoreboard-title">{t('party.scores')}</div> <div className="party-scoreboard-title">{t('party.scores')}</div>
{sorted.map((p, i) => ( {sorted.map((p, i) => (
<div key={p.id} className={`party-score-row ${p.id === myId ? 'party-score-you' : ''}`}> <div key={p.id} className={`party-score-row ${p.id === myId ? 'party-score-you' : ''}`}>
@@ -124,6 +124,7 @@ function SubmitPhase({ prompt, placeholder, maxLen }: { prompt: string; placehol
) : ( ) : (
<motion.div key="submitted" <motion.div key="submitted"
className="party-submitted" className="party-submitted"
role="status"
initial={{ scale: 0.9, opacity: 0 }} initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }} animate={{ scale: 1, opacity: 1 }}
exit={{ opacity: 0, scale: 0.9 }} exit={{ opacity: 0, scale: 0.9 }}
@@ -180,7 +181,7 @@ function VotePhase({ prompt }: { prompt: string }) {
</div> </div>
<AnimatePresence> <AnimatePresence>
{myVote && ( {myVote && (
<motion.div className="party-voted" <motion.div className="party-voted" role="status"
initial={{ opacity: 0 }} animate={{ opacity: 1 }} initial={{ opacity: 0 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} transition={{ duration: 0.15 }}> exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
{t('party.voteLocked')} {t('party.voteLocked')}
@@ -339,7 +340,7 @@ function SpeedRacePhase() {
<AnimatePresence> <AnimatePresence>
{correctPlayers.length > 0 && ( {correctPlayers.length > 0 && (
<motion.div key="feed" className="party-speed-feed" <motion.div key="feed" className="party-speed-feed" aria-live="polite"
initial={{ opacity: 0 }} animate={{ opacity: 1 }} initial={{ opacity: 0 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} transition={{ duration: 0.15 }}> exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
<AnimatePresence> <AnimatePresence>
@@ -403,7 +404,7 @@ function RouletteChoosePhase() {
</div> </div>
<AnimatePresence> <AnimatePresence>
{myPick && ( {myPick && (
<motion.div className="party-voted" <motion.div className="party-voted" role="status"
initial={{ opacity: 0 }} animate={{ opacity: 1 }} initial={{ opacity: 0 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} transition={{ duration: 0.15 }}> exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
{t('party.lockedIn')} {t('party.lockedIn')}
+5 -2
View File
@@ -62,6 +62,7 @@ export default function PuzzleArchive({ onClose }: { onClose: () => void }) {
exit={{ opacity: 0 }} transition={{ duration: 0.15 }}> exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
<motion.div className="settings-panel" onClick={e => e.stopPropagation()} <motion.div className="settings-panel" onClick={e => e.stopPropagation()}
role="dialog" aria-modal="true" aria-label={t('archive.title')} role="dialog" aria-modal="true" aria-label={t('archive.title')}
onKeyDown={e => { if (e.key === 'Escape') onClose() }}
initial={{ opacity: 0, scale: 0.96, y: 10 }} initial={{ opacity: 0, scale: 0.96, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }} animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 10 }} exit={{ opacity: 0, scale: 0.96, y: 10 }}
@@ -75,7 +76,7 @@ export default function PuzzleArchive({ onClose }: { onClose: () => void }) {
<div className="archive-filters" style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginBottom: 12 }}> <div className="archive-filters" style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginBottom: 12 }}>
{PRESETS.map(p => ( {PRESETS.map(p => (
<button key={p} className={`btn btn-sm${preset === p ? ' btn-active' : ''}`} <button key={p} className={`btn btn-sm${preset === p ? ' btn-active' : ''}`} aria-pressed={preset === p}
onClick={() => setPreset(p)} style={{ textTransform: 'capitalize' }}> onClick={() => setPreset(p)} style={{ textTransform: 'capitalize' }}>
{p === 'all' ? t('archive.all') : p} {p === 'all' ? t('archive.all') : p}
</button> </button>
@@ -88,7 +89,9 @@ export default function PuzzleArchive({ onClose }: { onClose: () => void }) {
)} )}
<div className="cc-list"> <div className="cc-list">
{entries.map((entry, i) => ( {entries.map((entry, i) => (
<div key={`${entry.date}-${entry.preset}-${i}`} className="cc-clue" style={{ cursor: 'pointer' }} onClick={() => play(entry)}> <div key={`${entry.date}-${entry.preset}-${i}`} className="cc-clue" style={{ cursor: 'pointer' }} role="button" tabIndex={0}
onClick={() => play(entry)}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); play(entry) } }}>
<div className="cc-clue-text" style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <div className="cc-clue-text" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{entry.solved && <IconCircleCheckFilled size={13} style={{ color: 'var(--color-correct)', flexShrink: 0 }} aria-hidden="true" />} {entry.solved && <IconCircleCheckFilled size={13} style={{ color: 'var(--color-correct)', flexShrink: 0 }} aria-hidden="true" />}
<span>{entry.date}</span> <span>{entry.date}</span>
+23 -95
View File
@@ -5,7 +5,6 @@ import { IconSunFilled, IconMoonFilled, IconDeviceDesktop, IconEyeFilled, IconTe
IconWorldFilled, IconTrashFilled, IconDownloadFilled, IconLogout2, IconArrowMoveRight, IconWorldFilled, IconTrashFilled, IconDownloadFilled, IconLogout2, IconArrowMoveRight,
IconUserFilled, IconSettingsFilled, IconReceiptFilled, IconDeviceSpeakerFilled, IconUserFilled, IconSettingsFilled, IconReceiptFilled, IconDeviceSpeakerFilled,
IconAccessibleFilled } from '@tabler/icons-react' IconAccessibleFilled } from '@tabler/icons-react'
import * as narrator from '../narrator'
import { motion, AnimatePresence } from 'motion/react' import { motion, AnimatePresence } from 'motion/react'
import { useStore } from '../store' import { useStore } from '../store'
import * as api from '../api' import * as api from '../api'
@@ -91,95 +90,23 @@ function Toggle({ checked, onChange, label, hint, disabled, disabledHint }: { ch
function NarratorSettingsBlock() { function NarratorSettingsBlock() {
const { t } = useTranslation() const { t } = useTranslation()
const narratorEnabled = useStore(s => s.narratorEnabled)
const narratorSettings = useStore(s => s.narratorSettings) const narratorSettings = useStore(s => s.narratorSettings)
const setNarratorEnabled = useStore(s => s.setNarratorEnabled)
const setNarratorSettings = useStore(s => s.setNarratorSettings) const setNarratorSettings = useStore(s => s.setNarratorSettings)
const [voices, setVoices] = useState<SpeechSynthesisVoice[]>([])
const [pendingRate, setPendingRate] = useState(narratorSettings.rate)
const [pendingPitch, setPendingPitch] = useState(narratorSettings.pitch)
useEffect(() => {
if (!narrator.isSupported()) return
const loadVoices = () => setVoices(narrator.getVoices())
loadVoices()
if (window.speechSynthesis.onvoiceschanged !== undefined) {
window.speechSynthesis.onvoiceschanged = loadVoices
}
}, [])
if (!narrator.isSupported()) return (
<p className="settings-hint">{t('settings.narrator.notSupported')}</p>
)
return ( return (
<div className="settings-narrator-controls"> <div className="settings-narrator-controls">
<Toggle checked={narratorEnabled} onChange={v => setNarratorEnabled(v)} <div className="settings-narrator-item">
label={t('settings.narrator.narratorMode')} /> <label className="settings-sublabel">{t('settings.narrator.verbosity')}</label>
<Select value={narratorSettings.verbosity}
<AnimatePresence> onChange={v => { const s = String(v); if (s === 'brief' || s === 'normal' || s === 'verbose') setNarratorSettings({ verbosity: s }) }}
{narratorEnabled && ( options={[
<motion.div className="settings-narrator-detail" { value: 'brief', label: t('settings.narrator.brief') },
initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} { value: 'normal', label: t('settings.narrator.normal') },
exit={{ opacity: 0, height: 0 }} transition={{ duration: 0.15 }} { value: 'verbose', label: t('settings.narrator.verbose') },
style={{ overflow: 'hidden' }}> ]} />
<div className="settings-narrator-grid"> </div>
<div className="settings-narrator-item"> <Toggle checked={narratorSettings.sounds} onChange={v => setNarratorSettings({ sounds: v })}
<label className="settings-sublabel">{t('settings.narrator.speechRate', { rate: pendingRate.toFixed(1) })}</label> label={t('settings.narrator.soundEffects')} />
<input type="range" className="settings-range" min={0.5} max={2} step={0.1} value={pendingRate}
onChange={e => setPendingRate(Number(e.target.value))}
onMouseUp={() => setNarratorSettings({ rate: pendingRate })}
onTouchEnd={() => setNarratorSettings({ rate: pendingRate })} />
</div>
<div className="settings-narrator-item">
<label className="settings-sublabel">{t('settings.narrator.pitch', { pitch: pendingPitch.toFixed(1) })}</label>
<input type="range" className="settings-range" min={0.5} max={2} step={0.1} value={pendingPitch}
onChange={e => setPendingPitch(Number(e.target.value))}
onMouseUp={() => setNarratorSettings({ pitch: pendingPitch })}
onTouchEnd={() => setNarratorSettings({ pitch: pendingPitch })} />
</div>
</div>
{voices.length > 0 && (
<div className="settings-narrator-item">
<label className="settings-sublabel">{t('settings.narrator.voice')}</label>
<Select value={narratorSettings.voice}
onChange={v => setNarratorSettings({ voice: String(v) })}
options={[
{ value: '', label: t('settings.narrator.defaultVoice') },
...voices.map(v => ({ value: v.name, label: `${v.name} (${v.lang})` }))
]} />
</div>
)}
<div className="settings-narrator-item">
<label className="settings-sublabel">{t('settings.narrator.verbosity')}</label>
<Select value={narratorSettings.verbosity}
onChange={v => { const s = String(v); if (s === 'brief' || s === 'normal' || s === 'verbose') setNarratorSettings({ verbosity: s }) }}
options={[
{ value: 'brief', label: t('settings.narrator.brief') },
{ value: 'normal', label: t('settings.narrator.normal') },
{ value: 'verbose', label: t('settings.narrator.verbose') },
]} />
</div>
<div className="settings-narrator-item">
<label className="settings-sublabel">{t('settings.narrator.output')}</label>
<Select value={narratorSettings.mode}
onChange={v => { const s = String(v); if (s === 'voice' || s === 'screenreader' || s === 'both') setNarratorSettings({ mode: s }) }}
options={[
{ value: 'voice', label: t('settings.narrator.outputVoice') },
{ value: 'screenreader', label: t('settings.narrator.outputScreenReader') },
{ value: 'both', label: t('settings.narrator.outputBoth') },
]} />
</div>
<Toggle checked={narratorSettings.sounds} onChange={v => setNarratorSettings({ sounds: v })}
label={t('settings.narrator.soundEffects')} />
</motion.div>
)}
</AnimatePresence>
</div> </div>
) )
} }
@@ -198,6 +125,7 @@ export default function SettingsPanel({ onClose }: { onClose: () => void }) {
const [newBlockedDomain, setNewBlockedDomain] = useState('') const [newBlockedDomain, setNewBlockedDomain] = useState('')
const [tab, setTab] = useState<Tab>('profile') const [tab, setTab] = useState<Tab>('profile')
const [migrateDomain, setMigrateDomain] = useState('') const [migrateDomain, setMigrateDomain] = useState('')
const [importMsg, setImportMsg] = useState('')
const [migrating, setMigrating] = useState(false) const [migrating, setMigrating] = useState(false)
const [migrateMsg, setMigrateMsg] = useState('') const [migrateMsg, setMigrateMsg] = useState('')
const [displayName, setDisplayName] = useState('') const [displayName, setDisplayName] = useState('')
@@ -303,7 +231,7 @@ export default function SettingsPanel({ onClose }: { onClose: () => void }) {
<label className="settings-label">{t('settings.display.theme')}</label> <label className="settings-label">{t('settings.display.theme')}</label>
<div className="settings-row"> <div className="settings-row">
{(['system', 'light', 'dark'] as const).map(th => ( {(['system', 'light', 'dark'] as const).map(th => (
<button key={th} className={`btn btn-sm ${settings.theme === th ? 'btn-primary' : ''}`} onClick={() => update({ theme: th })}> <button key={th} className={`btn btn-sm ${settings.theme === th ? 'btn-primary' : ''}`} aria-pressed={settings.theme === th} onClick={() => update({ theme: th })}>
{th === 'system' ? <><IconDeviceDesktop size={13} style={{ verticalAlign: -2 }} /> {t('settings.display.system')}</> : {th === 'system' ? <><IconDeviceDesktop size={13} style={{ verticalAlign: -2 }} /> {t('settings.display.system')}</> :
th === 'light' ? <><IconSunFilled size={13} style={{ verticalAlign: -2 }} /> {t('settings.display.light')}</> : th === 'light' ? <><IconSunFilled size={13} style={{ verticalAlign: -2 }} /> {t('settings.display.light')}</> :
<><IconMoonFilled size={13} style={{ verticalAlign: -2 }} /> {t('settings.display.dark')}</>} <><IconMoonFilled size={13} style={{ verticalAlign: -2 }} /> {t('settings.display.dark')}</>}
@@ -316,7 +244,7 @@ export default function SettingsPanel({ onClose }: { onClose: () => void }) {
<label className="settings-label"><IconKeyboardFilled size={11} style={{ verticalAlign: -1, marginRight: 4 }} /> {t('settings.display.inputMode')}</label> <label className="settings-label"><IconKeyboardFilled size={11} style={{ verticalAlign: -1, marginRight: 4 }} /> {t('settings.display.inputMode')}</label>
<div className="settings-row"> <div className="settings-row">
{(['auto', 'keyboard', 'stylus'] as const).map(m => ( {(['auto', 'keyboard', 'stylus'] as const).map(m => (
<button key={m} className={`btn btn-sm ${inputMode === m ? 'btn-primary' : ''}`} <button key={m} className={`btn btn-sm ${inputMode === m ? 'btn-primary' : ''}`} aria-pressed={inputMode === m}
onClick={() => { setInputMode(m); api.fetchSettings().then(s => api.saveSettings({ ...s, inputMode: m })) }}> onClick={() => { setInputMode(m); api.fetchSettings().then(s => api.saveSettings({ ...s, inputMode: m })) }}>
{m === 'auto' ? t('settings.display.inputAuto') : m === 'keyboard' ? t('settings.display.inputKeyboard') : t('settings.display.inputStylus')} {m === 'auto' ? t('settings.display.inputAuto') : m === 'keyboard' ? t('settings.display.inputKeyboard') : t('settings.display.inputStylus')}
</button> </button>
@@ -399,7 +327,7 @@ export default function SettingsPanel({ onClose }: { onClose: () => void }) {
<label className="settings-label"><IconCookieFilled size={11} style={{ verticalAlign: -1, marginRight: 4 }} /> {t('settings.session.duration')}</label> <label className="settings-label"><IconCookieFilled size={11} style={{ verticalAlign: -1, marginRight: 4 }} /> {t('settings.session.duration')}</label>
<div className="settings-grid-3"> <div className="settings-grid-3">
{LIFETIME_KEYS.map(opt => ( {LIFETIME_KEYS.map(opt => (
<button key={opt.value} className={`btn btn-sm ${settings.cookieLifetime === opt.value ? 'btn-primary' : ''}`} <button key={opt.value} className={`btn btn-sm ${settings.cookieLifetime === opt.value ? 'btn-primary' : ''}`} aria-pressed={settings.cookieLifetime === opt.value}
onClick={() => update({ cookieLifetime: opt.value })}>{t(opt.key)}</button> onClick={() => update({ cookieLifetime: opt.value })}>{t(opt.key)}</button>
))} ))}
</div> </div>
@@ -420,7 +348,7 @@ export default function SettingsPanel({ onClose }: { onClose: () => void }) {
) : ( ) : (
<p className="settings-hint">{t('settings.session.noPasskeys')}</p> <p className="settings-hint">{t('settings.session.noPasskeys')}</p>
)} )}
{passkeyError && <p className="settings-hint" style={{ color: 'var(--color-error)' }}>{passkeyError}</p>} {passkeyError && <p className="settings-hint" role="alert" style={{ color: 'var(--color-error)' }}>{passkeyError}</p>}
{supportsPasskey && ( {supportsPasskey && (
<button className="btn btn-sm" onClick={addPasskey} disabled={registering} style={{ marginTop: 8 }}> <button className="btn btn-sm" onClick={addPasskey} disabled={registering} style={{ marginTop: 8 }}>
<IconKeyFilled size={13} style={{ verticalAlign: -2 }} /> {registering ? t('settings.session.registering') : t('settings.session.addPasskey')} <IconKeyFilled size={13} style={{ verticalAlign: -2 }} /> {registering ? t('settings.session.registering') : t('settings.session.addPasskey')}
@@ -518,7 +446,7 @@ export default function SettingsPanel({ onClose }: { onClose: () => void }) {
{settings.blockedInstances.length > 0 && ( {settings.blockedInstances.length > 0 && (
<div className="privacy-blocked-list"> <div className="privacy-blocked-list">
{settings.blockedInstances.map(d => ( {settings.blockedInstances.map(d => (
<span key={d} className="privacy-blocked-tag">{d}<button onClick={() => update({ blockedInstances: settings.blockedInstances.filter(x => x !== d) })}><IconX size={10} /></button></span> <span key={d} className="privacy-blocked-tag">{d}<button aria-label="Remove" onClick={() => update({ blockedInstances: settings.blockedInstances.filter(x => x !== d) })}><IconX size={10} aria-hidden="true" /></button></span>
))} ))}
</div> </div>
)} )}
@@ -557,14 +485,14 @@ export default function SettingsPanel({ onClose }: { onClose: () => void }) {
const res = await api.importData(data) const res = await api.importData(data)
let msg = t('onboarding.imported', { count: res.imported }) let msg = t('onboarding.imported', { count: res.imported })
if (res.skipped?.length) msg += ' ' + t('onboarding.skipped', { items: res.skipped.join(', ') }) if (res.skipped?.length) msg += ' ' + t('onboarding.skipped', { items: res.skipped.join(', ') })
alert(msg) setImportMsg(msg)
window.location.reload() } catch (e) { setImportMsg(e instanceof Error ? e.message : t('onboarding.importFailed')) }
} catch (e) { alert(e instanceof Error ? e.message : t('onboarding.importFailed')) }
}; input.click() }; input.click()
}}> }}>
{t('settings.account.import')} {t('settings.account.import')}
</button> </button>
</div> </div>
{importMsg && <p className="settings-hint" role="status" style={{ marginTop: 8 }}>{importMsg}</p>}
</div> </div>
<div className="settings-section"> <div className="settings-section">
@@ -597,7 +525,7 @@ export default function SettingsPanel({ onClose }: { onClose: () => void }) {
}}>{claimingManual ? t('settings.account.claiming') : t('settings.account.claim')}</button> }}>{claimingManual ? t('settings.account.claiming') : t('settings.account.claim')}</button>
</div> </div>
{claimStatus && ( {claimStatus && (
<p className={`receipt-claim-msg ${claimStatus.ok ? 'receipt-claim-msg-ok' : 'receipt-claim-msg-err'}`}> <p className={`receipt-claim-msg ${claimStatus.ok ? 'receipt-claim-msg-ok' : 'receipt-claim-msg-err'}`} role="status">
{claimStatus.msg} {claimStatus.msg}
</p> </p>
)} )}
@@ -637,7 +565,7 @@ export default function SettingsPanel({ onClose }: { onClose: () => void }) {
.finally(() => setMigrating(false)) .finally(() => setMigrating(false))
}}>{migrating ? t('settings.account.migrating') : t('settings.account.migrate')}</button> }}>{migrating ? t('settings.account.migrating') : t('settings.account.migrate')}</button>
</div> </div>
{migrateMsg && <p className="settings-hint" style={{ marginTop: 8 }}>{migrateMsg}</p>} {migrateMsg && <p className="settings-hint" role="status" style={{ marginTop: 8 }}>{migrateMsg}</p>}
</>)} </>)}
</div> </div>
+14
View File
@@ -20,9 +20,23 @@ export default function SolveHeatmap() {
return 'var(--heatmap-clean, #22c55e)' return 'var(--heatmap-clean, #22c55e)'
} }
let clean = 0
let hinted = 0
let wrong = 0
let blocked = 0
for (let r = 0; r < puzzle.height; r++) {
for (let c = 0; c < puzzle.width; c++) {
if (puzzle.cells[r][c] === 'black') { blocked++; continue }
if (cellStatus[r]?.[c] === 'revealed') { hinted++; continue }
if (cellEverWrong[r]?.[c]) { wrong++; continue }
clean++
}
}
return ( return (
<div className="solve-heatmap"> <div className="solve-heatmap">
<h4 className="solve-heatmap-title">{t('heatmap.title')}</h4> <h4 className="solve-heatmap-title">{t('heatmap.title')}</h4>
<span className="visually-hidden">{t('heatmap.title')}: {clean} {t('heatmap.clean')}, {hinted} {t('heatmap.hinted')}, {wrong} {t('heatmap.wrong')}</span>
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="solve-heatmap-grid"> <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="solve-heatmap-grid">
{Array.from({ length: puzzle.height }, (_, r) => {Array.from({ length: puzzle.height }, (_, r) =>
Array.from({ length: puzzle.width }, (_, c) => ( Array.from({ length: puzzle.width }, (_, c) => (
+15 -7
View File
@@ -66,11 +66,18 @@ function RichSelect({ value, options, onChange, label }: {
} }
}, [open, updatePos]) }, [open, updatePos])
useEffect(() => {
if (open) {
dropRef.current?.querySelector<HTMLElement>('button')?.focus()
}
}, [open])
return ( return (
<div className="ed-rich-select"> <div className="ed-rich-select">
<label className="ed-select-label">{label}</label> <label className="ed-select-label">{label}</label>
<button ref={triggerRef} type="button" className="ed-rich-trigger" onClick={() => setOpen(!open)} <button ref={triggerRef} type="button" className="ed-rich-trigger" onClick={() => setOpen(!open)}
aria-haspopup="listbox" aria-expanded={open}> aria-haspopup="listbox" aria-expanded={open}
onKeyDown={e => { if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); setOpen(true) } }}>
{selected?.icon && <selected.icon size={13} className="ed-rich-trigger-icon" aria-hidden="true" />} {selected?.icon && <selected.icon size={13} className="ed-rich-trigger-icon" aria-hidden="true" />}
<span className="ed-rich-trigger-label">{selected?.label || ''}</span> <span className="ed-rich-trigger-label">{selected?.label || ''}</span>
<IconChevronDownFilled size={9} className="ed-rich-trigger-chevron" style={{ transform: open ? 'rotate(180deg)' : 'none' }} aria-hidden="true" /> <IconChevronDownFilled size={9} className="ed-rich-trigger-chevron" style={{ transform: open ? 'rotate(180deg)' : 'none' }} aria-hidden="true" />
@@ -243,7 +250,7 @@ export default function Welcome() {
{/* custom puzzle */} {/* custom puzzle */}
<div className="ed-drawer"> <div className="ed-drawer">
<button type="button" className="ed-drawer-toggle" onClick={() => setShowCustomPanel(!showCustomPanel)}> <button type="button" className="ed-drawer-toggle" aria-expanded={showCustomPanel} onClick={() => setShowCustomPanel(!showCustomPanel)}>
<h2>{t('welcome.customPuzzle')}</h2> <h2>{t('welcome.customPuzzle')}</h2>
<IconChevronDownFilled size={10} className="ed-drawer-chevron" style={{ transform: showCustomPanel ? 'rotate(180deg)' : 'none' }} /> <IconChevronDownFilled size={10} className="ed-drawer-chevron" style={{ transform: showCustomPanel ? 'rotate(180deg)' : 'none' }} />
</button> </button>
@@ -373,7 +380,7 @@ export default function Welcome() {
{/* community puzzles */} {/* community puzzles */}
{allCommunity.length > 0 && ( {allCommunity.length > 0 && (
<div className="ed-drawer"> <div className="ed-drawer">
<button type="button" className="ed-drawer-toggle" onClick={() => setShowCommunity(!showCommunity)}> <button type="button" className="ed-drawer-toggle" aria-expanded={showCommunity} onClick={() => setShowCommunity(!showCommunity)}>
<h2>{t('welcome.communityPuzzles')}</h2><span className="ed-drawer-badge">{allCommunity.length}</span> <h2>{t('welcome.communityPuzzles')}</h2><span className="ed-drawer-badge">{allCommunity.length}</span>
<IconChevronDownFilled size={10} className="ed-drawer-chevron" style={{ transform: showCommunity ? 'rotate(180deg)' : 'none' }} /> <IconChevronDownFilled size={10} className="ed-drawer-chevron" style={{ transform: showCommunity ? 'rotate(180deg)' : 'none' }} />
</button> </button>
@@ -385,15 +392,16 @@ export default function Welcome() {
style={{ overflow: 'hidden' }}> style={{ overflow: 'hidden' }}>
<div className="ed-puzzle-grid"> <div className="ed-puzzle-grid">
{allCommunity.slice(0, 8).map(p => ( {allCommunity.slice(0, 8).map(p => (
<button key={p.id} type="button" className="ed-puzzle-card" <div key={p.id} className="ed-puzzle-card" role="button" tabIndex={0}
onClick={() => !loading && loadCommunityPuzzle(p.id)} disabled={loading}> onClick={() => !loading && loadCommunityPuzzle(p.id)}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (!loading) loadCommunityPuzzle(p.id) } }}>
<span className="ed-puzzle-size">{p.width}x{p.height}</span> <span className="ed-puzzle-size">{p.width}x{p.height}</span>
<span className="ed-puzzle-title">{p.title}</span> <span className="ed-puzzle-title">{p.title}</span>
<span className="ed-puzzle-by"> <span className="ed-puzzle-by">
{p.origin ? `from ${p.origin}` : p.author ? `by ${p.author}` : ''} {p.origin ? `from ${p.origin}` : p.author ? `by ${p.author}` : ''}
</span> </span>
<span className="ed-puzzle-report"><ReportButton contentType="puzzle" contentId={p.id} /></span> <span className="ed-puzzle-report" onClick={e => e.stopPropagation()}><ReportButton contentType="puzzle" contentId={p.id} /></span>
</button> </div>
))} ))}
</div> </div>
</motion.div> </motion.div>
+38 -3
View File
@@ -38,11 +38,18 @@ export default function DatePicker({ value, onChange, className }: Props) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const triggerRef = useRef<HTMLButtonElement>(null) const triggerRef = useRef<HTMLButtonElement>(null)
const calRef = useRef<HTMLDivElement>(null) const calRef = useRef<HTMLDivElement>(null)
const dayRefs = useRef<(HTMLButtonElement | null)[]>([])
const [focusDay, setFocusDay] = useState<number | null>(null)
const { year, month } = parse(value) const { year, month } = parse(value)
const [viewYear, setViewYear] = useState(year) const [viewYear, setViewYear] = useState(year)
const [viewMonth, setViewMonth] = useState(month) const [viewMonth, setViewMonth] = useState(month)
const [pos, setPos] = useState({ top: 0, left: 0 }) const [pos, setPos] = useState({ top: 0, left: 0 })
// the day of the selected value, when it falls inside the current view
const selected = parse(value)
const selectedInView =
selected.year === viewYear && selected.month === viewMonth ? selected.day : 1
const updatePos = useCallback(() => { const updatePos = useCallback(() => {
if (!triggerRef.current) return if (!triggerRef.current) return
const rect = triggerRef.current.getBoundingClientRect() const rect = triggerRef.current.getBoundingClientRect()
@@ -60,7 +67,7 @@ export default function DatePicker({ value, onChange, className }: Props) {
} }
} }
function onKey(e: KeyboardEvent) { function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false) if (e.key === 'Escape') { setFocusDay(null); setOpen(false); triggerRef.current?.focus() }
} }
window.addEventListener('scroll', updatePos, true) window.addEventListener('scroll', updatePos, true)
document.addEventListener('mousedown', onDown) document.addEventListener('mousedown', onDown)
@@ -87,12 +94,38 @@ export default function DatePicker({ value, onChange, className }: Props) {
const pick = (d: number) => { const pick = (d: number) => {
onChange(fmt(viewYear, viewMonth, d)) onChange(fmt(viewYear, viewMonth, d))
setFocusDay(null)
setOpen(false) setOpen(false)
} }
const focusDayRef = (d: number) => {
setFocusDay(d)
dayRefs.current[d - 1]?.focus()
}
const onGridKeyDown = (e: { key: string; preventDefault(): void }) => {
const cur = focusDay != null ? focusDay : selectedInView
let next = cur
if (e.key === 'ArrowRight') next = cur + 1
else if (e.key === 'ArrowLeft') next = cur - 1
else if (e.key === 'ArrowDown') next = cur + 7
else if (e.key === 'ArrowUp') next = cur - 7
else return
e.preventDefault()
if (next < 1 || next > days) return
focusDayRef(next)
}
useEffect(() => {
if (!open) return
const d = focusDay != null && focusDay >= 1 && focusDay <= days ? focusDay : selectedInView
dayRefs.current[d - 1]?.focus()
}, [open, days, selectedInView])
return ( return (
<div className={`cv-datepicker ${className || ''}`}> <div className={`cv-datepicker ${className || ''}`}>
<button className="cv-select-trigger" ref={triggerRef} onClick={() => setOpen(!open)} type="button"> <button className="cv-select-trigger" ref={triggerRef} onClick={() => setOpen(!open)} type="button"
aria-haspopup="dialog" aria-expanded={open}>
<span>{value}</span> <span>{value}</span>
<IconChevronDownFilled size={10} style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 150ms' }} /> <IconChevronDownFilled size={10} style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 150ms' }} />
</button> </button>
@@ -107,7 +140,7 @@ export default function DatePicker({ value, onChange, className }: Props) {
<div className="cv-cal-days" role="grid"> <div className="cv-cal-days" role="grid">
{WEEKDAYS.map(d => <span key={d} className="cv-cal-dayname">{d}</span>)} {WEEKDAYS.map(d => <span key={d} className="cv-cal-dayname">{d}</span>)}
</div> </div>
<div className="cv-cal-grid" role="grid"> <div className="cv-cal-grid" role="grid" onKeyDown={onGridKeyDown}>
{Array.from({ length: offset }, (_, i) => <span key={`pad-${i}`} />)} {Array.from({ length: offset }, (_, i) => <span key={`pad-${i}`} />)}
{Array.from({ length: days }, (_, i) => { {Array.from({ length: days }, (_, i) => {
const d = i + 1 const d = i + 1
@@ -117,8 +150,10 @@ export default function DatePicker({ value, onChange, className }: Props) {
return ( return (
<button <button
key={d} key={d}
ref={el => { dayRefs.current[d - 1] = el }}
className={`cv-cal-day ${isSelected ? 'cv-cal-day-selected' : ''} ${isToday ? 'cv-cal-day-today' : ''}`} className={`cv-cal-day ${isSelected ? 'cv-cal-day-selected' : ''} ${isToday ? 'cv-cal-day-today' : ''}`}
onClick={() => pick(d)} onClick={() => pick(d)}
onFocus={() => setFocusDay(d)}
type="button" type="button"
aria-selected={isSelected} aria-selected={isSelected}
> >
+1 -1
View File
@@ -118,7 +118,7 @@ export default function Select({ value, options, onChange, className, style }: P
aria-selected={String(o.value) === String(value)} aria-selected={String(o.value) === String(value)}
tabIndex={i === (selectedIndex >= 0 ? selectedIndex : 0) ? 0 : -1} tabIndex={i === (selectedIndex >= 0 ? selectedIndex : 0) ? 0 : -1}
className={`cv-select-option ${String(o.value) === String(value) ? 'cv-select-option-active' : ''}`} className={`cv-select-option ${String(o.value) === String(value) ? 'cv-select-option-active' : ''}`}
onClick={() => { onChange(o.value); close() }} onClick={() => { onChange(o.value); close(); triggerRef.current?.focus() }}
onKeyDown={e => onOptionKeyDown(e, i)} onKeyDown={e => onOptionKeyDown(e, i)}
type="button" type="button"
> >
+7 -3
View File
@@ -1,4 +1,4 @@
import { useState, useRef, useCallback, useEffect, useId } from 'react' import { useState, useRef, useCallback, useEffect, useId, isValidElement, cloneElement } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
interface Props { interface Props {
@@ -59,6 +59,11 @@ export default function Tooltip({ text, children, delay = 400 }: Props) {
if (!text) return <>{children}</> if (!text) return <>{children}</>
// SAFETY: isValidElement checked children is an element before this cast.
const child = isValidElement(children)
? cloneElement(children as React.ReactElement<{ 'aria-describedby'?: string }>, { 'aria-describedby': visible ? tipId : undefined })
: children
return ( return (
<div <div
ref={ref} ref={ref}
@@ -66,10 +71,9 @@ export default function Tooltip({ text, children, delay = 400 }: Props) {
onMouseLeave={hide} onMouseLeave={hide}
onFocus={show} onFocus={show}
onBlur={hide} onBlur={hide}
aria-describedby={visible ? tipId : undefined}
className="cv-tooltip-wrap" className="cv-tooltip-wrap"
> >
{children} {child}
{visible && createPortal( {visible && createPortal(
<div <div
ref={tipRef} ref={tipRef}
+48 -189
View File
@@ -1,117 +1,34 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import { useStore } from '../store' import { useStore } from '../store'
import type { Direction } from '../types'
import i18n from '../i18n' import i18n from '../i18n'
import * as narrator from '../narrator' import { announce } from '../narrator'
function readCurrentCell() {
const t = i18n.t
const { puzzle, selectedCell, entries, direction, wordSpans, activeClue } = useStore.getState()
if (!puzzle || !selectedCell) {
narrator.speak(t('a11y.noCellSelected'))
return
}
const { row, col } = selectedCell
const letter = entries[row]?.[col] || ''
const num = puzzle.cell_numbers[`${row},${col}`]
const span = wordSpans.find(s => s.number === activeClue && s.direction === direction)
const pos = span ? span.cells.findIndex(([r, c]) => r === row && c === col) + 1 : 0
const len = span?.cells.length || 0
let msg = t('grid.cellLabel', { row: row + 1, col: col + 1 })
if (num) msg += t('grid.cellNumber', { num })
msg += letter ? t('grid.cellLetter', { letter }) : t('grid.cellEmpty')
msg += `, ${t(direction === 'across' ? 'game.across' : 'game.down')}`
if (pos && len) msg += t('a11y.position', { pos, len })
narrator.speak(msg)
}
function readWord(dir: Direction) {
const t = i18n.t
const { puzzle, selectedCell, entries, wordSpans } = useStore.getState()
if (!puzzle || !selectedCell) {
narrator.speak(t('a11y.noCellSelected'))
return
}
const span = wordSpans.find(s =>
s.direction === dir && s.cells.some(([r, c]) => r === selectedCell.row && c === selectedCell.col)
)
if (!span) {
narrator.speak(t(dir === 'across' ? 'a11y.noAcrossWord' : 'a11y.noDownWord'), false)
return
}
const letters = span.cells.map(([r, c]) => entries[r]?.[c] || t('a11y.empty')).join(', ')
const dirLabel = t(dir === 'across' ? 'game.across' : 'game.down')
narrator.speak(t('a11y.wordReading', { dir: dirLabel, number: span.number, length: span.cells.length, letters }), false)
}
function readCurrentClue() {
const t = i18n.t
const { puzzle, activeClue, direction, wordSpans, entries, narratorSettings } = useStore.getState()
if (!puzzle || activeClue === null) {
narrator.speak(t('a11y.noClueSelected'))
return
}
const clues = direction === 'across' ? puzzle.clues_across : puzzle.clues_down
const clue = clues.find(c => c.number === activeClue)
if (!clue) return
const span = wordSpans.find(s => s.number === activeClue && s.direction === direction)
const len = span?.cells.length || 0
const filled = span ? span.cells.filter(([r, c]) => entries[r]?.[c]).length : 0
const v = narratorSettings.verbosity
const dirLabel = t(direction === 'across' ? 'game.across' : 'game.down')
if (v === 'brief') {
narrator.speak(t('a11y.clueBrief', { number: activeClue, dir: dirLabel, text: clue.text }))
} else if (v === 'normal') {
narrator.speak(t('a11y.clueNormal', { number: activeClue, dir: dirLabel, length: len, text: clue.text }))
} else {
narrator.speak(t('a11y.clueVerbose', { number: activeClue, dir: dirLabel, length: len, filled, text: clue.text }))
}
}
function readPuzzleOverview() {
const t = i18n.t
const { puzzle, entries, wordSpans } = useStore.getState()
if (!puzzle) {
narrator.speak(t('a11y.noPuzzleLoaded'))
return
}
let totalCells = 0
let filledCells = 0
for (let r = 0; r < puzzle.height; r++) {
for (let c = 0; c < puzzle.width; c++) {
if (puzzle.cells[r][c] !== 'black') {
totalCells++
if (entries[r]?.[c]) filledCells++
}
}
}
const totalClues = puzzle.clues_across.length + puzzle.clues_down.length
const completedWords = wordSpans.filter(s =>
s.cells.every(([r, c]) => entries[r]?.[c])
).length
const remaining = totalClues - completedWords
narrator.speak(
t('a11y.overview', {
width: puzzle.width, height: puzzle.height,
filled: filledCells, total: totalCells, remaining,
})
)
}
function getGridEl(): HTMLElement | null { function getGridEl(): HTMLElement | null {
return document.querySelector('.grid-wrapper') return document.querySelector('.grid-sr-grid')
} }
// tracks whether focus (or the last pointer interaction) is inside the grid, // Announce the current clue and the fill pattern of its word (e.g. "1 Across,
// so typing after clicking a header button does not fill grid cells // 6 letters: P, blank, blank, blank, blank, T. What is a baby boar called.").
let gridHasFocus = false function readWordPattern() {
const t = i18n.t
function updateGridFocus(target: EventTarget | null) { const { puzzle, wordSpans, entries, activeClue, direction } = useStore.getState()
gridHasFocus = !!getGridEl()?.contains(document.activeElement) || (target instanceof Element && !!getGridEl()?.contains(target)) if (!puzzle || activeClue === null) {
announce(t('a11y.noClueSelected'))
return
}
const span = wordSpans.find((s) => s.number === activeClue && s.direction === direction)
if (!span) return
const pattern = span.cells.map(([r, c]) => entries[r]?.[c] || t('a11y.blank')).join(', ')
const clues = direction === 'across' ? puzzle.clues_across : puzzle.clues_down
const clue = clues.find((c) => c.number === activeClue)
const dir = t(direction === 'across' ? 'game.across' : 'game.down')
announce(t('a11y.wordPattern', {
number: activeClue,
dir,
length: span.cells.length,
pattern,
clue: clue ? clue.text : '',
}))
} }
export function useKeyboard() { export function useKeyboard() {
@@ -119,100 +36,42 @@ export function useKeyboard() {
const typeLetter = useStore((s) => s.typeLetter) const typeLetter = useStore((s) => s.typeLetter)
const deleteLetter = useStore((s) => s.deleteLetter) const deleteLetter = useStore((s) => s.deleteLetter)
const moveSelection = useStore((s) => s.moveSelection) const moveSelection = useStore((s) => s.moveSelection)
const moveRowEdge = useStore((s) => s.moveRowEdge)
const toggleDirection = useStore((s) => s.toggleDirection) const toggleDirection = useStore((s) => s.toggleDirection)
const tabClue = useStore((s) => s.tabClue)
const narratorEnabled = useStore((s) => s.narratorEnabled)
const undo = useStore((s) => s.undo) const undo = useStore((s) => s.undo)
const redo = useStore((s) => s.redo) const redo = useStore((s) => s.redo)
useEffect(() => { useEffect(() => {
if (!puzzle) return if (!puzzle) return
const grid = getGridEl()
gridHasFocus = !!getGridEl()?.contains(document.activeElement) if (!grid) return
const onPointerDown = (e: PointerEvent) => updateGridFocus(e.target)
const onFocusIn = (e: FocusEvent) => updateGridFocus(e.target)
document.addEventListener('pointerdown', onPointerDown, true)
document.addEventListener('focusin', onFocusIn)
// give the grid initial keyboard focus when nothing else is focused
if (document.activeElement === document.body || document.activeElement === null) {
const firstCell = getGridEl()?.querySelector('button.grid-sr-cell')
if (firstCell instanceof HTMLButtonElement) firstCell.focus()
}
function onKeyDown(e: KeyboardEvent) { function onKeyDown(e: KeyboardEvent) {
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLSelectElement
)
return
// only handle letter input when the grid area has focus
const gridFocused = gridHasFocus || !!getGridEl()?.contains(document.activeElement)
const key = e.key const key = e.key
const narr = useStore.getState().narratorEnabled const ctrl = e.ctrlKey || e.metaKey
// narrator-only shortcuts - only when grid is focused if (ctrl && key.toLowerCase() === 'z' && !e.shiftKey) {
if (narr && gridFocused) {
if (key === ' ') {
e.preventDefault()
readCurrentCell()
return
}
if (key === '?' || key === '/' || (e.altKey && key.toLowerCase() === 'p')) {
e.preventDefault()
readCurrentClue()
return
}
if ((e.ctrlKey && key.toLowerCase() === 'h') || key === 'F1') {
e.preventDefault()
readPuzzleOverview()
return
}
if (e.altKey && key.toLowerCase() === 'a') {
e.preventDefault()
readWord('across')
return
}
if (e.altKey && key.toLowerCase() === 'd') {
e.preventDefault()
readWord('down')
return
}
if (key === 'Tab') {
e.preventDefault()
tabClue(!e.shiftKey)
// clue announcement handled by useNarrator hook
return
}
}
if (gridFocused && e.ctrlKey && key.toLowerCase() === 'z' && !e.shiftKey) {
e.preventDefault() e.preventDefault()
undo() undo()
return return
} }
if (gridFocused && ((e.ctrlKey && key.toLowerCase() === 'y') || (e.ctrlKey && e.shiftKey && key.toLowerCase() === 'z'))) { if (ctrl && (key.toLowerCase() === 'y' || (e.shiftKey && key.toLowerCase() === 'z'))) {
e.preventDefault() e.preventDefault()
redo() redo()
return return
} }
if (e.altKey && key.toLowerCase() === 'w') {
e.preventDefault()
readWordPattern()
return
}
if (gridFocused && key.length === 1 && key >= 'a' && key <= 'z') { if (!e.ctrlKey && !e.metaKey && !e.altKey && key.length === 1 &&
((key >= 'a' && key <= 'z') || (key >= 'A' && key <= 'Z'))) {
e.preventDefault() e.preventDefault()
typeLetter(key) typeLetter(key)
return return
} }
if (gridFocused && key.length === 1 && key >= 'A' && key <= 'Z') {
e.preventDefault()
typeLetter(key)
return
}
if (!gridFocused) return
switch (key) { switch (key) {
case 'Backspace': case 'Backspace':
@@ -239,22 +98,22 @@ export function useKeyboard() {
e.preventDefault() e.preventDefault()
moveSelection(0, 1) moveSelection(0, 1)
break break
case 'Home':
e.preventDefault()
moveRowEdge(true)
break
case 'End':
e.preventDefault()
moveRowEdge(false)
break
case ' ': case ' ':
e.preventDefault() e.preventDefault()
toggleDirection() toggleDirection()
break break
case 'Tab':
e.preventDefault()
tabClue(!e.shiftKey)
break
} }
} }
document.addEventListener('keydown', onKeyDown) grid.addEventListener('keydown', onKeyDown)
return () => { return () => grid.removeEventListener('keydown', onKeyDown)
document.removeEventListener('keydown', onKeyDown) }, [puzzle, typeLetter, deleteLetter, moveSelection, moveRowEdge, toggleDirection, undo, redo])
document.removeEventListener('pointerdown', onPointerDown, true)
document.removeEventListener('focusin', onFocusIn)
}
}, [puzzle, typeLetter, deleteLetter, moveSelection, toggleDirection, tabClue, narratorEnabled, undo, redo])
} }
+59 -86
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { useStore } from '../store' import { useStore } from '../store'
import i18n from '../i18n' import i18n from '../i18n'
import * as narrator from '../narrator' import { announce } from '../narrator'
function formatTime(secs: number, t: (key: string, opts?: { count: number }) => string): string { function formatTime(secs: number, t: (key: string, opts?: { count: number }) => string): string {
const m = Math.floor(secs / 60) const m = Math.floor(secs / 60)
@@ -12,77 +12,54 @@ function formatTime(secs: number, t: (key: string, opts?: { count: number }) =>
} }
export function useNarrator() { export function useNarrator() {
const prevCell = useRef<{ row: number; col: number } | null>(null) const prevClueKey = useRef<string | null>(null)
const prevDirection = useRef<string | null>(null)
const prevEntries = useRef<string[][] | null>(null) const prevEntries = useRef<string[][] | null>(null)
const prevActiveClue = useRef<number | null>(null)
const prevSolved = useRef(false) const prevSolved = useRef(false)
// sync settings to narrator module whenever they change
const narratorSettings = useStore(s => s.narratorSettings)
useEffect(() => {
narrator.updateSettings(narratorSettings)
}, [narratorSettings])
// subscribe to store changes
useEffect(() => { useEffect(() => {
const unsub = useStore.subscribe((state, prev) => { const unsub = useStore.subscribe((state, prev) => {
if (!state.narratorEnabled || !state.puzzle) return if (!state.puzzle) return
const t = i18n.t const t = i18n.t
const v = state.narratorSettings.verbosity const v = state.narratorSettings.verbosity
// direction change // Active clue change (moving into a new word or toggling direction).
if (state.direction !== prevDirection.current && prevDirection.current !== null) { if (state.activeClue !== null) {
narrator.speak(t('a11y.nowSolving', { dir: t(state.direction === 'across' ? 'game.across' : 'game.down') })) const key = `${state.activeClue}-${state.direction}`
} if (key !== prevClueKey.current) {
prevDirection.current = state.direction const clues = state.direction === 'across' ? state.puzzle.clues_across : state.puzzle.clues_down
const clue = clues.find((c) => c.number === state.activeClue)
if (clue) {
const span = state.wordSpans.find((s) => s.number === state.activeClue && s.direction === state.direction)
const len = span?.cells.length || 0
const filled = span ? span.cells.filter(([r, c]) => state.entries[r]?.[c]).length : 0
const dir = t(state.direction === 'across' ? 'game.across' : 'game.down')
// cell selection if (v === 'brief') {
if (state.selectedCell && announce(t('a11y.clueBrief', { number: state.activeClue, dir, text: clue.text }))
(state.selectedCell.row !== prevCell.current?.row || } else if (v === 'normal') {
state.selectedCell.col !== prevCell.current?.col)) { announce(t('a11y.clueNormal', { number: state.activeClue, dir, length: len, text: clue.text }))
} else {
const { row, col } = state.selectedCell announce(t('a11y.clueVerbose', { number: state.activeClue, dir, length: len, filled, text: clue.text }))
const letter = state.entries[row]?.[col] || '' }
const cellContent = letter ? letter : t('a11y.empty')
if (v === 'brief') {
narrator.speak(`Row ${row + 1}, Column ${col + 1}`)
} else if (v === 'normal') {
narrator.speak(`Row ${row + 1}, Column ${col + 1}, ${cellContent}, ${state.direction}`)
} else {
const puzzle = state.puzzle
let neighbors = ''
if (col > 0 && puzzle.cells[row][col - 1] !== 'black') {
const l = state.entries[row][col - 1]
neighbors += l ? `${l} to the left` : 'blank to the left'
} }
if (row > 0 && puzzle.cells[row - 1]?.[col] !== 'black') {
const l = state.entries[row - 1]?.[col]
if (neighbors) neighbors += ', '
neighbors += l ? `${l} above` : 'blank above'
}
let msg = `Row ${row + 1}, Column ${col + 1}, ${cellContent}, ${state.direction}`
if (neighbors) msg += `. Neighboring: ${neighbors}`
narrator.speak(msg)
} }
} }
prevCell.current = state.selectedCell prevClueKey.current = state.activeClue !== null ? `${state.activeClue}-${state.direction}` : null
// letter entry - detect new letter in the grid // letter entry and deletion
if (prevEntries.current && state.entries !== prevEntries.current) { if (prevEntries.current && state.entries !== prevEntries.current) {
for (let r = 0; r < state.entries.length; r++) { for (let r = 0; r < state.entries.length; r++) {
for (let c = 0; c < state.entries[r].length; c++) { for (let c = 0; c < state.entries[r].length; c++) {
const cur = state.entries[r][c] const cur = state.entries[r][c]
const old = prevEntries.current[r]?.[c] || '' const old = prevEntries.current[r]?.[c] || ''
if (cur && !old) { if (cur && !old) {
if (v === 'brief') narrator.speak(cur, false) if (v === 'brief') announce(cur)
else if (v === 'normal') narrator.speak(t('a11y.entered', { letter: cur }), false) else if (v === 'normal') announce(t('a11y.entered', { letter: cur }))
else narrator.speak(t('a11y.enteredAt', { letter: cur, row: r + 1, col: c + 1 }), false) else announce(t('a11y.enteredAt', { letter: cur, row: r + 1, col: c + 1 }))
// check word completion // word completion
const span = state.wordSpans.find(s => const span = state.wordSpans.find((s) =>
s.cells.some(([sr, sc]) => sr === r && sc === c) s.cells.some(([sr, sc]) => sr === r && sc === c),
) )
if (span) { if (span) {
const allFilled = span.cells.every(([wr, wc]) => state.entries[wr]?.[wc]) const allFilled = span.cells.every(([wr, wc]) => state.entries[wr]?.[wc])
@@ -90,58 +67,54 @@ export function useNarrator() {
const wasFilled = span.cells.every(([wr, wc]) => prevEntries.current?.[wr]?.[wc]) const wasFilled = span.cells.every(([wr, wc]) => prevEntries.current?.[wr]?.[wc])
if (!wasFilled) { if (!wasFilled) {
const word = span.cells.map(([wr, wc]) => state.entries[wr][wc]).join('') const word = span.cells.map(([wr, wc]) => state.entries[wr][wc]).join('')
setTimeout(() => narrator.speak(t('a11y.wordComplete', { word: word.split('').join(' ') }), false), 300) setTimeout(() => announce(t('a11y.wordComplete', { word: word.split('').join(' ') })), 300)
} }
} }
} }
} else if (!cur && old) {
if (v !== 'brief') announce(t('a11y.deleted'))
} }
} }
} }
} }
prevEntries.current = state.entries prevEntries.current = state.entries
// clue focus change // correct / incorrect / revealed cell feedback
if (state.activeClue !== null && state.activeClue !== prevActiveClue.current) { if (prev.cellStatus !== state.cellStatus) {
const clues = state.direction === 'across' ? state.puzzle.clues_across : state.puzzle.clues_down let correct = 0
const clue = clues.find(c => c.number === state.activeClue) let incorrect = 0
if (clue) { const revealed: [number, number][] = []
const span = state.wordSpans.find(s => s.number === state.activeClue && s.direction === state.direction)
const len = span?.cells.length || 0
const filled = span ? span.cells.filter(([r, c]) => state.entries[r]?.[c]).length : 0
if (v === 'brief') {
narrator.speak(`${state.activeClue} ${t(state.direction === 'across' ? 'game.across' : 'game.down')}: ${clue.text}`, false)
} else if (v === 'normal') {
narrator.speak(t('a11y.clueNormal', { number: state.activeClue, dir: t(state.direction === 'across' ? 'game.across' : 'game.down'), length: len, text: clue.text }), false)
} else {
narrator.speak(t('a11y.clueVerbose', { number: state.activeClue, dir: t(state.direction === 'across' ? 'game.across' : 'game.down'), length: len, filled, text: clue.text }), false)
}
}
}
prevActiveClue.current = state.activeClue
// hint reveal - detect newly revealed cells
if (prev.cellStatus !== state.cellStatus && prevEntries.current) {
for (let r = 0; r < state.cellStatus.length; r++) { for (let r = 0; r < state.cellStatus.length; r++) {
for (let c = 0; c < state.cellStatus[r].length; c++) { for (let c = 0; c < state.cellStatus[r].length; c++) {
if (state.cellStatus[r][c] === 'revealed' && prev.cellStatus[r]?.[c] !== 'revealed') { const ns = state.cellStatus[r][c]
const letter = state.entries[r][c] const ps = prev.cellStatus[r]?.[c]
const span = state.wordSpans.find(s => if (ns === ps) continue
s.cells.some(([sr, sc]) => sr === r && sc === c) if (ns === 'correct') correct++
) else if (ns === 'incorrect') incorrect++
const pos = span ? span.cells.findIndex(([sr, sc]) => sr === r && sc === c) + 1 : 0 else if (ns === 'revealed') revealed.push([r, c])
if (letter && pos) {
narrator.speak(t('a11y.revealed', { letter, pos }), false)
}
}
} }
} }
if (incorrect > 0) {
announce(incorrect === 1 ? t('a11y.incorrect') : t('a11y.incorrectMany', { count: incorrect }))
} else if (correct > 0) {
announce(correct === 1 ? t('a11y.correct') : t('a11y.correctMany', { count: correct }))
}
for (const [r, c] of revealed) {
const letter = state.entries[r][c]
const span = state.wordSpans.find((s) =>
s.cells.some(([sr, sc]) => sr === r && sc === c),
)
const pos = span ? span.cells.findIndex(([sr, sc]) => sr === r && sc === c) + 1 : 0
if (letter && pos) announce(t('a11y.revealed', { letter, pos }))
}
} }
// puzzle solved // puzzle solved
if (state.solved && !prevSolved.current) { if (state.solved && !prevSolved.current) {
const time = formatTime(state.elapsed, t) const time = formatTime(state.elapsed, t)
narrator.speak(t('a11y.solvedTime', { time })) announce(t('a11y.solvedTime', { time }))
} }
prevSolved.current = state.solved prevSolved.current = state.solved
}) })
-6
View File
@@ -1,6 +0,0 @@
// stylus handling is now built into InkOverlay.tsx directly
// this file kept for import compatibility
export function useStylus(
_canvasRef: React.RefObject<HTMLCanvasElement | null>,
_svgRef: React.RefObject<SVGSVGElement | null>,
) {}
+44 -11
View File
@@ -86,6 +86,36 @@
cursor: default; cursor: default;
} }
.grid-sr-cell:focus-visible {
outline: none;
box-shadow: inset 0 0 0 3px var(--accent);
}
.editor-grid-wrap {
position: relative;
}
.editor-sr-grid {
position: absolute;
inset: 0;
display: grid;
}
.editor-sr-cell {
border: 0;
padding: 0;
margin: 0;
background: transparent;
pointer-events: none;
outline: none;
cursor: default;
}
.editor-sr-cell:focus-visible {
outline: none;
box-shadow: inset 0 0 0 3px var(--accent);
}
/* hidden typing field so touch devices can summon the on-screen keyboard */ /* hidden typing field so touch devices can summon the on-screen keyboard */
.grid-type-input { .grid-type-input {
position: fixed; position: fixed;
@@ -567,6 +597,20 @@ body { font-family: var(--font-body); background: var(--bg); color: var(--text);
.grid-wrapper:focus-within { .grid-wrapper:focus-within {
outline: 2px solid var(--accent); outline-offset: 2px; outline: 2px solid var(--accent); outline-offset: 2px;
} }
.clue-nav {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-top: 0.5rem;
}
.clue-nav-current {
font-family: var(--font-mono);
font-size: 0.8125rem;
color: var(--text-tertiary);
}
@keyframes gridReveal { @keyframes gridReveal {
from { opacity: 0; transform: scale(0.96); } from { opacity: 0; transform: scale(0.96); }
to { opacity: 1; transform: scale(1); } to { opacity: 1; transform: scale(1); }
@@ -1195,8 +1239,6 @@ input[type="range"] { accent-color: var(--accent); }
color: var(--text-tertiary); margin-top: 0.125rem; color: var(--text-tertiary); margin-top: 0.125rem;
} }
.settings-narrator-controls { display: flex; flex-direction: column; gap: 0.5rem; } .settings-narrator-controls { display: flex; flex-direction: column; gap: 0.5rem; }
.settings-narrator-detail { display: flex; flex-direction: column; gap: 0.75rem; padding-top: 0.5rem; }
.settings-narrator-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
.settings-narrator-item { display: flex; flex-direction: column; gap: 0.25rem; } .settings-narrator-item { display: flex; flex-direction: column; gap: 0.25rem; }
.settings-label { .settings-label {
display: block; font-family: var(--font-mono); font-size: 0.563rem; font-weight: 500; display: block; font-family: var(--font-mono); font-size: 0.563rem; font-weight: 500;
@@ -3383,15 +3425,6 @@ svg text {
} }
.header-room-tag:hover { border-color: var(--border); } .header-room-tag:hover { border-color: var(--border); }
.header-narrator-tag {
display: inline-flex; align-items: center; gap: 0.25rem;
font-family: var(--font-mono); font-size: 0.563rem;
letter-spacing: 0.06em; color: var(--accent);
padding: 0.125rem 0.5rem;
border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
opacity: 0.8;
}
/* welcome join room */ /* welcome join room */
.welcome-join-room { .welcome-join-room {
display: flex; align-items: center; gap: 0.375rem; display: flex; align-items: center; gap: 0.375rem;
+24 -20
View File
@@ -91,9 +91,19 @@
"clueNormal": "{{number}} {{dir}}, {{length}} letters: {{text}}", "clueNormal": "{{number}} {{dir}}, {{length}} letters: {{text}}",
"clueVerbose": "{{number}} {{dir}}, {{length}} letters, {{filled}} filled: {{text}}", "clueVerbose": "{{number}} {{dir}}, {{length}} letters, {{filled}} filled: {{text}}",
"empty": "empty", "empty": "empty",
"blank": "blank",
"wordPattern": "{{number}} {{dir}}, {{length}} letters: {{pattern}}. {{clue}}",
"clueNavigation": "Clue navigation",
"previousClue": "Previous clue",
"nextClue": "Next clue",
"nowSolving": "Now solving {{dir}}", "nowSolving": "Now solving {{dir}}",
"entered": "Entered {{letter}}", "entered": "Entered {{letter}}",
"enteredAt": "Entered {{letter}} at Row {{row}}, Column {{col}}", "enteredAt": "Entered {{letter}} at Row {{row}}, Column {{col}}",
"deleted": "Deleted",
"correct": "Correct",
"correctMany": "{{count}} correct",
"incorrect": "Incorrect",
"incorrectMany": "{{count}} incorrect",
"wordComplete": "Word complete: {{word}}", "wordComplete": "Word complete: {{word}}",
"revealed": "Revealed letter {{letter}} at position {{pos}}", "revealed": "Revealed letter {{letter}} at position {{pos}}",
"solvedTime": "Puzzle complete. Time: {{time}}", "solvedTime": "Puzzle complete. Time: {{time}}",
@@ -118,6 +128,8 @@
"cellIncorrect": ", incorrect", "cellIncorrect": ", incorrect",
"cellRevealed": ", revealed", "cellRevealed": ", revealed",
"cellPencil": ", pencil letter {{letter}}", "cellPencil": ", pencil letter {{letter}}",
"cellWord": ", {{number}} {{dir}}",
"cellClue": ", {{number}} {{dir}}: {{clue}}",
"handwritingCanvas": "Handwriting input area" "handwritingCanvas": "Handwriting input area"
}, },
"welcome": { "welcome": {
@@ -163,8 +175,6 @@
"aboutInstance": "About this instance", "aboutInstance": "About this instance",
"signInPasskey": "Sign in with passkey", "signInPasskey": "Sign in with passkey",
"keyboardShortcuts": "Keyboard shortcuts", "keyboardShortcuts": "Keyboard shortcuts",
"narrator": "Narrator",
"narratorActive": "Narrator active",
"newPuzzle": "New puzzle", "newPuzzle": "New puzzle",
"startNewPuzzle": "Start a new puzzle", "startNewPuzzle": "Start a new puzzle",
"custom": "Custom", "custom": "Custom",
@@ -191,20 +201,19 @@
"subtitle": "Available while the grid is focused", "subtitle": "Available while the grid is focused",
"navigation": "Navigation", "navigation": "Navigation",
"editing": "Editing", "editing": "Editing",
"narrator": "Narrator mode",
"arrows": "Move between cells", "arrows": "Move between cells",
"tabNext": "Jump to next clue", "tabNext": "Jump to next clue",
"tabPrev": "Jump to previous clue", "tabPrev": "Jump to previous clue",
"space": "Toggle across / down", "space": "Toggle across / down",
"escape": "Deselect cell", "escape": "Deselect cell",
"homeEnd": "Jump to start / end of row",
"reading": "Reading",
"readWord": "Read current clue and word",
"letters": "Enter a letter", "letters": "Enter a letter",
"backspace": "Clear current cell", "backspace": "Clear current cell",
"delete": "Clear and move back", "delete": "Clear and move back",
"ctrlZ": "Undo last entry", "ctrlZ": "Undo last entry",
"ctrlY": "Redo last entry", "ctrlY": "Redo last entry"
"question": "Re-read current clue",
"ctrlH": "Puzzle overview",
"f1": "Puzzle overview"
}, },
"difficulty": { "difficulty": {
"veryEasy": "Very easy", "veryEasy": "Very easy",
@@ -565,23 +574,13 @@
"pushComingSoon": "Coming soon" "pushComingSoon": "Coming soon"
}, },
"narrator": { "narrator": {
"title": "Narrator", "title": "Screen reader",
"narratorMode": "Narrator mode", "hint": "Announces cell positions, clues, and game events to screen readers and braille displays",
"hint": "Speaks cell positions, clues, and game events aloud",
"speechRate": "Speech rate: {{rate}}x",
"pitch": "Pitch: {{pitch}}x",
"voice": "Voice",
"defaultVoice": "Default",
"verbosity": "Verbosity", "verbosity": "Verbosity",
"brief": "Brief", "brief": "Brief",
"normal": "Normal", "normal": "Normal",
"verbose": "Verbose", "verbose": "Verbose",
"output": "Output", "soundEffects": "Sound effects"
"outputVoice": "Voice",
"outputScreenReader": "Screen reader",
"outputBoth": "Both",
"soundEffects": "Sound effects",
"notSupported": "Your browser does not support speech synthesis"
}, },
"session": { "session": {
"duration": "Session duration", "duration": "Session duration",
@@ -700,6 +699,11 @@
"rotationalSymmetry": "Rotational symmetry", "rotationalSymmetry": "Rotational symmetry",
"mirrorSymmetry": "Mirror symmetry", "mirrorSymmetry": "Mirror symmetry",
"noSymmetry": "No symmetry", "noSymmetry": "No symmetry",
"symmetry": "Symmetry",
"width": "Width",
"height": "Height",
"delete": "Delete",
"offensivePattern": "That grid pattern is not allowed",
"checkAnswers": "Check answers", "checkAnswers": "Check answers",
"cluesClickToEdit": "Clues (click to edit)", "cluesClickToEdit": "Clues (click to edit)",
"writeClue": "Write a clue...", "writeClue": "Write a clue...",
+12 -73
View File
@@ -1,98 +1,37 @@
export interface NarratorSettings { export interface NarratorSettings {
enabled: boolean
rate: number
pitch: number
voice: string
verbosity: 'brief' | 'normal' | 'verbose' verbosity: 'brief' | 'normal' | 'verbose'
mode: 'voice' | 'screenreader' | 'both'
sounds: boolean sounds: boolean
} }
export const defaultNarratorSettings: NarratorSettings = { export const defaultNarratorSettings: NarratorSettings = {
enabled: false,
rate: 1.0,
pitch: 1.0,
voice: '',
verbosity: 'normal', verbosity: 'normal',
mode: 'voice',
sounds: true, sounds: true,
} }
let settings: NarratorSettings = { ...defaultNarratorSettings } // A polite live region that the screen reader (speech and braille) announces
// from. There is no self-voicing here: announcing through the accessibility
export function updateSettings(s: NarratorSettings) { // tree lets the user's own screen reader and braille display do the reading.
settings = s
}
export function isSupported(): boolean {
return 'speechSynthesis' in window
}
export function getVoices(): SpeechSynthesisVoice[] {
if (!isSupported()) return []
return window.speechSynthesis.getVoices()
}
export function cancel() {
if (!isSupported()) return
window.speechSynthesis.cancel()
}
// a polite live region that screen readers announce from, created on demand
let srRegion: HTMLElement | null = null let srRegion: HTMLElement | null = null
let regionTimer: number | undefined
function getSrRegion(): HTMLElement { function getSrRegion(): HTMLElement {
if (!srRegion) { if (!srRegion) {
srRegion = document.createElement('div') srRegion = document.createElement('div')
srRegion.setAttribute('aria-live', 'polite') srRegion.setAttribute('aria-live', 'polite')
srRegion.setAttribute('role', 'status') srRegion.setAttribute('role', 'status')
srRegion.setAttribute('aria-atomic', 'true')
srRegion.className = 'visually-hidden' srRegion.className = 'visually-hidden'
document.body.appendChild(srRegion) document.body.appendChild(srRegion)
} }
return srRegion return srRegion
} }
function announceToScreenReader(text: string) { export function announce(text: string) {
const region = getSrRegion() const region = getSrRegion()
const msg = document.createElement('span') window.clearTimeout(regionTimer)
msg.textContent = `${text} ` // Clear then set in a later task so repeated identical text still re-announces.
region.appendChild(msg) region.textContent = ''
while (region.children.length > 6) { regionTimer = window.setTimeout(() => {
const first = region.firstChild region.textContent = text
if (first) region.removeChild(first) }, 0)
}
setTimeout(() => msg.remove(), 15000)
} }
export function speak(text: string, interrupt = true) {
if (!settings.enabled) return
const mode = settings.mode
const wantsVoice = (mode === 'voice' || mode === 'both') && isSupported()
const wantsSr = mode === 'screenreader' || mode === 'both' || !isSupported()
if (wantsSr) announceToScreenReader(text)
if (!wantsVoice) return
if (interrupt) cancel()
const utterance = new SpeechSynthesisUtterance(text)
utterance.rate = settings.rate
utterance.pitch = settings.pitch
if (settings.voice) {
const voices = getVoices()
const match = voices.find(v => v.name === settings.voice)
if (match) utterance.voice = match
}
window.speechSynthesis.speak(utterance)
}
// voices load async - call this early so they're ready
export function preloadVoices() {
if (!isSupported()) return
getVoices()
}
preloadVoices()
+22 -9
View File
@@ -138,9 +138,7 @@ interface PuzzleStore {
pencilEntries: string[][] pencilEntries: string[][]
togglePencilMode: () => void togglePencilMode: () => void
narratorEnabled: boolean
narratorSettings: NarratorSettings narratorSettings: NarratorSettings
setNarratorEnabled: (enabled: boolean) => void
setNarratorSettings: (settings: Partial<NarratorSettings>) => void setNarratorSettings: (settings: Partial<NarratorSettings>) => void
partySubmitAnswer: (answer: string) => void partySubmitAnswer: (answer: string) => void
@@ -168,6 +166,7 @@ interface PuzzleStore {
typeLetter: (letter: string) => void typeLetter: (letter: string) => void
deleteLetter: () => void deleteLetter: () => void
moveSelection: (dr: number, dc: number) => void moveSelection: (dr: number, dc: number) => void
moveRowEdge: (home: boolean) => void
tabClue: (forward: boolean) => void tabClue: (forward: boolean) => void
deselectCell: () => void deselectCell: () => void
validateSolution: () => Promise<void> validateSolution: () => Promise<void>
@@ -638,16 +637,10 @@ export const useStore = create<PuzzleStore>((set, get) => ({
pencilMode: false, pencilMode: false,
pencilEntries: [], pencilEntries: [],
narratorEnabled: false,
narratorSettings: { ...defaultNarratorSettings }, narratorSettings: { ...defaultNarratorSettings },
setNarratorEnabled: (enabled: boolean) => {
set({ narratorEnabled: enabled, narratorSettings: { ...get().narratorSettings, enabled } })
},
setNarratorSettings: (partial: Partial<NarratorSettings>) => { setNarratorSettings: (partial: Partial<NarratorSettings>) => {
const next = { ...get().narratorSettings, ...partial } set({ narratorSettings: { ...get().narratorSettings, ...partial } })
set({ narratorSettings: next, narratorEnabled: next.enabled })
}, },
undo: () => { undo: () => {
@@ -1335,6 +1328,26 @@ export const useStore = create<PuzzleStore>((set, get) => ({
} }
}, },
moveRowEdge: (home) => {
const { selectedCell, puzzle, wordSpans, direction } = get()
if (!selectedCell || !puzzle) return
const row = selectedCell.row
const width = puzzle.width
let col: number | null = null
if (home) {
for (let c = 0; c < width; c++) {
if (puzzle.cells[row][c] !== 'black') { col = c; break }
}
} else {
for (let c = width - 1; c >= 0; c--) {
if (puzzle.cells[row][c] !== 'black') { col = c; break }
}
}
if (col === null) return
const span = findClueForCell(wordSpans, row, col, direction)
set({ selectedCell: { row, col }, activeClue: span?.number ?? get().activeClue })
},
tabClue: (forward) => { tabClue: (forward) => {
const { wordSpans, direction, activeClue, puzzle } = get() const { wordSpans, direction, activeClue, puzzle } = get()
if (!puzzle) return if (!puzzle) return