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:
parent
d72b335965
commit
705808d45a
7 changed files with 215 additions and 82 deletions
16
README.md
16
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 `<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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement>(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<string>();
|
||||
|
||||
const choose = (next: ChatScope) => {
|
||||
|
|
@ -120,12 +105,15 @@ export function ContextPicker({
|
|||
</button>
|
||||
);
|
||||
|
||||
const body = (
|
||||
const body = (listMax: string) => (
|
||||
<div className="space-y-2">
|
||||
{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" />)}
|
||||
{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) => {
|
||||
const inFolder = decks.filter((deck) => deck.folderId === folder.id);
|
||||
if (inFolder.length === 0) return null;
|
||||
|
|
@ -211,7 +199,7 @@ export function ContextPicker({
|
|||
>
|
||||
<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>
|
||||
{body}
|
||||
{body("38dvh")}
|
||||
<p className="px-1 py-3 text-[11.5px] text-[var(--text-subtle)]">{t("chat.scope.note")}</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
|
@ -221,25 +209,12 @@ export function ContextPicker({
|
|||
}
|
||||
|
||||
return (
|
||||
<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" }}
|
||||
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)]">
|
||||
{t("chat.scope.title")}
|
||||
</h2>
|
||||
{body}
|
||||
<p className="px-1 pt-2 text-[11.5px] text-[var(--text-subtle)]">{t("chat.scope.note")}</p>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
<PickerPopover open={open} onClose={onClose} label={t("chat.scope.title")} align="left" width="w-96">
|
||||
<h2 className="px-1 pb-2 text-[13px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||
{t("chat.scope.title")}
|
||||
</h2>
|
||||
{body("none")}
|
||||
<p className="px-1 pt-2 text-[11.5px] text-[var(--text-subtle)]">{t("chat.scope.note")}</p>
|
||||
</PickerPopover>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement>(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 ? (
|
||||
<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"
|
||||
/>
|
||||
) : 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 ? (
|
||||
<li className="px-3 py-2 text-[12.5px] text-[var(--text-subtle)]">{t("chat.modelSearchEmpty")}</li>
|
||||
) : null}
|
||||
|
|
@ -160,7 +147,7 @@ export function ModelPicker({
|
|||
>
|
||||
<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>
|
||||
{rows}
|
||||
{rows("60dvh")}
|
||||
<p className="px-1 py-3 text-[11.5px] text-[var(--text-subtle)]">{t("chat.modelNote")}</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
|
@ -170,25 +157,12 @@ export function ModelPicker({
|
|||
}
|
||||
|
||||
return (
|
||||
<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" }}
|
||||
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)]">
|
||||
{t("chat.modelTitle")}
|
||||
</h2>
|
||||
{rows}
|
||||
<p className="px-1 pt-2 text-[11.5px] text-[var(--text-subtle)]">{t("chat.modelNote")}</p>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
<PickerPopover open={open} onClose={onClose} label={t("chat.modelTitle")} align="right" width="w-80">
|
||||
<h2 className="px-1 pb-2 text-[13px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||
{t("chat.modelTitle")}
|
||||
</h2>
|
||||
{rows("none")}
|
||||
<p className="px-1 pt-2 text-[11.5px] text-[var(--text-subtle)]">{t("chat.modelNote")}</p>
|
||||
</PickerPopover>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
132
components/chat/PickerPopover.tsx
Normal file
132
components/chat/PickerPopover.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "freezmo",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"private": true,
|
||||
"license": "GPL-3.0-only",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue