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!(
- "
",
+ 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");
}