From 705808d45af53005e6509dad29b5ee5ec4741e80 Mon Sep 17 00:00:00 2001 From: Martin Dvorak Date: Sun, 18 Oct 2026 21:55:47 +0000 Subject: [PATCH] Pickers: anchor the desktop dropdown to its chip, never clipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "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 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. --- README.md | 16 ++++ components/chat/ContextPicker.tsx | 55 ++++--------- components/chat/ModelPicker.tsx | 54 ++++-------- components/chat/PickerPopover.tsx | 132 ++++++++++++++++++++++++++++++ package.json | 2 +- public/sw.js | 2 +- tests/teach-test.mjs | 36 ++++++++ 7 files changed, 215 insertions(+), 82 deletions(-) create mode 100644 components/chat/PickerPopover.tsx diff --git a/README.md b/README.md index cd1fe6b..d0b48c0 100644 --- a/README.md +++ b/README.md @@ -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 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 ``, `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 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 diff --git a/components/chat/ContextPicker.tsx b/components/chat/ContextPicker.tsx index dd443a0..e2dd49c 100644 --- a/components/chat/ContextPicker.tsx +++ b/components/chat/ContextPicker.tsx @@ -1,9 +1,10 @@ "use client"; -import { useEffect, useRef } from "react"; +import { useEffect } from "react"; import { AnimatePresence, motion } from "framer-motion"; import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref"; +import { PickerPopover } from "./PickerPopover"; import { CheckCircleIcon, FolderIcon, LayersIcon } from "@/components/icons"; import { useI18n } from "@/lib/i18n"; import { springSheet } from "@/lib/motion"; @@ -35,7 +36,6 @@ export function ContextPicker({ const { t, plural } = useI18n(); const isPhone = useIsPhone(); const reduceMotion = useReducedMotion(); - const panel = useRef(null); useEffect(() => { if (!open) return; @@ -46,21 +46,6 @@ export function ContextPicker({ return () => window.removeEventListener("keydown", onKey); }, [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(); const choose = (next: ChatScope) => { @@ -120,12 +105,15 @@ export function ContextPicker({ ); - const body = ( + const body = (listMax: string) => (
{modeRow("all", t("chat.scope.all"), t("chat.scope.allNote"), )} {modeRow("decks", t("chat.scope.pick"), t("chat.scope.pickNote"), )} {scope.kind === "decks" ? ( -
+
{folders.map((folder) => { const inFolder = decks.filter((deck) => deck.folderId === folder.id); if (inFolder.length === 0) return null; @@ -211,7 +199,7 @@ export function ContextPicker({ >

{t("chat.scope.title")}

- {body} + {body("38dvh")}

{t("chat.scope.note")}

@@ -221,25 +209,12 @@ export function ContextPicker({ } return ( - - {open ? ( - -

- {t("chat.scope.title")} -

- {body} -

{t("chat.scope.note")}

-
- ) : null} -
+ +

+ {t("chat.scope.title")} +

+ {body("none")} +

{t("chat.scope.note")}

+
); } diff --git a/components/chat/ModelPicker.tsx b/components/chat/ModelPicker.tsx index bed0fe3..be0528e 100644 --- a/components/chat/ModelPicker.tsx +++ b/components/chat/ModelPicker.tsx @@ -1,9 +1,10 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { AnimatePresence, motion } from "framer-motion"; import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref"; +import { PickerPopover } from "./PickerPopover"; import { CheckCircleIcon } from "@/components/icons"; import { useI18n } from "@/lib/i18n"; import { providerInfo } from "@/lib/providers"; @@ -36,7 +37,6 @@ export function ModelPicker({ const { t } = useI18n(); const isPhone = useIsPhone(); const reduceMotion = useReducedMotion(); - const panel = useRef(null); const [query, setQuery] = useState(""); /* 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); }, [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 badge = (model: ChatModelChoice) => 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 ? ( ) : null} -
    +
      {visible.length === 0 ? (
    • {t("chat.modelSearchEmpty")}
    • ) : null} @@ -160,7 +147,7 @@ export function ModelPicker({ >

      {t("chat.modelTitle")}

      - {rows} + {rows("60dvh")}

      {t("chat.modelNote")}

      @@ -170,25 +157,12 @@ export function ModelPicker({ } return ( - - {open ? ( - -

      - {t("chat.modelTitle")} -

      - {rows} -

      {t("chat.modelNote")}

      -
      - ) : null} -
      + +

      + {t("chat.modelTitle")} +

      + {rows("none")} +

      {t("chat.modelNote")}

      +
      ); } diff --git a/components/chat/PickerPopover.tsx b/components/chat/PickerPopover.tsx new file mode 100644 index 0000000..53e6951 --- /dev/null +++ b/components/chat/PickerPopover.tsx @@ -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 , `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(null); + const reduceMotion = useReducedMotion(); + const [style, setStyle] = useState(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( + + {open ? ( + + {children} + + ) : null} + , + document.body, + ); +} diff --git a/package.json b/package.json index 3ffc1b6..3e1f8fc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "freezmo", - "version": "0.8.2", + "version": "0.8.3", "private": true, "license": "GPL-3.0-only", "scripts": { diff --git a/public/sw.js b/public/sw.js index 3ef35d5..7c7182e 100644 --- a/public/sw.js +++ b/public/sw.js @@ -9,7 +9,7 @@ * * 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 ASSET_CACHE = `${CACHE_VERSION}-assets`; diff --git a/tests/teach-test.mjs b/tests/teach-test.mjs index 811c006..fbd7562 100644 --- a/tests/teach-test.mjs +++ b/tests/teach-test.mjs @@ -224,6 +224,42 @@ try { (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 ----------------------------------------------- */ turns = 0; await page.getByTestId("teach-start").click();