diff --git a/web/scripts/lobby-screen.test.mjs b/web/scripts/lobby-screen.test.mjs --- a/web/scripts/lobby-screen.test.mjs +++ b/web/scripts/lobby-screen.test.mjs @@ -232,29 +232,79 @@ ); }); -test("a held slot says so, and still offers the host the two ways to fill it", () => { +test("a held slot says so, and still offers the one way to fill it", () => { const actions = source.match( /function seatActions\(slotName: string\): HTMLElement \{[\s\S]*?\n \}/, ); assert.ok(actions, "seatActions moved or was renamed"); assert.match( actions[0], - /if \(lockedSlots\.includes\(slotName\)\) \{[\s\S]*?takeIt\.disabled = true;[\s\S]*?botIt\.disabled = true;/, - "a held slot still offers the two ways in that the API refuses - they " + - "would fail silently", + /if \(locked\) \{[\s\S]*?you\.disabled = true;[\s\S]*?bot\.disabled = true;/, + "a held slot still offers the two answers the API refuses - they would " + + "fail silently", ); assert.match( actions[0], - /lockButton\(slotName\)/, - "the hold control left the open slot's actions", + /textContent: locked \? "Held for somebody"/, + "a held slot no longer says it is held, so its two dead answers say " + + "nothing about why", ); - // The invite form stays: inviting somebody into a held slot is what the - // host held it for. + // Inviting stays: it is what a hold was for, and the API allows it. assert.match( actions[0], /inviteForm/, - "the invite form is gone from a slot's actions - a lock is a " + - "reservation, and inviting is how the host fills it", + "the invite form is gone from an open force's answers - a hold is a " + + "reservation, and inviting is how it gets filled", + ); + assert.doesNotMatch( + source, + /setLock/, + "the Hold control is back - reserving a force for somebody who may " + + "never arrive is the reverse of what an open force is for", + ); +}); + +test("Fill is stamped and disabled rather than left off the control", () => { + const actions = source.match( + /function seatActions\(slotName: string\): HTMLElement \{[\s\S]*?\n \}/, + ); + assert.ok(actions, "seatActions moved or was renamed"); + const fill = actions[0].slice(actions[0].indexOf("const fill =")); + assert.match( + fill, + /disabled: true/, + "Fill is pressable - matchmaking does not exist, and a control that " + + "answers nothing is worse than one that says so", + ); + assert.match( + fill, + /soonStamp\("seat"\)/, + "Fill lost the site's own coming-soon mark", + ); + assert.match( + fill, + /notReadyNote\(\)/, + "Fill's label no longer carries the note the stamp cannot give a " + + "screen reader", + ); +}); + +test("the invite field is what Invite opens, and it keeps what was typed", () => { + const actions = source.match( + /function seatActions\(slotName: string\): HTMLElement \{[\s\S]*?\n \}/, + ); + assert.ok(actions, "seatActions moved or was renamed"); + assert.match( + actions[0], + /inviteForm\.hidden = !inviting\.has\(slotName\)/, + "the handle field is showing on every open force again - the screen is " + + "mostly a roster, and that is one text input per force", + ); + assert.match( + actions[0], + /inviteInput\.value = inviting\.get\(slotName\) \?\? ""/, + "a half-typed handle no longer survives a seat snapshot, which rebuilds " + + "every card from scratch", ); }); @@ -488,12 +538,12 @@ assert.match( fn[0], /socket\?\.claimSeat\(slotName\)/, - '"Take it yourself" no longer calls claimSeat', + '"You" no longer calls claimSeat', ); assert.match( fn[0], /socket\?\.assignBot\(slotName\)/, - '"Assign the bot" no longer calls assignBot', + '"AI" no longer calls assignBot', ); }); @@ -991,7 +1041,7 @@ branch[0], /seats = current\.slots\.map\(\(slot, index\) =>/, "a daily no longer seats every faction it fields - Bug Company has four, " + - "and seating only two left the rest offering 'Take it yourself' and " + + "and seating only two left the rest offering an open force's answers " + "Deploy disabled forever (updateDeploy wants every slot claimed)", ); assert.match( diff --git a/web/scripts/soon-stamp.test.mjs b/web/scripts/soon-stamp.test.mjs --- a/web/scripts/soon-stamp.test.mjs +++ b/web/scripts/soon-stamp.test.mjs @@ -50,10 +50,10 @@ // union rather than in any string a search would find; Base.astro's is // written out. A variant with no rule is a bare word sitting on a card. const variants = new Set([ - ...[...stamp.matchAll(/"(fact|mode|tile)"/g)].map((m) => m[1]), + ...[...stamp.matchAll(/"(fact|mode|tile|seat)"/g)].map((m) => m[1]), ...[...layout.matchAll(/stamp-soon stamp-(\w+)"/g)].map((m) => m[1]), ]); - assert.ok(variants.size >= 4, "the variants stopped being found at all"); + assert.ok(variants.size >= 5, "the variants stopped being found at all"); assert.ok(css.includes(".stamp-soon {"), "styles.css has no .stamp-soon"); for (const variant of variants) { diff --git a/web/src/soon-stamp.ts b/web/src/soon-stamp.ts --- a/web/src/soon-stamp.ts +++ b/web/src/soon-stamp.ts @@ -4,11 +4,12 @@ * * The same mark as the alpha stamp next door in alpha-stamp.ts, for a related * job: that one says the whole site is unfinished, this one says which pieces - * of it are not there at all. Four places. Over the facts on the front page + * of it are not there at all. Five places. Over the facts on the front page * that describe something the site cannot do yet, over the Modes on Play that * cannot be started, under the figures on the pilot card that nothing records, - * and across the three destinations on the bar that have no address behind - * them. + * across the three destinations on the bar that have no address behind them, + * and on the lobby's "Fill" - the answer to who pilots a force that means + * "somebody we find", which is matchmaking and is `invitations`' to build. * Over three of them and under the fourth. A stat tile is a number and a * six-letter caption and a Mode is a heading and a sentence: the mark goes @@ -33,19 +34,24 @@ /** * A stamp for something the site names but has not built. `variant` picks the * surface: "fact" is the front page's strip, "mode" a Mode on Play that cannot - * be started, "tile" a figure on the pilot card that nothing records. + * be started, "tile" a figure on the pilot card that nothing records, "seat" + * one answer of the lobby's four-answer seat control. * * The first two lie over their card and are sized off it; the third sits under - * its caption, because a stat tile has no middle to lay anything across. So - * the variant is a size and a place, and never a different mark. + * its caption, because a stat tile has no middle to lay anything across; the + * fourth lies across a button one word wide, which is the bar's problem at the + * bar's size and gets the bar's answer. So the variant is a size and a place, + * and never a different mark. * - * Hidden from screen readers, all three. The mark is an angle and a colour, + * Hidden from screen readers, all four. The mark is an angle and a colour, * neither of which is anything to one, and there is nothing here to act on: - * what a reader gets instead is the "(not ready yet)" the card's own heading - * or the tile's own caption carries, which is in the reading order where the - * name is rather than wherever the stamp landed. + * what a reader gets instead is the "(not ready yet)" the card's own heading, + * the tile's own caption or the button's own label carries, which is in the + * reading order where the name is rather than wherever the stamp landed. */ -export function soonStamp(variant: "fact" | "mode" | "tile"): HTMLElement { +export function soonStamp( + variant: "fact" | "mode" | "tile" | "seat", +): HTMLElement { return el("span", { className: `stamp stamp-soon stamp-${variant}`, textContent: SOON, diff --git a/web/src/styles.css b/web/src/styles.css --- a/web/src/styles.css +++ b/web/src/styles.css @@ -3136,6 +3136,14 @@ max-width: 24rem; } +/* `display: flex` on .lineup-add beats the browser's own rule for [hidden], + the same way .pilot-hover's does, so a closed field has to be put away + twice — without this every open force is showing a handle input again, + which is the thing this control was for. */ +.lineup-add[hidden] { + display: none; +} + /* A refusal, worn as a badge beside the control that refused. */ .pill { display: inline-flex; @@ -3595,11 +3603,103 @@ box-shadow: 0 6px 20px light-dark(rgb(15 23 34 / 18%), rgb(0 0 0 / 45%)); } -/* Holding a force open for somebody. Pressed state is the same accent fill - the other toggles use. */ -.lobby-lock.active { +/* --- who pilots an open force -------------------------------------------- + lobby.ts's seatActions(): one question, four answers, where three loose + buttons and a permanently open handle field used to be. + + The answers are one plate between them rather than four: the ends wear the + two cuts (.plate-tl, .plate-br) and the middles are square, which is what + .plate-tl/.plate-br are for. The 1px gap is the card showing through, so + the divisions cost no border that would double up against the cut edges. + + The rule that squares the middles has to name what it is not, rather than a + class of its own: `.plate-tl` and `.plate-br` are declared further up this + file, and a `.seat-answer { clip-path: none }` down here would win over + both and take the plate's shape off the ends. */ +.seat-fill { + display: flex; + flex-direction: column; + gap: 0.5rem; + /* The only thing in the seat row when a force is open, and the row is a + space-between flex — without this the control sits against the card's + left edge with the word "Open" and nothing else beside it. */ + flex: 1 1 100%; + min-width: 0; +} + +.seat-fill-row { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.seat-answers { + display: inline-flex; + gap: 1px; +} + +.seat-answers > button:not(.plate-tl):not(.plate-br) { + clip-path: none; +} + +/* `button` sets align-self: flex-start for a button standing on its own in a + column; these are in a row and have to fill its height, or the four are + different heights the moment one of them wraps. */ +.seat-answer { + align-self: stretch; +} + +/* The answer that is open: Invite, while its field is showing. The same + accent fill every other pressed toggle on the site wears. */ +.seat-answer.active { color: var(--on-accent); background: var(--accent); +} + +/* The answer that is not built. + + The mark lies across the button rather than beside it: set beside the label + it made this one answer half again the width of the other three, and the + four stopped reading as one plate. One line at a steep angle, which is what + fits across four letters - the two-line mark the front page uses filled the + button and buried the word under it. + + Translucent, and the label under it is not dimmed to a ghost: at this size + the two have to share the space, so the mark reads as ink over the word + rather than instead of it. + + `button[disabled]` fades the box, and a --muted mark under that opacity is + a mark that cannot be read - the same reason the bar dims its label and not + its stamp. The cursor is the button rule's `progress` otherwise, which + promises something is on its way. */ +.seat-answer-soon { + position: relative; + padding-inline: 1.05rem; + opacity: 1; + cursor: default; +} + +.seat-answer-soon .soon-label { + opacity: 0.7; +} + +.stamp-seat { + position: absolute; + top: 50%; + left: 50%; + z-index: 1; + padding: 0.1em 0.3em 0.05em; + border-width: 0.12em; + font-size: 0.38rem; + line-height: 1.2; + letter-spacing: 0.08em; + text-indent: 0.08em; + white-space: nowrap; + transform: translate(-50%, -50%) rotate(-50deg); + opacity: 0.8; + pointer-events: none; } /* The team control on a Force card, where the team pill used to sit. Sized diff --git a/web/src/screens/lobby.ts b/web/src/screens/lobby.ts --- a/web/src/screens/lobby.ts +++ b/web/src/screens/lobby.ts @@ -35,6 +35,7 @@ import { presetNamed } from "../content/megamek-text"; import { costText, forceTotals, statsFor, tonsText } from "../content/units"; import { scenarioPool, type ChallengeMode } from "./scenario-pool"; +import { notReadyNote, soonStamp } from "../soon-stamp"; import { waitingScreen } from "./waiting"; export type { ChallengeMode } from "./scenario-pool"; @@ -315,8 +316,19 @@ * the whole room as having "joined" — see `renderRoster`. */ let here: Watcher[] | null = null; - /** The slots the host is holding open for somebody, as the API last said. */ + /** + * The slots the host is holding open for somebody, as the API last said. + * Read-only here: nothing on this screen locks anything any more, and a + * lock another client set is why an open force can refuse "You" and "AI" + * — see `seatActions`. + */ let lockedSlots: string[] = []; + /** + * The open forces whose Invite field is showing, and what has been typed + * into each. Out here rather than on the card because every seat snapshot + * rebuilds the cards - see `seatActions`. + */ + const inviting = new Map(); let socket: LobbySocket | null = null; let matchId: string | null = null; // Set the moment this screen is torn down, so a `close` event the @@ -746,9 +758,28 @@ } /** - * An open seat's three resolvers: take it yourself, hand it to the bot, - * or invite a specific player by handle — who need not be connected to - * this lobby, or signed in anywhere, at the moment they are invited. + * The one question an open force asks: who pilots it. + * + * Four answers, in the order a lobby reaches for them — yourself, somebody + * you name, somebody we find, the bot — as one split plate rather than the + * three loose buttons and a permanently open text field this used to be. + * Nothing said which of them a visitor was meant to press, and a screen + * that is mostly a roster carried one handle input per force. The field is + * what "Invite" opens now, and closes again. + * + * "Fill" is matchmaking, which does not exist. It is disabled and wears the + * site's own coming-soon stamp, the same mark as the bar's destinations + * with no address behind them, rather than being left off the control: what + * fills a force nobody wants to take is the question this control asks, and + * a missing answer is worth saying out loud. Building it is the + * `Matchmaking` item in plan/invitations.md. + * + * There is no Hold. Reserving a force for somebody who may never arrive is + * the reverse of what an open force is for — the answer to "keep this for + * my friend" is to invite them. The API's lock is still there and still + * broadcast, so a slot another client locked reads as held here and says + * why the two answers the API refuses are refused; nothing in this client + * sets one any more. * * Nothing here reports why an invite did not seat anyone — an unknown * handle, one already seated elsewhere in this lobby — the same silent @@ -758,60 +789,8 @@ * snapshot can arrive well after the click that asked for it, unlike the * other two resolvers. */ - /** - * Hold this slot open, or let it go. - * - * Rendered on every open slot and never disabled, the same "show it, let - * the server decide" posture Kick, Deploy and the team control take: a - * joiner has no reliable way to know whether they are the host, and the - * host is the only identity the API lets lock anything. - * - * A locked slot still shows the host's own ways to fill it — inviting a - * handle, or handing it to the bot — because that is what makes a lock a - * reservation rather than a hole in the scenario. - */ - function lockButton(slotName: string): HTMLButtonElement { - const locked = lockedSlots.includes(slotName); - const button = el("button", { - type: "button", - className: "secondary small-btn lobby-lock", - textContent: locked ? "Held ✓" : "Hold", - title: locked - ? "Let anybody claim this force again" - : "Keep this force open for somebody", - }); - button.classList.toggle("active", locked); - button.setAttribute("aria-pressed", String(locked)); - button.addEventListener("click", () => socket?.setLock(slotName, !locked)); - return button; - } - function seatActions(slotName: string): HTMLElement { - const takeIt = el("button", { - type: "button", - className: "secondary small-btn", - textContent: "Take it yourself", - }); - takeIt.addEventListener("click", () => socket?.claimSeat(slotName)); - const botIt = el("button", { - type: "button", - className: "secondary small-btn", - textContent: "Assign the bot", - }); - botIt.addEventListener("click", () => socket?.assignBot(slotName)); - const buttonRow = el("div", { className: "row" }, [ - takeIt, - botIt, - lockButton(slotName), - ]); - // Held for somebody: the two ways in that belong to everybody are the - // ones the API refuses, so they say why rather than failing silently. - if (lockedSlots.includes(slotName)) { - takeIt.disabled = true; - botIt.disabled = true; - takeIt.title = "The host is keeping this force for somebody"; - botIt.title = takeIt.title; - } + const locked = lockedSlots.includes(slotName); const inviteInput = el("input", { type: "text", @@ -824,12 +803,21 @@ const inviteButton = el("button", { type: "submit", className: "secondary small-btn", - textContent: "Invite", + textContent: "Send", }); const inviteForm = el("form", { className: "lineup-add" }, [ withHandleTypeahead(inviteInput), inviteButton, ]); + // Both halves of the field's state are in `inviting` rather than in the + // DOM: renderSeats rebuilds every card from scratch, and a seat snapshot + // landing while somebody is halfway through a handle must not take the + // field, or what they had typed, away from under them. + inviteForm.hidden = !inviting.has(slotName); + inviteInput.value = inviting.get(slotName) ?? ""; + inviteInput.addEventListener("input", () => { + inviting.set(slotName, inviteInput.value); + }); inviteForm.addEventListener("submit", (event) => { event.preventDefault(); const handle = inviteInput.value.trim().replace(/^@/, ""); @@ -837,10 +825,83 @@ socket?.inviteHandle(slotName, handle); // Cleared on send, not on a confirmed seat: there is nothing this // screen is ever told that would make waiting to clear it meaningful. + // The field stays open for the same reason — nothing here knows the + // handle resolved, and a field that vanished would read as if it had. inviteInput.value = ""; + inviting.set(slotName, ""); }); - return el("div", {}, [buttonRow, inviteForm]); + const you = seatAnswer("You", () => socket?.claimSeat(slotName)); + you.classList.add("plate-tl"); + const invite = seatAnswer("Invite", () => { + const open = inviteForm.hidden; + if (open) inviting.set(slotName, inviteInput.value); + else inviting.delete(slotName); + inviteForm.hidden = !open; + invite.classList.toggle("active", open); + invite.setAttribute("aria-expanded", String(open)); + if (open) inviteInput.focus(); + }); + invite.classList.toggle("active", !inviteForm.hidden); + invite.setAttribute("aria-expanded", String(!inviteForm.hidden)); + // Disabled and stamped, not hidden: see this function's own doc. The + // label carries the note a stamp cannot give a screen reader, the same + // pairing the bar's three destinations use. + const fill = el("button", { + type: "button", + className: "secondary small-btn seat-answer seat-answer-soon", + disabled: true, + title: "Matchmaking is not built yet", + }); + fill.append( + el("span", { className: "soon-label", textContent: "Fill" }), + notReadyNote(), + soonStamp("seat"), + ); + const bot = seatAnswer("AI", () => socket?.assignBot(slotName)); + bot.classList.add("plate-br"); + // Held for somebody: the two answers that belong to everybody are the + // ones the API refuses, so they say why rather than failing silently. + // Inviting is not one of them — it is what a hold was for. + if (locked) { + you.disabled = true; + bot.disabled = true; + you.title = "The host is keeping this force for somebody"; + bot.title = you.title; + } + + const answers = el("div", { className: "seat-answers" }, [ + you, + invite, + fill, + bot, + ]); + answers.setAttribute("role", "group"); + answers.setAttribute("aria-label", `Who pilots ${slotName}`); + + return el("div", { className: "seat-fill" }, [ + el("div", { className: "seat-fill-row" }, [ + el("span", { + className: "hint", + textContent: locked ? "Held for somebody" : "Open", + }), + answers, + ]), + inviteForm, + ]); + } + + /** One answer, shaped like a segment of the plate the four make between + * them: the ends carry the notches (`plate-tl`, `plate-br`) and the two in + * the middle are square. */ + function seatAnswer(label: string, pick: () => void): HTMLButtonElement { + const button = el("button", { + type: "button", + className: "secondary small-btn seat-answer", + textContent: label, + }); + button.addEventListener("click", pick); + return button; } /** @@ -1583,7 +1644,7 @@ onLocked(slots: string[]) { connected = true; lockedSlots = slots; - // The lock lives on an open slot's own actions, so the cards are what + // A lock shows on an open force's own answers, so the cards are what // redraw — there is nowhere else it shows. renderSeats(); },