diff --git a/plan/post-to-bluesky.md b/plan/post-to-bluesky.md index 3757d8b..bf6a6cf 100644 --- a/plan/post-to-bluesky.md +++ b/plan/post-to-bluesky.md @@ -254,9 +254,11 @@ this epic does not answer it. og:image. A match still being fought reloads itself. - [x] **The card is chosen from a gallery, and the post is just the link.** A finished match draws several cards, so the share block is a gallery: - one slide per card this match can actually draw, turned with `:target` - like the record sheets, each slide showing that card and posting that - card's own address. Only eligible cards are listed - a duel needs two + one slide per card this match can actually draw, turned by a radio + group whose arrows and dots are labels for it, each slide showing that + card and posting that card's own address. A radio rather than + `:target`, which the record sheets use: a fragment scrolls the gallery + to the top of the viewport, so the page jumped on every press. Only eligible cards are listed - a duel needs two forces, a lance needs a point of view, the challenge card needs a challenge, the battlefield needs a board render. The composer carries the link alone: the verdict, the scenario and the round are drawn on diff --git a/services/api/src/share.rs b/services/api/src/share.rs index a92cc15..12a6cbf 100644 --- a/services/api/src/share.rs +++ b/services/api/src/share.rs @@ -1314,11 +1314,16 @@ fn unit_id(force: usize, unit: usize) -> String { /// its own permalink and its own composer, so what is on screen and what /// would be posted are the same thing. /// -/// No script: the slides are `:target`, the way the record sheets are. The -/// consequence worth knowing is that turning the gallery does not change this -/// page's own og:image, which is fixed at render. It does not have to - each -/// slide's post links to that card's address, and the unfurler reads the card -/// from there. +/// No script: the slides are a radio group, and the arrows and dots are +/// labels for it. `:target` would be the shorter markup, but following a +/// fragment scrolls the gallery to the top of the viewport on every press - +/// the page jumps where only the card should turn. A radio changes nothing +/// but its own state. +/// +/// The consequence worth knowing is that turning the gallery does not change +/// this page's own og:image, which is fixed at render. It does not have to - +/// each slide's post links to that card's address, and the unfurler reads the +/// card from there. fn share_menu(page: &Page) -> String { // A reader who arrived from somebody's post is not posting it again. The // composer belongs to the player the match handed here, and nothing else @@ -1326,7 +1331,7 @@ fn share_menu(page: &Page) -> String { if !page.participant { return String::new(); } - let slides: Vec<(card::Layout, String, String)> = page + let slides: Vec<(card::Layout, String, String, String)> = page .offered .iter() .map(|layout| { @@ -1335,12 +1340,13 @@ fn share_menu(page: &Page) -> String { *layout, format!("{}{}", page.base, query), slide_id(*layout), + pick_id(*layout), ) }) .collect(); - // The one shown before anything is targeted, and the one every other - // slide hides for. + // The one whose radio is checked at render, and so the one showing + // before anything is pressed. let opening = page .offered .iter() @@ -1348,16 +1354,16 @@ fn share_menu(page: &Page) -> String { .unwrap_or(0); let mut out = String::from("
"); - for (index, (layout, address, id)) in slides.iter().enumerate() { - let previous = &slides[(index + slides.len() - 1) % slides.len()].2; - let next = &slides[(index + 1) % slides.len()].2; - let opens = if index == opening { " opens" } else { "" }; + for (index, (layout, address, id, pick)) in slides.iter().enumerate() { + let previous = &slides[(index + slides.len() - 1) % slides.len()].3; + let next = &slides[(index + 1) % slides.len()].3; + let opens = if index == opening { " checked" } else { "" }; let turn = if slides.len() > 1 { format!( - "\u{276e}\ - \u{276f}", + "\ + ", previous = escape(previous), next = escape(next), ) @@ -1366,19 +1372,22 @@ fn share_menu(page: &Page) -> String { }; let dots: String = slides .iter() - .map(|(_, _, other)| { + .map(|(other_layout, _, other, other_pick)| { format!( - "", + "", here = if other == id { " here" } else { "" }, - other = escape(other), - label = escape(layout.label()), + other_pick = escape(other_pick), + label = escape(other_layout.label()), ) }) .collect(); let compose = compose_url(address); out.push_str(&format!( - "
\ + "\ +
\
\ \"The\ @@ -1388,6 +1397,7 @@ fn share_menu(page: &Page) -> String { \ Post report to Bluesky\
", + pick = escape(pick), id = escape(id), picture = escape(&format!( "{}/card.png{}", @@ -1402,11 +1412,17 @@ fn share_menu(page: &Page) -> String { out } -/// The fragment one card's slide answers to. +/// The id one card's slide carries. fn slide_id(layout: card::Layout) -> String { format!("card-{}", layout.slug()) } +/// The id of the radio that shows one card's slide, and so what the arrows +/// and dots on every other slide are labels for. +fn pick_id(layout: card::Layout) -> String { + format!("pick-{}", layout.slug()) +} + /// The query that names a point of view and a card. fn card_query(perspective: Option<&Perspective>, layout: Option) -> String { let mut parts: Vec = Vec::new(); @@ -2317,20 +2333,27 @@ color:var(--mark);font-size:.7rem;text-transform:uppercase;letter-spacing:.1em}\ font-size:.66rem;font-weight:700;letter-spacing:.16em;\ text-transform:uppercase;color:var(--mark)}\ .share{position:relative;margin:18px 0 0}\ -/* One slide at a time: the targeted one, or the opening one while nothing is \ - targeted. Written as two rules that cannot both match rather than as a \ - show and an override - when the opening slide is also the targeted one, \ - the override is the more specific selector and hides the slide it was \ - meant to reveal, which is a gallery that goes blank on the way round. */\ +/* One slide at a time, chosen by the radio immediately before it. A radio \ + rather than `:target`: following a fragment scrolls the gallery to the top \ + of the viewport, so the whole page jumps on every press. The radios are \ + rendered - kept out of sight rather than out of the box tree - so they \ + stay focusable and the group can be turned with the arrow keys, and they \ + are fixed so that focusing one can never scroll anything either. */\ +.pickr{position:fixed;top:0;left:0;width:1px;height:1px;margin:0;\ +opacity:0;pointer-events:none}\ .slide{display:none}\ -.slide:target{display:block}\ -.share:not(:has(.slide:target)) .slide.opens{display:block}\ +.pickr:checked+.slide{display:block}\ +/* The labels cannot take focus, so the ring belongs to the radio holding it \ + and is drawn around the card that radio is showing. */\ +.pickr:focus-visible+.slide .framed{outline:2px solid var(--mark);\ +outline-offset:3px}\ .framed{position:relative}\ .cardshot{display:block;width:100%;border:1px solid var(--line);border-radius:6px}\ /* Full height of the card, because they are the control this block is for: \ a chevron floating in the middle of a busy picture is something to hunt \ for. The scrim is what makes them legible over any card. */\ .turn{position:absolute;top:0;bottom:0;width:16%;min-width:3.6rem;\ +cursor:pointer;user-select:none;-webkit-user-select:none;\ display:flex;align-items:center;justify-content:center;\ color:var(--mark);font-size:2.2rem;line-height:1;text-decoration:none;\ text-shadow:0 2px 12px rgba(0,0,0,.95)}\ @@ -2338,17 +2361,18 @@ text-shadow:0 2px 12px rgba(0,0,0,.95)}\ background:linear-gradient(90deg,rgba(6,10,15,.92),rgba(6,10,15,.05))}\ .turn.on{right:0;border-radius:0 6px 6px 0;\ background:linear-gradient(270deg,rgba(6,10,15,.92),rgba(6,10,15,.05))}\ -.turn:hover,.turn:focus-visible{color:var(--bg);outline:0}\ +.turn:hover{color:var(--bg)}\ /* The glyph is knocked out of a lit column on hover, so the control is \ unmistakably a control rather than a mark on the picture. */\ -.turn.back:hover,.turn.back:focus-visible{\ +.turn.back:hover{\ background:linear-gradient(90deg,rgba(79,193,214,.94),rgba(79,193,214,.06))}\ -.turn.on:hover,.turn.on:focus-visible{\ +.turn.on:hover{\ background:linear-gradient(270deg,rgba(79,193,214,.94),rgba(79,193,214,.06))}\ .pick{display:flex;align-items:center;gap:8px;margin:8px 0 14px}\ .named{font-size:.72rem;font-weight:700;letter-spacing:.14em;text-transform:uppercase;\ color:var(--muted);margin-right:auto}\ -.dot{width:8px;height:8px;border-radius:50%;background:var(--line);display:block}\ +.dot{width:8px;height:8px;border-radius:50%;background:var(--line);display:block;\ +cursor:pointer}\ .dot.here{background:var(--mark)}\ .dot:hover{background:var(--accent)}\ .sharerow{display:flex;justify-content:center}\ @@ -3322,9 +3346,14 @@ mod tests { "no composer for {slug}" ); } - // One slide stands before anything is targeted, and it is the chosen + // One slide stands before anything is pressed, and it is the chosen // one rather than whichever happens to be first in the document. - assert_eq!(html.matches("class=\"slide opens\"").count(), 1); + assert_eq!(html.matches(" checked ").count(), 1); + let chosen = format!("id=\"pick-{}\" checked", offered[0].slug()); + assert!( + html.contains(&chosen), + "the opening slide is not the chosen one" + ); } /// Both halves of the ask travel together. The card decides which picture @@ -3365,20 +3394,27 @@ mod tests { } } - /// The opening slide is also a slide somebody can turn back to, and the - /// rule that hides it must not be able to match it. Written as an - /// override it out-specifies `:target` and the gallery goes blank on the - /// way round - which is not visible to any test that only reads markup, - /// so the shape of the rule is asserted instead. + /// Turning the gallery must not move the page. A fragment link scrolls + /// the targeted slide to the top of the viewport, so the arrows are + /// labels for a radio group and nothing in the block is an anchor to + /// itself - which is not visible to any test that only reads markup, so + /// the shape of the rule is asserted alongside it. #[test] - fn the_opening_slide_survives_being_targeted() { + fn turning_the_gallery_does_not_move_the_page() { + assert!( + STYLE.contains(".pickr:checked+.slide{display:block}"), + "a slide is shown by its own radio" + ); assert!( - STYLE.contains(".share:not(:has(.slide:target)) .slide.opens{display:block}"), - "the opening slide is shown by an override rather than a guard" + !STYLE.contains(".slide:target"), + "the gallery is back on fragments, and the page will jump" ); + let html = page(); + let block = &html[html.find("
").expect("a share block")..]; + let block = &block[..block.find("
").expect("an end") + 12]; assert!( - !STYLE.contains(".share:has(.slide:target) .slide.opens{display:none}"), - "the override that hides the targeted opening slide is back" + !block.contains("href=\"#"), + "a fragment link inside the gallery" ); } @@ -3533,6 +3569,8 @@ mod tests { }); let _ = (&summary, &context); assert!(!html.contains("class=\"turn"), "arrows with one card"); + // The single slide still shows: its own radio is the checked one. + assert!(html.contains(" checked "), "nothing shows the only card"); assert!(html.contains("Post report to Bluesky"), "still postable"); }