diff --git a/deploy/release-app.sh b/deploy/release-app.sh index 950aa31..573f088 100644 --- a/deploy/release-app.sh +++ b/deploy/release-app.sh @@ -16,6 +16,7 @@ lock='/opt/screener/deploy.lock' public_origin="${SCREENER_PUBLIC_ORIGIN:-}" descriptor="$(realpath -e -- "$1")" stage='' +release_owned=0 cutover_started=0 old_release='' @@ -72,6 +73,29 @@ cleanup_stage() { rm -rf --one-file-system -- "$resolved" } +cleanup_release() { + if [ "$release_owned" -ne 1 ] || { [ ! -e "$release" ] && [ ! -L "$release" ]; }; then + return + fi + if [ -n "$stage" ] && [ -e "$stage" ]; then + return + fi + local resolved parent base active + resolved="$(realpath -m -- "$release")" + parent="$(dirname -- "$resolved")" + base="$(basename -- "$resolved")" + active="$(readlink -f -- "$current" 2>/dev/null || true)" + if [ "$parent" != "$release_root" ] || [ "$base" != "$release_id" ] || \ + [ "$active" = "$resolved" ] || [ -L "$release" ] || [ ! -d "$release" ]; then + printf 'refusing to remove unexpected release: %s\n' "$resolved" >&2 + return 1 + fi + if ! rm -rf --one-file-system -- "$resolved"; then + return 1 + fi + release_owned=0 +} + wait_for_health() { local body='' for _ in $(seq 1 120); do @@ -102,6 +126,9 @@ recover() { systemctl start screener.service || ok=0 wait_for_health || ok=0 fi + if [ "$ok" -eq 1 ]; then + cleanup_release || ok=0 + fi cleanup_stage printf 'deployment_failed=%s recovery_ok=%s current=%s active=%s\n' \ "$code" "$ok" \ @@ -267,6 +294,7 @@ old_asset="$(OLD_RELEASE="$old_release" "$node" --input-type=module --eval ' process.stdout.write(match[1]); ')" +release_owned=1 mv -- "$stage" "$release" stage='' test -d "$release" @@ -310,6 +338,7 @@ fi test -z "$(journalctl -u screener.service --since "$cutover_since" -p warning --no-pager --output=cat)" asset_sha="$(sha256sum "$release/dist/client/$main_asset" | awk '{print $1}')" +release_owned=0 cutover_started=0 trap - ERR HUP INT TERM EXIT printf 'deployment=ok revision=%s release=%s previous=%s artifact_sha=%s manifest_sha=%s asset=%s asset_sha=%s health_ms=%s firewall_sha=%s\n' \ diff --git a/src/client/components/living/Comic.tsx b/src/client/components/living/Comic.tsx index adc11f8..eaebf71 100644 --- a/src/client/components/living/Comic.tsx +++ b/src/client/components/living/Comic.tsx @@ -899,7 +899,6 @@ ${rmBlock( - diff --git a/src/client/components/living/ComicTooltip.tsx b/src/client/components/living/ComicTooltip.tsx index edc36d4..f28df3c 100644 --- a/src/client/components/living/ComicTooltip.tsx +++ b/src/client/components/living/ComicTooltip.tsx @@ -11,9 +11,16 @@ // Styling in styles.css under "comic tooltip" / "glyph draw-in". SSR-safe: // handlers only run in the browser. -import { useEffect, useRef, useState, type ReactNode } from "react"; +import { + isValidElement, + useEffect, + useRef, + useState, + type ReactNode, +} from "react"; import { Comic, type ComicKind } from "./Comic"; import { HintComic, isHintKind, type HintKind } from "./hints"; +import { isCopyKey, say } from "../../ui/copy"; const LONG_PRESS_MS = 500; const TOUCH_HIDE_MS = 1500; @@ -49,6 +56,23 @@ export function ComicTooltip({ const [comicMounted, setComicMounted] = useState(false); const [liveAlign, setLiveAlign] = useState(null); const interactionOpen = hoverOpen || focusOpen || touchOpen; + const disabledTrigger = + isValidElement<{ + disabled?: boolean; + "aria-label"?: string; + label?: string; + title?: string; + }>(children) && + children.props.disabled === true; + const rawDisabledTriggerLabel = disabledTrigger + ? children.props["aria-label"] ?? + children.props.label ?? + children.props.title + : undefined; + const disabledTriggerLabel = + rawDisabledTriggerLabel && isCopyKey(rawDisabledTriggerLabel) + ? say(rawDisabledTriggerLabel) + : rawDisabledTriggerLabel; const mountComic = () => { if (comicUnmountTimer.current !== null) { @@ -163,7 +187,9 @@ export function ComicTooltip({ return ( { pickAlign(); if (event.pointerType !== "touch") { @@ -250,9 +276,9 @@ export function ComicTooltip({ }} onKeyDown={(event) => { if (event.key === "Escape") { + setHoverOpen(false); setTouchOpen(false); setFocusOpen(false); - (document.activeElement as HTMLElement | null)?.blur?.(); } else if ((event.target as HTMLElement).matches(":focus-visible")) { mountComic(); setFocusOpen(true); diff --git a/src/client/components/living/Couch.tsx b/src/client/components/living/Couch.tsx index 2b5ff77..964d14d 100644 --- a/src/client/components/living/Couch.tsx +++ b/src/client/components/living/Couch.tsx @@ -2,17 +2,7 @@ // self carries the green pointer, the host wears a crown (topology only). import { Glyph } from "../../ui/icons"; import { useCopy } from "../../ui/copy"; - -const PAWN_COLORS = [ - "var(--pawn-1)", "var(--pawn-2)", "var(--pawn-3)", "var(--pawn-4)", - "var(--pawn-5)", "var(--pawn-6)", "var(--pawn-7)", "var(--pawn-8)", -]; - -export function pawnColor(key: string): string { - let hash = 0; - for (const char of key) hash = (hash * 31 + char.charCodeAt(0)) | 0; - return PAWN_COLORS[Math.abs(hash) % PAWN_COLORS.length]!; -} +import { participantColor } from "./participant-color"; export function PawnSvg({ color, crown }: { color: string; crown?: boolean }) { return ( @@ -80,7 +70,7 @@ export function Couch({ : `${entry.name} · ${stateLabel}`; const inner = ( <> - +