One-pill study chrome, toggling chat pickers, readable homework steps

UI batch from feedback:

- The study/review/exam/homework chrome is now a single 58px pill: round
  close button, title + position over the progress bar, chips (or the exam
  timer) at the far end — everything on the centre line, so nothing crowds
  the rounded corners any more. Homework guide gets the same recipe.
- The immersive feeds carry z-[45] so they paint over the app chrome (on
  /exam the header used to cover the top of the chrome), and the page chrome
  behind an open overlay (.app-header, .segmented, .tabbar) is hidden while
  the transparent overlay is up, so it can no longer ghost through the glass.
  The guide's disclaimer moved inside the overlay with it.
- Chat: the context and model chips toggle their own popover (pressed state,
  aria-expanded), opening one closes the other, a press outside closes it —
  no more being forced to pick something to get out.
- Homework: the model's plain-text step explanations/hints/verdicts keep
  their line breaks (whitespace-pre-wrap) — a step used to collapse into one
  run-on paragraph with the whole solution inside it; the guide prompt now
  says the explanation is coaching, 2-4 sentences.
- The four glacier blobs are at 25% of their old opacity (0.045 light,
  0.1 dark).

Tests: phase-2 chrome geometry + exam layering checks (62), chat desktop
picker checks (101), the standing suites all green. README: decisions 54-56,
decision 23 updated. SW -> freezmo-v44.
This commit is contained in:
Martin Dvořák 2026-10-12 14:21:11 +00:00
commit 9d204b85be
16 changed files with 314 additions and 118 deletions

View file

@ -681,8 +681,18 @@ export function ChatScreen() {
online={online}
scopeLabel={scopeText}
modelLabel={activeModel || t("chat.modelTitle")}
onOpenScope={() => setScopeOpen(true)}
onOpenModel={() => setModelOpen(true)}
scopeOpen={scopeOpen}
modelOpen={modelOpen}
// The chips toggle their own popover (and open one at a time), so a
// second tap closes instead of forcing a pick.
onOpenScope={() => {
setScopeOpen((open) => !open);
setModelOpen(false);
}}
onOpenModel={() => {
setModelOpen((open) => !open);
setScopeOpen(false);
}}
onAttachFile={(file) => void handleAttach(file)}
attachBusy={attachBusy}
attachments={attachments}

View file

@ -26,6 +26,8 @@ export function Composer({
online,
scopeLabel,
modelLabel,
scopeOpen,
modelOpen,
onOpenScope,
onOpenModel,
onAttachFile,
@ -41,6 +43,8 @@ export function Composer({
online: boolean;
scopeLabel: string;
modelLabel: string;
scopeOpen: boolean;
modelOpen: boolean;
onOpenScope: () => void;
onOpenModel: () => void;
onAttachFile: (file: File) => void;
@ -156,16 +160,22 @@ export function Composer({
<div className="mt-2 flex items-center gap-2 overflow-x-auto">
<button
type="button"
data-picker-trigger
aria-haspopup="dialog"
aria-expanded={scopeOpen}
onClick={onOpenScope}
className="flex min-w-0 items-center gap-1.5 rounded-xl border border-[var(--hairline-soft)] bg-[var(--surface-sunken)] px-2.5 py-1.5 text-[12px] font-medium text-[var(--text-muted)] transition hover:text-[var(--text)]"
className={pickerChipClass(scopeOpen)}
>
<LayersIcon className="size-3.5 shrink-0" />
<span className="max-w-[170px] truncate">{scopeLabel}</span>
</button>
<button
type="button"
data-picker-trigger
aria-haspopup="dialog"
aria-expanded={modelOpen}
onClick={onOpenModel}
className="flex min-w-0 items-center gap-1.5 rounded-xl border border-[var(--hairline-soft)] bg-[var(--surface-sunken)] px-2.5 py-1.5 text-[12px] font-medium text-[var(--text-muted)] transition hover:text-[var(--text)]"
className={pickerChipClass(modelOpen)}
>
<ChatIcon className="size-3.5 shrink-0" />
<span className="max-w-[170px] truncate">{modelLabel}</span>
@ -181,3 +191,16 @@ export function Composer({
</div>
);
}
/**
* Chip style for the two popover triggers (context, model). The open state
* reads pressed, because the same chip opens and closes its popover.
*/
function pickerChipClass(open: boolean): string {
return cn(
"flex min-w-0 items-center gap-1.5 rounded-xl border px-2.5 py-1.5 text-[12px] font-medium transition",
open
? "border-[var(--nav-active-border)] bg-[var(--nav-active-bg)] text-[var(--text)]"
: "border-[var(--hairline-soft)] bg-[var(--surface-sunken)] text-[var(--text-muted)] hover:text-[var(--text)]",
);
}

View file

@ -1,6 +1,6 @@
"use client";
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { CheckCircleIcon, FolderIcon, LayersIcon } from "@/components/icons";
@ -34,6 +34,7 @@ export function ContextPicker({
const { t, plural } = useI18n();
const isPhone = useIsPhone();
const reduceMotion = useReducedMotion();
const panel = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
@ -44,6 +45,21 @@ 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) => {
@ -207,6 +223,7 @@ export function ContextPicker({
<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 }}

View file

@ -1,6 +1,6 @@
"use client";
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { CheckCircleIcon } from "@/components/icons";
@ -31,6 +31,7 @@ export function ModelPicker({
const { t } = useI18n();
const isPhone = useIsPhone();
const reduceMotion = useReducedMotion();
const panel = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
@ -41,6 +42,21 @@ 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 badge = (model: ChatModelChoice) =>
model.badge === "vision" ? t("chat.modelVision") : model.badge === "both" ? t("chat.modelBoth") : t("chat.modelText");
@ -123,6 +139,7 @@ export function ModelPicker({
<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 }}