Pickers: anchor the desktop dropdown to its chip, never clipped

"The model selection in teach it back is kinda broken at least on desktop":
the desktop panels were position:absolute children of whatever container held
their chip. In chat that is the composer at the bottom of the column, which
works, but Teach it back keeps its chips inside a scrolling form card — so the
panel opened upward out of the card and its top rows (including the selected
model) were cut off. The probe measured top: -66px and no way to reach the
first rows by scrolling the list.

- New components/chat/PickerPopover.tsx: the desktop surface for both pickers,
  portalled to <body> and position:fixed, anchored to the opening chip
  ([data-picker-trigger][aria-expanded="true"]), opening on the side with room
  and capped to the viewport so the panel scrolls as a whole with its heading
  and note attached. It closes on an outside press, Escape, resize or a page
  scroll — but not on scrolling its own list.
- ContextPicker and ModelPicker use it on desktop; the phone sheets and their
  inner list caps are unchanged (the list max-height became a parameter: the
  sheet caps it, the desktop panel scrolls instead).
- teach-test pins it: the panel's rect is fully inside the viewport,
  elementFromPoint over it lands on the panel, it is not a descendant of the
  scrolling card, and picking a model updates the chip + freezmo.chat.model.v1
  (46 checks). Chat and the phone sheets re-verified unchanged (110 + 128).

Version 0.8.3, SW v105.
This commit is contained in:
Martin Dvořák 2026-10-18 21:55:47 +00:00
commit 705808d45a
7 changed files with 215 additions and 82 deletions

View file

@ -1513,6 +1513,22 @@ Things I chose while building, with the reasoning:
into view as you type. No strings, so no i18n; the additive change sits on top of the merged into view as you type. No strings, so no i18n; the additive change sits on top of the merged
scene-heat rework unchanged. scene-heat rework unchanged.
111. **The model dropdown was clipped by the card it lived in.** "The model selection in teach it
back is kinda broken at least on desktop": the pickers' desktop panels were
`position: absolute` children of whatever container held their chip. In chat that is the
composer at the bottom of the column (open upwards, nothing clips it), but Teach it back puts
its chips inside a scrolling form card — so the panel opened upward, out of the card, and its
first rows (the selected model among them) were cut off by the viewport: the probe measured
`top: -66px`, and the list could not be reached by scrolling the inner list. Both pickers now
hand the desktop surface to `PickerPopover`: portalled to `<body>`, `position: fixed`, anchored
to the trigger that opened it (`[data-picker-trigger][aria-expanded="true"]`), opening on the
side with room and capped to the viewport, so the panel scrolls as a whole and always keeps its
heading and note attached to the chip. It closes on an outside press, Escape, a resize or a
page scroll (not on scrolling its own list), and the phone sheets are untouched. Pinned by the
teach suite: the panel's rect sits fully inside the viewport, `elementFromPoint` over it hits
the panel, it is not a descendant of the scrolling card, and picking a model updates the chip
and `freezmo.chat.model.v1` (46 checks).
110. **The reminder is written by the browser, because the notes never leave it.** Push reminders 110. **The reminder is written by the browser, because the notes never leave it.** Push reminders
used to say one generic line to everyone, and the server had no way to do better: it only used to say one generic line to everyone, and the server had no way to do better: it only
knows push endpoints, never a deck. So the notification text is now composed on the device knows push endpoints, never a deck. So the notification text is now composed on the device

View file

@ -1,9 +1,10 @@
"use client"; "use client";
import { useEffect, useRef } from "react"; import { useEffect } from "react";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref"; import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref";
import { PickerPopover } from "./PickerPopover";
import { CheckCircleIcon, FolderIcon, LayersIcon } from "@/components/icons"; import { CheckCircleIcon, FolderIcon, LayersIcon } from "@/components/icons";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { springSheet } from "@/lib/motion"; import { springSheet } from "@/lib/motion";
@ -35,7 +36,6 @@ export function ContextPicker({
const { t, plural } = useI18n(); const { t, plural } = useI18n();
const isPhone = useIsPhone(); const isPhone = useIsPhone();
const reduceMotion = useReducedMotion(); const reduceMotion = useReducedMotion();
const panel = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@ -46,21 +46,6 @@ export function ContextPicker({
return () => window.removeEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]); }, [open, onClose]);
/* Desktop dropdown: a press anywhere outside closes it, except on the
trigger chips, which toggle their own popover (see Composer). */
useEffect(() => {
if (!open || isPhone) return;
const onDown = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Element)) return;
if (panel.current?.contains(target)) return;
if (target.closest("[data-picker-trigger]")) return;
onClose();
};
document.addEventListener("pointerdown", onDown);
return () => document.removeEventListener("pointerdown", onDown);
}, [open, isPhone, onClose]);
const selected = scope.kind === "decks" ? new Set(scope.deckIds) : new Set<string>(); const selected = scope.kind === "decks" ? new Set(scope.deckIds) : new Set<string>();
const choose = (next: ChatScope) => { const choose = (next: ChatScope) => {
@ -120,12 +105,15 @@ export function ContextPicker({
</button> </button>
); );
const body = ( const body = (listMax: string) => (
<div className="space-y-2"> <div className="space-y-2">
{modeRow("all", t("chat.scope.all"), t("chat.scope.allNote"), <LayersIcon className="size-5" />)} {modeRow("all", t("chat.scope.all"), t("chat.scope.allNote"), <LayersIcon className="size-5" />)}
{modeRow("decks", t("chat.scope.pick"), t("chat.scope.pickNote"), <FolderIcon className="size-5" />)} {modeRow("decks", t("chat.scope.pick"), t("chat.scope.pickNote"), <FolderIcon className="size-5" />)}
{scope.kind === "decks" ? ( {scope.kind === "decks" ? (
<div className="max-h-[38dvh] space-y-1 overflow-y-auto overscroll-contain rounded-[var(--radius-control)] border border-[var(--glass-border)] bg-[var(--surface-sunken)] p-2"> <div
className="space-y-1 overflow-y-auto overscroll-contain rounded-[var(--radius-control)] border border-[var(--glass-border)] bg-[var(--surface-sunken)] p-2"
style={{ maxHeight: listMax }}
>
{folders.map((folder) => { {folders.map((folder) => {
const inFolder = decks.filter((deck) => deck.folderId === folder.id); const inFolder = decks.filter((deck) => deck.folderId === folder.id);
if (inFolder.length === 0) return null; if (inFolder.length === 0) return null;
@ -211,7 +199,7 @@ export function ContextPicker({
> >
<div className="sheet-grabber mx-auto" aria-hidden /> <div className="sheet-grabber mx-auto" aria-hidden />
<h2 className="px-1 pb-3 pt-2 text-[15px] font-semibold text-[var(--text)]">{t("chat.scope.title")}</h2> <h2 className="px-1 pb-3 pt-2 text-[15px] font-semibold text-[var(--text)]">{t("chat.scope.title")}</h2>
{body} {body("38dvh")}
<p className="px-1 py-3 text-[11.5px] text-[var(--text-subtle)]">{t("chat.scope.note")}</p> <p className="px-1 py-3 text-[11.5px] text-[var(--text-subtle)]">{t("chat.scope.note")}</p>
</motion.div> </motion.div>
</div> </div>
@ -221,25 +209,12 @@ export function ContextPicker({
} }
return ( return (
<AnimatePresence> <PickerPopover open={open} onClose={onClose} label={t("chat.scope.title")} align="left" width="w-96">
{open ? (
<motion.div
ref={panel}
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.98 }}
transition={reduceMotion ? { duration: 0 } : { duration: 0.16, ease: "easeOut" }}
className="glass-panel absolute bottom-full left-0 z-40 mb-2 w-96 p-3"
role="dialog"
aria-label={t("chat.scope.title")}
>
<h2 className="px-1 pb-2 text-[13px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]"> <h2 className="px-1 pb-2 text-[13px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
{t("chat.scope.title")} {t("chat.scope.title")}
</h2> </h2>
{body} {body("none")}
<p className="px-1 pt-2 text-[11.5px] text-[var(--text-subtle)]">{t("chat.scope.note")}</p> <p className="px-1 pt-2 text-[11.5px] text-[var(--text-subtle)]">{t("chat.scope.note")}</p>
</motion.div> </PickerPopover>
) : null}
</AnimatePresence>
); );
} }

View file

@ -1,9 +1,10 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useState } from "react";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref"; import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref";
import { PickerPopover } from "./PickerPopover";
import { CheckCircleIcon } from "@/components/icons"; import { CheckCircleIcon } from "@/components/icons";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { providerInfo } from "@/lib/providers"; import { providerInfo } from "@/lib/providers";
@ -36,7 +37,6 @@ export function ModelPicker({
const { t } = useI18n(); const { t } = useI18n();
const isPhone = useIsPhone(); const isPhone = useIsPhone();
const reduceMotion = useReducedMotion(); const reduceMotion = useReducedMotion();
const panel = useRef<HTMLDivElement>(null);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
/* Every model the server lists is offered, so the list can be long — filter /* Every model the server lists is offered, so the list can be long — filter
@ -56,27 +56,14 @@ export function ModelPicker({
return () => window.removeEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]); }, [open, onClose]);
/* Desktop dropdown: a press anywhere outside closes it, except on the
trigger chips, which toggle their own popover (see Composer). */
useEffect(() => {
if (!open || isPhone) return;
const onDown = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Element)) return;
if (panel.current?.contains(target)) return;
if (target.closest("[data-picker-trigger]")) return;
onClose();
};
document.addEventListener("pointerdown", onDown);
return () => document.removeEventListener("pointerdown", onDown);
}, [open, isPhone, onClose]);
const providerLabel = (id: string) => providerInfo(id)?.label ?? id; const providerLabel = (id: string) => providerInfo(id)?.label ?? id;
const badge = (model: ChatModelChoice) => const badge = (model: ChatModelChoice) =>
model.badge === "vision" ? t("chat.modelVision") : model.badge === "both" ? t("chat.modelBoth") : t("chat.modelText"); model.badge === "vision" ? t("chat.modelVision") : model.badge === "both" ? t("chat.modelBoth") : t("chat.modelText");
const rows = ( /** `listMax` is a CSS max-height for the list: the phone sheet caps it, the
* desktop panel scrolls as a whole instead (see PickerPopover). */
const rows = (listMax: string) => (
<> <>
{models.length > 8 ? ( {models.length > 8 ? (
<input <input
@ -89,7 +76,7 @@ export function ModelPicker({
className="mb-2 w-full rounded-[var(--radius-control)] border border-[var(--glass-border)] bg-[var(--surface-sunken)] px-3 py-2 text-[13px] text-[var(--text)] outline-none placeholder:text-[var(--text-subtle)] focus:border-accent-400" className="mb-2 w-full rounded-[var(--radius-control)] border border-[var(--glass-border)] bg-[var(--surface-sunken)] px-3 py-2 text-[13px] text-[var(--text)] outline-none placeholder:text-[var(--text-subtle)] focus:border-accent-400"
/> />
) : null} ) : null}
<ul className="max-h-[60dvh] space-y-1 overflow-y-auto overscroll-contain"> <ul className="space-y-1 overflow-y-auto overscroll-contain" style={{ maxHeight: listMax }}>
{visible.length === 0 ? ( {visible.length === 0 ? (
<li className="px-3 py-2 text-[12.5px] text-[var(--text-subtle)]">{t("chat.modelSearchEmpty")}</li> <li className="px-3 py-2 text-[12.5px] text-[var(--text-subtle)]">{t("chat.modelSearchEmpty")}</li>
) : null} ) : null}
@ -160,7 +147,7 @@ export function ModelPicker({
> >
<div className="sheet-grabber mx-auto" aria-hidden /> <div className="sheet-grabber mx-auto" aria-hidden />
<h2 className="px-1 pb-3 pt-2 text-[15px] font-semibold text-[var(--text)]">{t("chat.modelTitle")}</h2> <h2 className="px-1 pb-3 pt-2 text-[15px] font-semibold text-[var(--text)]">{t("chat.modelTitle")}</h2>
{rows} {rows("60dvh")}
<p className="px-1 py-3 text-[11.5px] text-[var(--text-subtle)]">{t("chat.modelNote")}</p> <p className="px-1 py-3 text-[11.5px] text-[var(--text-subtle)]">{t("chat.modelNote")}</p>
</motion.div> </motion.div>
</div> </div>
@ -170,25 +157,12 @@ export function ModelPicker({
} }
return ( return (
<AnimatePresence> <PickerPopover open={open} onClose={onClose} label={t("chat.modelTitle")} align="right" width="w-80">
{open ? (
<motion.div
ref={panel}
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.98 }}
transition={reduceMotion ? { duration: 0 } : { duration: 0.16, ease: "easeOut" }}
className="glass-panel absolute bottom-full right-0 z-40 mb-2 w-80 p-3"
role="dialog"
aria-label={t("chat.modelTitle")}
>
<h2 className="px-1 pb-2 text-[13px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]"> <h2 className="px-1 pb-2 text-[13px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
{t("chat.modelTitle")} {t("chat.modelTitle")}
</h2> </h2>
{rows} {rows("none")}
<p className="px-1 pt-2 text-[11.5px] text-[var(--text-subtle)]">{t("chat.modelNote")}</p> <p className="px-1 pt-2 text-[11.5px] text-[var(--text-subtle)]">{t("chat.modelNote")}</p>
</motion.div> </PickerPopover>
) : null}
</AnimatePresence>
); );
} }

View file

@ -0,0 +1,132 @@
"use client";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion } from "framer-motion";
import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref";
/**
* Desktop surface for the context/model pickers.
*
* The chips live wherever the screen puts them: at the bottom of the chat
* column, or inside a scrolling card (Teach it back's start). An absolutely
* positioned panel is then clipped by the scrolling ancestor — the teach model
* list opened upward and lost its first rows off the top of the viewport — so
* this renders the panel in a portal on <body>, `position: fixed`, anchored to
* the trigger that opened it. It opens on the side with room, is capped to the
* viewport (the panel scrolls, not the page), and closes on resize, on an
* outside press or when the page scrolls away from its chip — never floating
* loose. Phones keep their bottom sheet.
*/
/** Gap between the panel and the chip, and the minimum distance to the edge. */
const GAP = 8;
/** Below this there is not enough room to open on that side. */
const MIN_ROOM = 240;
export function PickerPopover({
open,
label,
align = "left",
width = "w-80",
onClose,
children,
}: {
open: boolean;
/** Accessible name of the panel (role="dialog"). */
label: string;
/** Which chip edge the panel lines up with. */
align?: "left" | "right";
/** Panel width utility class. */
width?: string;
onClose: () => void;
children: React.ReactNode;
}) {
const panel = useRef<HTMLDivElement>(null);
const reduceMotion = useReducedMotion();
const [style, setStyle] = useState<React.CSSProperties | null>(null);
/* Anchor to the open chip and fit inside the viewport. */
useLayoutEffect(() => {
if (!open) {
setStyle(null);
return;
}
const place = () => {
const node = panel.current;
if (!node) return;
const anchor = document.querySelector('[data-picker-trigger][aria-expanded="true"]');
const rect = anchor?.getBoundingClientRect();
const panelWidth = node.offsetWidth || 320;
/* The full content height, before any cap is applied. */
const natural = node.scrollHeight || node.offsetHeight || 320;
const below = rect ? window.innerHeight - rect.bottom - GAP * 2 : MIN_ROOM;
const above = rect ? rect.top - GAP * 2 : MIN_ROOM;
/* Prefer opening downwards when there is room; otherwise up, and split
the difference when neither side is comfortable. */
const placement = below >= MIN_ROOM || below >= above ? "bottom" : "top";
const room = Math.max(MIN_ROOM, placement === "bottom" ? below : above);
const height = Math.min(natural, room);
const rawLeft = rect ? (align === "right" ? rect.right - panelWidth : rect.left) : GAP;
const left = Math.max(GAP, Math.min(rawLeft, window.innerWidth - panelWidth - GAP));
/* Anchored to the chip on whichever side it opens: the panel's near edge
sits a GAP away from it, never drifting to the viewport edge. */
const top =
placement === "bottom"
? (rect?.bottom ?? GAP) + GAP
: Math.max(GAP, (rect?.top ?? height + GAP * 2) - GAP - height);
setStyle({ top, left, maxHeight: room });
};
place();
const close = () => onClose();
/* A scroll inside the panel (the model list) is not the page moving. */
const onScroll = (event: Event) => {
if (panel.current && event.target instanceof Node && panel.current.contains(event.target)) return;
onClose();
};
window.addEventListener("resize", close);
window.addEventListener("scroll", onScroll, true);
return () => {
window.removeEventListener("resize", close);
window.removeEventListener("scroll", onScroll, true);
};
}, [open, align, onClose]);
/* Desktop dropdown: a press anywhere outside closes it, except on the trigger
chips, which toggle their own popover. */
useEffect(() => {
if (!open) return;
const onDown = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Element)) return;
if (panel.current?.contains(target)) return;
if (target.closest("[data-picker-trigger]")) return;
onClose();
};
document.addEventListener("pointerdown", onDown);
return () => document.removeEventListener("pointerdown", onDown);
}, [open, onClose]);
if (typeof document === "undefined") return null;
return createPortal(
<AnimatePresence>
{open ? (
<motion.div
ref={panel}
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.98 }}
transition={reduceMotion ? { duration: 0 } : { duration: 0.16, ease: "easeOut" }}
style={style ?? { top: 0, left: 0, visibility: "hidden" }}
className={`glass-panel fixed z-50 overflow-y-auto overscroll-contain p-3 ${width}`}
role="dialog"
aria-label={label}
>
{children}
</motion.div>
) : null}
</AnimatePresence>,
document.body,
);
}

View file

@ -1,6 +1,6 @@
{ {
"name": "freezmo", "name": "freezmo",
"version": "0.8.2", "version": "0.8.3",
"private": true, "private": true,
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"scripts": { "scripts": {

View file

@ -9,7 +9,7 @@
* *
* Bumping CACHE_VERSION is how a new deploy invalidates old assets. * Bumping CACHE_VERSION is how a new deploy invalidates old assets.
*/ */
const CACHE_VERSION = "freezmo-v104"; const CACHE_VERSION = "freezmo-v105";
const SHELL_CACHE = `${CACHE_VERSION}-shell`; const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`; const ASSET_CACHE = `${CACHE_VERSION}-assets`;

View file

@ -224,6 +224,42 @@ try {
(await page.getByRole("button", { name: "Skeptical student" }).getAttribute("aria-pressed")) === "true", (await page.getByRole("button", { name: "Skeptical student" }).getAttribute("aria-pressed")) === "true",
); );
/* The model dropdown lives in a fixed, portalled panel: inside the
scrolling card it used to open off the top of the viewport and lose its
first rows. */
await page.locator("[data-picker-trigger]").last().click();
const modelDialog = page.getByRole("dialog", { name: "Chat model" });
await modelDialog.waitFor({ timeout: 5000 });
const panelFit = await modelDialog.evaluate((node) => {
const rect = node.getBoundingClientRect();
const centre = document.elementFromPoint(rect.left + rect.width / 2, rect.top + 12);
return {
inside:
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= window.innerHeight + 1 &&
rect.right <= window.innerWidth + 1,
onTop: Boolean(centre && node.contains(centre)),
portalled: node.closest(".chat-scroll") === null,
};
});
check(
"ui: the model dropdown fits the viewport and is not clipped by the card",
panelFit.inside && panelFit.onTop && panelFit.portalled,
JSON.stringify(panelFit),
);
await modelDialog.getByRole("button", { name: /stub-vision/i }).first().click();
await page.waitForTimeout(300);
check(
"ui: picking a model updates the chip and is remembered",
(await page.getByRole("button", { name: /Stub Vision/ }).count()) > 0 &&
(await page.evaluate(() => window.localStorage.getItem("freezmo.chat.model.v1"))) === "stub-vision",
);
/* Back to the first model so the session checks below stay on stub-text. */
await page.locator("[data-picker-trigger]").last().click();
await page.getByRole("dialog", { name: "Chat model" }).getByRole("button", { name: /stub-text/i }).first().click();
await page.waitForTimeout(300);
/* 6. UI: a session ----------------------------------------------- */ /* 6. UI: a session ----------------------------------------------- */
turns = 0; turns = 0;
await page.getByTestId("teach-start").click(); await page.getByTestId("teach-start").click();