feat: recipe cards that scale servings and cook step by step (#140)

Claude-Session: https://claude.ai/code/session_01UmSCnoCgXqBp2fU3vwz4o1

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
cynarAI 2026-10-05 01:13:52 +02:00 committed by GitHub
parent fb5165bb26
commit 5f3aa8f3fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 1571 additions and 113 deletions

View File

@ -0,0 +1,173 @@
import type { CardAct, CardSection } from "@rakazo/contracts";
import { cardTimerRemaining, formatCardClock } from "@rakazo/core";
import { useKeepAwake } from "expo-keep-awake";
import { useEffect, useState } from "react";
import { Modal, Pressable, SafeAreaView, StyleSheet, Text, View } from "react-native";
import { mobileTokens } from "../lib/appearance";
import { useI18n } from "../lib/i18n";
import { NativeSymbol } from "./native-symbol";
type Steps = Extract<CardSection, { type: "steps" }>;
/** The current time, ticking every second while `active`. */
export function useNow(active: boolean): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!active) return;
setNow(Date.now());
const timer = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(timer);
}, [active]);
return now;
}
/**
* A card's steps one at a time in full screen, large enough to read from the stove, with the
* screen kept on. The step and any timer live on the card, so the web and a reload agree.
*/
export function CookingMode({
title,
steps,
now,
busy,
onAct,
onClose,
}: {
title: string;
steps: Steps;
now: number;
busy: boolean;
onAct: (act: CardAct) => Promise<void>;
onClose: () => void;
}) {
useKeepAwake();
const tokens = mobileTokens();
const { t } = useI18n();
const index = Math.min(steps.current ?? 0, steps.steps.length - 1);
const step = steps.steps[index];
const last = index === steps.steps.length - 1;
const remaining = cardTimerRemaining(step?.timerEndsAt, now);
const running = remaining !== null && remaining > 0;
const go = (next: number) => void onAct({ type: "step", sectionId: steps.id, index: next });
return (
<Modal animationType="slide" presentationStyle="fullScreen" onRequestClose={onClose}>
<SafeAreaView style={[styles.screen, { backgroundColor: tokens.background }]}>
<View style={styles.top}>
<Text numberOfLines={1} style={[styles.title, { color: tokens.mutedForeground }]}>
{title}
</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel={t("Close")}
onPress={onClose}
hitSlop={12}
>
<NativeSymbol ios="xmark" android="close" size={20} />
</Pressable>
</View>
<View style={styles.middle}>
<Text style={[styles.counter, { color: tokens.mutedForeground }]}>
{t("Step {position} of {total}", {
position: index + 1,
total: steps.steps.length,
})}
</Text>
<Text style={[styles.step, { color: tokens.foreground }]}>{step?.text}</Text>
{step?.timerSeconds ? (
<Pressable
accessibilityRole="button"
accessibilityLabel={running ? t("Stop timer") : t("Start timer")}
disabled={busy}
onPress={() =>
void onAct({
type: "timer",
sectionId: steps.id,
stepId: step.id,
running: !running,
})
}
style={[
styles.timer,
{
borderColor: running ? tokens.primary : tokens.border,
backgroundColor: running ? tokens.primary : "transparent",
},
]}
>
<NativeSymbol
ios={running ? "pause.fill" : "play.fill"}
android={running ? "pause" : "play"}
size={20}
color={running ? tokens.primaryForeground : tokens.foreground}
/>
<Text
style={[
styles.clock,
{ color: running ? tokens.primaryForeground : tokens.foreground },
]}
>
{formatCardClock(remaining ?? step.timerSeconds)}
</Text>
</Pressable>
) : null}
</View>
<View style={styles.bottom}>
<Pressable
accessibilityRole="button"
disabled={busy || index === 0}
onPress={() => go(index - 1)}
style={[styles.nav, { borderColor: tokens.border, opacity: index === 0 ? 0.4 : 1 }]}
>
<Text style={[styles.navText, { color: tokens.foreground }]}>{t("Back")}</Text>
</Pressable>
<Pressable
accessibilityRole="button"
disabled={busy && !last}
onPress={() => (last ? onClose() : go(index + 1))}
style={[styles.nav, { borderColor: tokens.primary, backgroundColor: tokens.primary }]}
>
<Text style={[styles.navText, { color: tokens.primaryForeground }]}>
{last ? t("Done") : t("Next")}
</Text>
</Pressable>
</View>
</SafeAreaView>
</Modal>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, paddingHorizontal: 24 },
top: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
paddingTop: 16,
},
title: { flex: 1, fontSize: 15, fontWeight: "600" },
middle: { flex: 1, justifyContent: "center", gap: 28 },
counter: { fontSize: 15 },
step: { fontSize: 30, lineHeight: 40, fontWeight: "600" },
timer: {
alignSelf: "flex-start",
flexDirection: "row",
alignItems: "center",
gap: 10,
borderWidth: 1,
borderRadius: 16,
paddingHorizontal: 18,
paddingVertical: 12,
},
clock: { fontSize: 26, fontWeight: "600", fontVariant: ["tabular-nums"] },
bottom: { flexDirection: "row", justifyContent: "space-between", gap: 12, paddingBottom: 16 },
nav: {
flex: 1,
alignItems: "center",
borderWidth: 1,
borderRadius: 14,
paddingVertical: 14,
},
navText: { fontSize: 16, fontWeight: "600" },
});

View File

@ -1,25 +1,44 @@
import type { CardAct, CardBlock, CardSection } from "@rakazo/contracts";
import { CARD_LIMITS } from "@rakazo/contracts";
import {
applyCardAct,
cardScale,
cardTimerRemaining,
checklistItemDisplay,
formatCardClock,
formatCardTimerLength,
} from "@rakazo/core";
import { useState } from "react";
import { Alert, Pressable, StyleSheet, Text, View } from "react-native";
import { mobileTokens } from "../lib/appearance";
import { useI18n } from "../lib/i18n";
import { dateLocaleForUi, useI18n } from "../lib/i18n";
import { CookingMode, useNow } from "./cooking-mode";
import { NativeSymbol } from "./native-symbol";
type Section<T extends CardSection["type"]> = Extract<CardSection, { type: T }>;
/** A bot's interactive card. Taps go to the server, which owns the card's state. */
export function ThreadCard({
block,
block: stored,
onAct,
}: {
block: CardBlock;
onAct?: (act: CardAct) => Promise<void>;
}) {
const tokens = mobileTokens();
const { t } = useI18n();
const { t, locale } = useI18n();
const [pending, setPending] = useState<CardAct | null>(null);
const [expanded, setExpanded] = useState<string[]>([]);
const [cooking, setCooking] = useState(false);
// Show the tap in flight on top of the stored card, so the card answers at once.
const preview = pending ? applyCardAct(stored, pending) : null;
const block = preview?.ok ? preview.block : stored;
const answered = Boolean(block.answer);
const busy = !onAct || pending !== null;
const factor = cardScale(block);
const header = block.sections?.find((part): part is Section<"header"> => part.type === "header");
const steps = block.sections?.find((part): part is Section<"steps"> => part.type === "steps");
const now = useNow(Boolean(steps?.steps.some((step) => step.timerEndsAt)));
async function act(next: CardAct) {
if (!onAct || pending) return;
@ -36,14 +55,48 @@ export function ThreadCard({
}
}
const checkedNow = (list: Section<"checklist">, itemId: string, stored: boolean) =>
pending?.type === "toggle" && pending.sectionId === list.id && pending.itemId === itemId
? pending.checked
: stored;
const selectedNow = (select: Section<"select">, optionId: string) =>
pending?.type === "select" && pending.sectionId === select.id && pending.optionId === optionId
? (pending.selected ?? true)
: (select.selected?.includes(optionId) ?? false);
function visible<T>(id: string, entries: T[]): { shown: T[]; hidden: number } {
if (expanded.includes(id) || entries.length <= CARD_LIMITS.collapsedEntries + 1) {
return { shown: entries, hidden: 0 };
}
return {
shown: entries.slice(0, CARD_LIMITS.collapsedEntries),
hidden: entries.length - CARD_LIMITS.collapsedEntries,
};
}
function showMore(id: string, hidden: number) {
if (!hidden) return null;
return (
<Pressable
accessibilityRole="button"
onPress={() => setExpanded((ids) => [...ids, id])}
style={{ paddingVertical: 6 }}
>
<Text style={[styles.small, { color: tokens.mutedForeground }]}>
{t("Show {count} more", { count: hidden })}
</Text>
</Pressable>
);
}
const roundButton = (
label: string,
symbol: { ios: string; android: "remove" | "add" },
disabled: boolean,
onPress: () => void,
) => (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
accessibilityState={{ disabled }}
disabled={disabled}
onPress={onPress}
style={[styles.round, { borderColor: tokens.border, opacity: disabled ? 0.4 : 1 }]}
>
<NativeSymbol ios={symbol.ios} android={symbol.android} size={16} />
</Pressable>
);
const select = block.sections?.find((part): part is Section<"select"> => part.type === "select");
const nothingChosen = Boolean(select?.multiple && !select.selected?.length);
@ -84,11 +137,71 @@ export function ThreadCard({
</View>
);
}
if (part.type === "stepper") {
const label = part.label;
return (
<View key={index} style={styles.stepperRow}>
<Text style={[styles.body, { color: tokens.mutedForeground }]}>{label}</Text>
<View style={styles.stepperControls}>
{roundButton(
t("Fewer {label}", { label }),
{ ios: "minus", android: "remove" },
busy || part.value <= 1,
() => void act({ type: "stepper", sectionId: part.id, value: part.value - 1 }),
)}
<Text
testID="card-stepper-value"
style={[styles.body, styles.stepperValue, { color: tokens.foreground }]}
>
{part.value}
</Text>
{roundButton(
t("More {label}", { label }),
{ ios: "plus", android: "add" },
busy || part.value >= CARD_LIMITS.stepperMax,
() => void act({ type: "stepper", sectionId: part.id, value: part.value + 1 }),
)}
</View>
</View>
);
}
if (part.type === "steps") {
const { shown, hidden } = visible(part.id, part.steps);
return (
<View key={index} style={{ gap: 10 }}>
{shown.map((step, position) => {
const remaining = cardTimerRemaining(step.timerEndsAt, now);
return (
<View key={step.id} style={{ flexDirection: "row", gap: 10 }}>
<Text
style={[styles.body, styles.stepNumber, { color: tokens.mutedForeground }]}
>
{position + 1}
</Text>
<View style={{ flex: 1, gap: 2 }}>
<Text style={[styles.body, { color: tokens.foreground }]}>{step.text}</Text>
{step.timerSeconds ? (
<Text style={[styles.small, { color: tokens.mutedForeground }]}>
{remaining !== null
? formatCardClock(remaining)
: formatCardTimerLength(step.timerSeconds, dateLocaleForUi(locale))}
</Text>
) : null}
</View>
</View>
);
})}
{showMore(part.id, hidden)}
</View>
);
}
if (part.type === "checklist") {
const { shown, hidden } = visible(part.id, part.items);
return (
<View key={index}>
{part.items.map((item) => {
const checked = checkedNow(part, item.id, Boolean(item.checked));
{shown.map((item) => {
const checked = Boolean(item.checked);
const display = checklistItemDisplay(item, factor);
return (
<Pressable
key={item.id}
@ -121,11 +234,15 @@ export function ThreadCard({
checked && { textDecorationLine: "line-through" },
]}
>
{item.label}
{display.amount ? (
<Text style={{ fontWeight: "600" }}>{display.amount} </Text>
) : null}
{display.label}
</Text>
</Pressable>
);
})}
{showMore(part.id, hidden)}
</View>
);
}
@ -133,7 +250,7 @@ export function ThreadCard({
return (
<View key={index} style={part.multiple ? styles.chips : { gap: 6 }}>
{part.options.map((option) => {
const chosen = selectedNow(part, option.id);
const chosen = part.selected?.includes(option.id) ?? false;
return (
<Pressable
key={option.id}
@ -172,19 +289,25 @@ export function ThreadCard({
</View>
);
}
if (answered) return null;
const actions = part.actions.filter((action) => action.kind === "steps" || !answered);
if (!actions.length) return null;
return (
<View key={index} style={styles.chips}>
{part.actions.map((action, position) => {
{actions.map((action, position) => {
const primary = position === 0;
const disabled = busy || nothingChosen;
const opensSteps = action.kind === "steps";
const disabled = opensSteps ? !steps : busy || nothingChosen;
return (
<Pressable
key={action.id}
accessibilityRole="button"
accessibilityState={{ disabled }}
disabled={disabled}
onPress={() => void act({ type: "action", actionId: action.id })}
onPress={() =>
opensSteps
? setCooking(true)
: void act({ type: "action", actionId: action.id })
}
style={[
styles.option,
{
@ -211,6 +334,16 @@ export function ThreadCard({
</View>
);
})}
{cooking && steps ? (
<CookingMode
title={header?.title ?? ""}
steps={steps}
now={now}
busy={busy}
onAct={act}
onClose={() => setCooking(false)}
/>
) : null}
</View>
);
}
@ -232,4 +365,16 @@ const styles = StyleSheet.create({
checkRow: { flexDirection: "row", alignItems: "center", gap: 10, paddingVertical: 6 },
chips: { flexDirection: "row", flexWrap: "wrap", gap: 6 },
option: { borderRadius: 12, borderWidth: 1, paddingHorizontal: 14, paddingVertical: 10 },
stepperRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
stepperControls: { flexDirection: "row", alignItems: "center", gap: 8 },
stepperValue: { minWidth: 28, textAlign: "center", fontWeight: "600" },
round: {
width: 34,
height: 34,
borderRadius: 17,
borderWidth: 1,
alignItems: "center",
justifyContent: "center",
},
stepNumber: { width: 16, textAlign: "right" },
});

View File

@ -608,4 +608,12 @@ export const RU_MESSAGES: Record<string, string> = {
"Every {day}, {time}": "Еженедельно: {day}, {time}",
"Monthly on day {day}, {time}": "Ежемесячно {day}-го числа, {time}",
"Next {date}": "Далее: {date}",
Close: "Закрыть",
"Fewer {label}": "Меньше: {label}",
"More {label}": "Больше: {label}",
Next: "Далее",
"Show {count} more": "Показать ещё {count}",
"Start timer": "Запустить таймер",
"Step {position} of {total}": "Шаг {position} из {total}",
"Stop timer": "Остановить таймер",
};

View File

@ -592,4 +592,12 @@ export const ZH_MESSAGES: Record<string, string> = {
"Every {day}, {time}": "每{day} {time}",
"Monthly on day {day}, {time}": "每月 {day} 日 {time}",
"Next {date}": "下次:{date}",
Close: "关闭",
"Fewer {label}": "减少{label}",
"More {label}": "增加{label}",
Next: "下一步",
"Show {count} more": "再显示 {count} 项",
"Start timer": "开始计时",
"Step {position} of {total}": "第 {position} 步,共 {total} 步",
"Stop timer": "停止计时",
};

View File

@ -33,6 +33,7 @@
"expo-file-system": "57.0.7",
"expo-font": "57.0.4",
"expo-image-picker": "57.0.20",
"expo-keep-awake": "57.0.2",
"expo-linking": "57.0.11",
"expo-localization": "57.0.2",
"expo-modules-core": "57.0.20",

View File

@ -0,0 +1,71 @@
import { expect, type Page, test } from "@playwright/test";
import { captureScreenshot, completeOnboarding, openUserSettings, signup } from "./helpers";
async function useAppearance(page: Page, appearance: "light" | "dark") {
const settings = await openUserSettings(page);
await settings.getByTestId(`ui-appearance-${appearance}`).click();
await expect(page.locator("html")).toHaveAttribute("data-theme", appearance);
await settings.getByRole("button", { name: "Close user settings" }).click();
await expect(settings).toBeHidden();
}
test("a recipe card scales its ingredients and cooks step by step", async ({ page }, testInfo) => {
const stamp = Date.now();
await signup(page, `recipe-card-${stamp}@rakazo.test`, "password12", "Recipe Card");
await completeOnboarding(page);
await page.getByPlaceholder(/Message/).fill("Give me a pancake recipe.");
await page.keyboard.press("Enter");
const card = page.getByTestId("thread-card");
await expect(card).toBeVisible({ timeout: 60_000 });
await expect(card).toContainText("200 g");
await expect(card).toContainText("1 Egg");
await card.getByRole("button", { name: "More Servings" }).click();
await expect(card.getByTestId("card-stepper-value")).toHaveText("3");
await expect(card).toContainText("300 g");
await expect(card).toContainText("1½ Eggs");
await expect(card).toContainText("450 ml");
await useAppearance(page, "light");
await card.scrollIntoViewIfNeeded();
await captureScreenshot(page, testInfo, "recipe-card-light");
await useAppearance(page, "dark");
await card.scrollIntoViewIfNeeded();
await captureScreenshot(page, testInfo, "recipe-card-dark");
await card.getByRole("button", { name: "Cook" }).click();
const cooking = page.getByTestId("cooking-mode");
await expect(cooking).toContainText("Step 1 of 3");
await cooking.getByRole("button", { name: "Next" }).click();
await expect(cooking).toContainText("Step 2 of 3");
await expect(cooking).toContainText("Let the batter rest.");
await cooking.getByRole("button", { name: "Start timer" }).click();
await expect(cooking.getByRole("button", { name: "Stop timer" })).toContainText(/9:5\d/, {
timeout: 10_000,
});
await captureScreenshot(page, testInfo, "cooking-mode-dark");
await closeCookingMode(page);
await useAppearance(page, "light");
await card.getByRole("button", { name: "Cook" }).click();
await expect(cooking).toContainText("Step 2 of 3");
await captureScreenshot(page, testInfo, "cooking-mode-light");
await closeCookingMode(page);
// Servings, step and timer are stored on the card, so a reload keeps them.
await page.reload({ waitUntil: "domcontentloaded" });
const reloaded = page.getByTestId("thread-card");
await expect(reloaded.getByTestId("card-stepper-value")).toHaveText("3", { timeout: 30_000 });
await expect(reloaded).toContainText(/9:[45]\d/);
await reloaded.getByRole("button", { name: "Cook" }).click();
await expect(page.getByTestId("cooking-mode")).toContainText("Step 2 of 3");
await expect(
page.getByTestId("cooking-mode").getByRole("button", { name: "Stop timer" }),
).toBeVisible();
});
async function closeCookingMode(page: Page) {
await page.keyboard.press("Escape");
await expect(page.getByTestId("cooking-mode")).toBeHidden();
}

View File

@ -630,5 +630,11 @@
"Reminder": "Erinnerung",
"Removed": "Entfernt",
"Undo": "Rückgängig",
"Weekdays, {time}": "Werktags, {time}"
"Weekdays, {time}": "Werktags, {time}",
"Fewer {label}": "Weniger {label}",
"More {label}": "Mehr {label}",
"Show {hidden} more": "{hidden} weitere zeigen",
"Start timer": "Timer starten",
"Step {position} of {total}": "Schritt {position} von {total}",
"Stop timer": "Timer stoppen"
}

View File

@ -0,0 +1,137 @@
import { useLingui } from "@lingui/react/macro";
import type { CardAct, CardSection } from "@rakazo/contracts";
import { cardTimerRemaining, formatCardClock } from "@rakazo/core";
import { Button, Dialog, DialogContent, DialogTitle } from "@rakazo/ui-web";
import { ChevronLeft, ChevronRight, Pause, Play } from "lucide-react";
import { useEffect } from "react";
type Steps = Extract<CardSection, { type: "steps" }>;
type WakeLockSentinelLike = { release: () => Promise<void> };
type WakeLockNavigator = Navigator & {
wakeLock?: { request: (type: "screen") => Promise<WakeLockSentinelLike> };
};
/** Keep the screen on while mounted; the browser drops the lock when the tab hides. */
function useScreenWakeLock() {
useEffect(() => {
const wakeLock = (navigator as WakeLockNavigator).wakeLock;
if (!wakeLock) return;
let lock: WakeLockSentinelLike | null = null;
let released = false;
const acquire = () => {
if (document.visibilityState !== "visible") return;
wakeLock
.request("screen")
.then((next) => {
if (released) void next.release();
else lock = next;
})
.catch(() => undefined);
};
acquire();
document.addEventListener("visibilitychange", acquire);
return () => {
released = true;
document.removeEventListener("visibilitychange", acquire);
void lock?.release().catch(() => undefined);
};
}, []);
}
/**
* A card's steps one at a time in full screen, large enough to read from the stove. The step and
* any timer live on the card, so another device or a reload picks up where the cook is.
*/
export function CookingMode({
title,
steps,
now,
busy,
onAct,
onClose,
}: {
title: string;
steps: Steps;
now: number;
busy: boolean;
onAct: (act: CardAct) => Promise<void>;
onClose: () => void;
}) {
const { t } = useLingui();
useScreenWakeLock();
const index = Math.min(steps.current ?? 0, steps.steps.length - 1);
const step = steps.steps[index];
const last = index === steps.steps.length - 1;
const position = index + 1;
const total = steps.steps.length;
const remaining = cardTimerRemaining(step?.timerEndsAt, now);
const running = remaining !== null && remaining > 0;
const go = (next: number) => void onAct({ type: "step", sectionId: steps.id, index: next });
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent
data-testid="cooking-mode"
className="top-0 left-0 flex h-dvh w-screen max-w-none translate-x-0 translate-y-0 flex-col gap-0 rounded-none p-6 ring-0 sm:max-w-none sm:p-10"
>
{/* A plain line, not the display face the app gives headings. */}
<DialogTitle
render={<div />}
className="pe-10 text-ui-body font-medium text-muted-foreground"
>
{title}
</DialogTitle>
<div className="flex flex-1 flex-col justify-center gap-8 py-8">
<div className="text-ui-body text-muted-foreground tabular-nums">
{t`Step ${position} of ${total}`}
</div>
<p className="max-w-3xl text-3xl leading-snug font-medium text-foreground sm:text-5xl sm:leading-tight">
{step?.text}
</p>
{step?.timerSeconds ? (
<Button
variant={running ? "default" : "outline"}
size="lg"
className="self-start text-2xl tabular-nums"
aria-label={running ? t`Stop timer` : t`Start timer`}
disabled={busy}
onClick={() =>
void onAct({
type: "timer",
sectionId: steps.id,
stepId: step.id,
running: !running,
})
}
>
{running ? <Pause /> : <Play />}
{formatCardClock(remaining ?? step.timerSeconds)}
</Button>
) : null}
</div>
<div className="flex items-center justify-between gap-3">
<Button
variant="outline"
size="lg"
disabled={busy || index === 0}
onClick={() => go(index - 1)}
>
<ChevronLeft />
{t`Back`}
</Button>
{last ? (
<Button size="lg" onClick={onClose}>
{t`Done`}
</Button>
) : (
<Button size="lg" disabled={busy} onClick={() => go(index + 1)}>
{t`Next`}
<ChevronRight />
</Button>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -1,6 +1,19 @@
import { useLingui } from "@lingui/react/macro";
import type { CardAct, CardBlock, CardSection } from "@rakazo/contracts";
import { CARD_LIMITS } from "@rakazo/contracts";
import {
applyCardAct,
cardScale,
cardTimerRemaining,
checklistItemDisplay,
formatCardClock,
formatCardTimerLength,
} from "@rakazo/core";
import { Button, Checkbox, cn } from "@rakazo/ui-web";
import { Minus, Plus, Timer } from "lucide-react";
import { useId, useState } from "react";
import { CookingMode } from "./CookingMode";
import { useNow } from "./card-timer";
type Section<T extends CardSection["type"]> = Extract<CardSection, { type: T }>;
@ -9,7 +22,7 @@ type Section<T extends CardSection["type"]> = Extract<CardSection, { type: T }>;
* card re-renders from the updated message, so only the tap in flight is held locally.
*/
export function ThreadCard({
block,
block: stored,
onAct,
className,
}: {
@ -17,10 +30,21 @@ export function ThreadCard({
onAct?: (act: CardAct) => Promise<void>;
className?: string;
}) {
const { t, i18n } = useLingui();
const idPrefix = useId();
const [pending, setPending] = useState<CardAct | null>(null);
const [error, setError] = useState<string | null>(null);
const [expanded, setExpanded] = useState<string[]>([]);
const [cooking, setCooking] = useState(false);
// Show the tap in flight on top of the stored card, so the card answers at once.
const preview = pending ? applyCardAct(stored, pending) : null;
const block = preview?.ok ? preview.block : stored;
const answered = Boolean(block.answer);
const busy = !onAct || pending !== null;
const factor = cardScale(block);
const header = block.sections?.find((part): part is Section<"header"> => part.type === "header");
const steps = block.sections?.find((part): part is Section<"steps"> => part.type === "steps");
const now = useNow(Boolean(steps?.steps.some((step) => step.timerEndsAt)));
async function act(next: CardAct) {
if (!onAct || pending) return;
@ -35,19 +59,29 @@ export function ThreadCard({
}
}
const checkedNow = (list: Section<"checklist">, itemId: string, stored: boolean) =>
pending?.type === "toggle" && pending.sectionId === list.id && pending.itemId === itemId
? pending.checked
: stored;
function visible<T>(id: string, entries: T[]): { shown: T[]; hidden: number } {
if (expanded.includes(id) || entries.length <= CARD_LIMITS.collapsedEntries + 1) {
return { shown: entries, hidden: 0 };
}
return {
shown: entries.slice(0, CARD_LIMITS.collapsedEntries),
hidden: entries.length - CARD_LIMITS.collapsedEntries,
};
}
const selectedNow = (select: Section<"select">, optionId: string) => {
const stored = select.selected?.includes(optionId) ?? false;
return pending?.type === "select" &&
pending.sectionId === select.id &&
pending.optionId === optionId
? (pending.selected ?? true)
: stored;
};
function showMore(id: string, hidden: number) {
if (!hidden) return null;
return (
<Button
variant="ghost"
size="sm"
className="self-start px-0 text-muted-foreground"
onClick={() => setExpanded((ids) => [...ids, id])}
>
{t`Show ${hidden} more`}
</Button>
);
}
const select = block.sections?.find((part): part is Section<"select"> => part.type === "select");
const nothingChosen = Boolean(select?.multiple && !select.selected?.length);
@ -87,43 +121,120 @@ export function ThreadCard({
</dl>
);
}
if (part.type === "checklist") {
if (part.type === "stepper") {
const label = part.label;
return (
<ul key={index} className="flex flex-col gap-2">
{part.items.map((item) => {
const checked = checkedNow(part, item.id, Boolean(item.checked));
return (
<li key={item.id}>
<label
htmlFor={`${idPrefix}-${item.id}`}
className="flex cursor-pointer items-center gap-2.5 text-ui-body"
>
<Checkbox
id={`${idPrefix}-${item.id}`}
checked={checked}
disabled={!onAct || pending !== null}
onCheckedChange={(next) =>
void act({
type: "toggle",
sectionId: part.id,
itemId: item.id,
checked: Boolean(next),
})
}
/>
<span
className={cn(
"text-foreground",
checked && "text-muted-foreground line-through",
)}
<div key={index} className="flex items-center justify-between gap-3 text-ui-body">
<span className="text-muted-foreground">{label}</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon-sm"
aria-label={t`Fewer ${label}`}
disabled={busy || part.value <= 1}
onClick={() =>
void act({ type: "stepper", sectionId: part.id, value: part.value - 1 })
}
>
<Minus />
</Button>
<span
data-testid="card-stepper-value"
className="min-w-8 text-center font-medium tabular-nums text-foreground"
>
{part.value}
</span>
<Button
variant="outline"
size="icon-sm"
aria-label={t`More ${label}`}
disabled={busy || part.value >= CARD_LIMITS.stepperMax}
onClick={() =>
void act({ type: "stepper", sectionId: part.id, value: part.value + 1 })
}
>
<Plus />
</Button>
</div>
</div>
);
}
if (part.type === "checklist") {
const { shown, hidden } = visible(part.id, part.items);
return (
<div key={index} className="flex flex-col gap-2">
<ul className="flex flex-col gap-2">
{shown.map((item) => {
const checked = Boolean(item.checked);
const display = checklistItemDisplay(item, factor);
return (
<li key={item.id}>
<label
htmlFor={`${idPrefix}-${item.id}`}
className="flex cursor-pointer items-center gap-2.5 text-ui-body"
>
{item.label}
<Checkbox
id={`${idPrefix}-${item.id}`}
checked={checked}
disabled={busy}
onCheckedChange={(next) =>
void act({
type: "toggle",
sectionId: part.id,
itemId: item.id,
checked: Boolean(next),
})
}
/>
<span
className={cn(
"text-foreground",
checked && "text-muted-foreground line-through",
)}
>
{display.amount ? (
<span className="font-medium tabular-nums">{display.amount} </span>
) : null}
{display.label}
</span>
</label>
</li>
);
})}
</ul>
{showMore(part.id, hidden)}
</div>
);
}
if (part.type === "steps") {
const { shown, hidden } = visible(part.id, part.steps);
return (
<div key={index} className="flex flex-col gap-2">
<ol className="flex flex-col gap-2.5">
{shown.map((step, position) => {
const remaining = cardTimerRemaining(step.timerEndsAt, now);
return (
<li key={step.id} className="flex gap-2.5 text-ui-body">
<span className="w-4 shrink-0 text-end tabular-nums text-muted-foreground">
{position + 1}
</span>
</label>
</li>
);
})}
</ul>
<span className="flex flex-col gap-1 text-foreground">
{step.text}
{step.timerSeconds ? (
<span className="inline-flex items-center gap-1 text-ui-body-sm text-muted-foreground tabular-nums">
<Timer className="size-3.5" />
{remaining !== null
? formatCardClock(remaining)
: formatCardTimerLength(step.timerSeconds, i18n.locale)}
</span>
) : null}
</span>
</li>
);
})}
</ol>
{showMore(part.id, hidden)}
</div>
);
}
if (part.type === "select") {
@ -133,7 +244,7 @@ export function ThreadCard({
className={cn("flex gap-1.5", part.multiple ? "flex-wrap" : "flex-col")}
>
{part.options.map((option) => {
const chosen = selectedNow(part, option.id);
const chosen = part.selected?.includes(option.id) ?? false;
return (
<Button
key={option.id}
@ -143,7 +254,7 @@ export function ThreadCard({
"h-auto whitespace-normal px-3.5 py-2.5 font-normal",
!part.multiple && "w-full justify-start text-start",
)}
disabled={!onAct || answered || pending !== null}
disabled={busy || answered}
onClick={() =>
void act({
type: "select",
@ -160,23 +271,47 @@ export function ThreadCard({
</div>
);
}
if (answered) return null;
const visibleActions = part.actions.filter(
(action) => action.kind === "steps" || !answered,
);
if (!visibleActions.length) return null;
return (
<div key={index} className="flex flex-wrap gap-2">
{part.actions.map((action, position) => (
<Button
key={action.id}
variant={position === 0 ? "default" : "outline"}
disabled={!onAct || pending !== null || nothingChosen}
onClick={() => void act({ type: "action", actionId: action.id })}
>
{action.label}
</Button>
))}
{visibleActions.map((action, position) =>
action.kind === "steps" ? (
<Button
key={action.id}
variant={position === 0 ? "default" : "outline"}
disabled={!steps}
onClick={() => setCooking(true)}
>
{action.label}
</Button>
) : (
<Button
key={action.id}
variant={position === 0 ? "default" : "outline"}
disabled={busy || nothingChosen}
onClick={() => void act({ type: "action", actionId: action.id })}
>
{action.label}
</Button>
),
)}
</div>
);
})}
{error ? <p className="text-ui-body-sm text-destructive">{error}</p> : null}
{cooking && steps ? (
<CookingMode
title={header?.title ?? ""}
steps={steps}
now={now}
busy={busy}
onAct={act}
onClose={() => setCooking(false)}
/>
) : null}
</div>
);
}

View File

@ -0,0 +1,13 @@
import { useEffect, useState } from "react";
/** The current time, ticking every second while `active`. */
export function useNow(active: boolean): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!active) return;
setNow(Date.now());
const timer = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(timer);
}, [active]);
return now;
}

View File

@ -757,6 +757,7 @@ msgid "Avatar Studio"
msgstr "Avatar-Studio"
#: src/components/browser/InAppBrowser.tsx
#: src/components/CookingMode.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/pages/Onboarding.tsx
#: src/pages/PluginsOverlay.tsx
@ -2059,6 +2060,7 @@ msgid "Don't stress — we're here now."
msgstr "Kein Stress — wir sind jetzt da."
#: src/components/ai/DelegatedTaskCard.tsx
#: src/components/CookingMode.tsx
#: src/components/media/MediaCard.tsx
#: src/pages/HeuteBrief.tsx
#: src/pages/ProjectSessionTimeline.tsx
@ -2312,6 +2314,10 @@ msgstr "Fehlerbehandlung"
msgid "Fast"
msgstr "Schnell"
#: src/components/ThreadCard.tsx
msgid "Fewer {label}"
msgstr "Weniger {label}"
#: src/pages/ModelSettingsOverlay.tsx
msgid "Find models"
msgstr "Modelle suchen"
@ -3071,6 +3077,10 @@ msgstr "Monatlich am {day}., {time}"
msgid "More"
msgstr "Mehr"
#: src/components/ThreadCard.tsx
msgid "More {label}"
msgstr "Mehr {label}"
#: src/components/ComputerMaintenanceActions.tsx
msgid "More computer actions"
msgstr "Weitere Computeraktionen"
@ -3189,6 +3199,10 @@ msgstr "Neuer Skill"
msgid "New space"
msgstr "Neuer Space"
#: src/components/CookingMode.tsx
msgid "Next"
msgstr "Weiter"
#: src/lib/routine-line.ts
msgid "Next {date}"
msgstr "Nächstes Mal {date}"
@ -4289,6 +4303,10 @@ msgstr "Geteilte Dokumente"
msgid "Sharing the draft…"
msgstr "Teile den Entwurf…"
#: src/components/ThreadCard.tsx
msgid "Show {hidden} more"
msgstr "{hidden} weitere zeigen"
#. placeholder {0}: group.bots.length
#: src/pages/Shell.tsx
msgid "Show all {0}"
@ -4476,6 +4494,10 @@ msgstr "Starte die Unterhaltung — {activeName} steht oben auf der Bühne."
msgid "Start the conversation."
msgstr "Starte die Unterhaltung."
#: src/components/CookingMode.tsx
msgid "Start timer"
msgstr "Timer starten"
#: src/pages/shell/composer.tsx
msgid "Start voice conversation"
msgstr "Sprachunterhaltung starten"
@ -4498,6 +4520,10 @@ msgstr "Startet…"
msgid "Staying in my specialty…"
msgstr "Bleibe in meinem Fachgebiet…"
#: src/components/CookingMode.tsx
msgid "Step {position} of {total}"
msgstr "Schritt {position} von {total}"
#: src/components/teach/SkillDraftCard.tsx
msgid "Steps"
msgstr "Schritte"
@ -4517,6 +4543,10 @@ msgstr "Vorlesen stoppen"
msgid "Stop teaching"
msgstr "Anlernen beenden"
#: src/components/CookingMode.tsx
msgid "Stop timer"
msgstr "Timer stoppen"
#. js-lingui-explicit-id
#: src/lib/browser-notifications.ts
msgid "Stopped."

View File

@ -757,6 +757,7 @@ msgid "Avatar Studio"
msgstr "Avatar Studio"
#: src/components/browser/InAppBrowser.tsx
#: src/components/CookingMode.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/pages/Onboarding.tsx
#: src/pages/PluginsOverlay.tsx
@ -2059,6 +2060,7 @@ msgid "Don't stress — we're here now."
msgstr "Don't stress — we're here now."
#: src/components/ai/DelegatedTaskCard.tsx
#: src/components/CookingMode.tsx
#: src/components/media/MediaCard.tsx
#: src/pages/HeuteBrief.tsx
#: src/pages/ProjectSessionTimeline.tsx
@ -2312,6 +2314,10 @@ msgstr "Failure handling"
msgid "Fast"
msgstr "Fast"
#: src/components/ThreadCard.tsx
msgid "Fewer {label}"
msgstr "Fewer {label}"
#: src/pages/ModelSettingsOverlay.tsx
msgid "Find models"
msgstr "Find models"
@ -3071,6 +3077,10 @@ msgstr "Monthly on day {day}, {time}"
msgid "More"
msgstr "More"
#: src/components/ThreadCard.tsx
msgid "More {label}"
msgstr "More {label}"
#: src/components/ComputerMaintenanceActions.tsx
msgid "More computer actions"
msgstr "More computer actions"
@ -3189,6 +3199,10 @@ msgstr "New skill"
msgid "New space"
msgstr "New space"
#: src/components/CookingMode.tsx
msgid "Next"
msgstr "Next"
#: src/lib/routine-line.ts
msgid "Next {date}"
msgstr "Next {date}"
@ -4289,6 +4303,10 @@ msgstr "Shared documents"
msgid "Sharing the draft…"
msgstr "Sharing the draft…"
#: src/components/ThreadCard.tsx
msgid "Show {hidden} more"
msgstr "Show {hidden} more"
#. placeholder {0}: group.bots.length
#: src/pages/Shell.tsx
msgid "Show all {0}"
@ -4476,6 +4494,10 @@ msgstr "Start the conversation — {activeName} is on stage above."
msgid "Start the conversation."
msgstr "Start the conversation."
#: src/components/CookingMode.tsx
msgid "Start timer"
msgstr "Start timer"
#: src/pages/shell/composer.tsx
msgid "Start voice conversation"
msgstr "Start voice conversation"
@ -4498,6 +4520,10 @@ msgstr "Starting…"
msgid "Staying in my specialty…"
msgstr "Staying in my specialty…"
#: src/components/CookingMode.tsx
msgid "Step {position} of {total}"
msgstr "Step {position} of {total}"
#: src/components/teach/SkillDraftCard.tsx
msgid "Steps"
msgstr "Steps"
@ -4517,6 +4543,10 @@ msgstr "Stop speaking"
msgid "Stop teaching"
msgstr "Stop teaching"
#: src/components/CookingMode.tsx
msgid "Stop timer"
msgstr "Stop timer"
#. js-lingui-explicit-id
#: src/lib/browser-notifications.ts
msgid "Stopped."

View File

@ -757,6 +757,7 @@ msgid "Avatar Studio"
msgstr "Estudio de avatares"
#: src/components/browser/InAppBrowser.tsx
#: src/components/CookingMode.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/pages/Onboarding.tsx
#: src/pages/PluginsOverlay.tsx
@ -2059,6 +2060,7 @@ msgid "Don't stress — we're here now."
msgstr ""
#: src/components/ai/DelegatedTaskCard.tsx
#: src/components/CookingMode.tsx
#: src/components/media/MediaCard.tsx
#: src/pages/HeuteBrief.tsx
#: src/pages/ProjectSessionTimeline.tsx
@ -2312,6 +2314,10 @@ msgstr "Manejo de fallos"
msgid "Fast"
msgstr ""
#: src/components/ThreadCard.tsx
msgid "Fewer {label}"
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx
msgid "Find models"
msgstr "Buscar modelos"
@ -3071,6 +3077,10 @@ msgstr ""
msgid "More"
msgstr ""
#: src/components/ThreadCard.tsx
msgid "More {label}"
msgstr ""
#: src/components/ComputerMaintenanceActions.tsx
msgid "More computer actions"
msgstr "Más acciones de computadora"
@ -3189,6 +3199,10 @@ msgstr "Nueva habilidad"
msgid "New space"
msgstr "Nuevo space"
#: src/components/CookingMode.tsx
msgid "Next"
msgstr ""
#: src/lib/routine-line.ts
msgid "Next {date}"
msgstr ""
@ -4289,6 +4303,10 @@ msgstr "Documentos compartidos"
msgid "Sharing the draft…"
msgstr ""
#: src/components/ThreadCard.tsx
msgid "Show {hidden} more"
msgstr ""
#. placeholder {0}: group.bots.length
#: src/pages/Shell.tsx
msgid "Show all {0}"
@ -4476,6 +4494,10 @@ msgstr ""
msgid "Start the conversation."
msgstr ""
#: src/components/CookingMode.tsx
msgid "Start timer"
msgstr ""
#: src/pages/shell/composer.tsx
msgid "Start voice conversation"
msgstr ""
@ -4498,6 +4520,10 @@ msgstr ""
msgid "Staying in my specialty…"
msgstr ""
#: src/components/CookingMode.tsx
msgid "Step {position} of {total}"
msgstr ""
#: src/components/teach/SkillDraftCard.tsx
msgid "Steps"
msgstr "Pasos"
@ -4517,6 +4543,10 @@ msgstr "Dejar de hablar"
msgid "Stop teaching"
msgstr "Detener enseñanza"
#: src/components/CookingMode.tsx
msgid "Stop timer"
msgstr ""
#. js-lingui-explicit-id
#: src/lib/browser-notifications.ts
msgid "Stopped."

View File

@ -757,6 +757,7 @@ msgid "Avatar Studio"
msgstr "अवतार स्टूडियो"
#: src/components/browser/InAppBrowser.tsx
#: src/components/CookingMode.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/pages/Onboarding.tsx
#: src/pages/PluginsOverlay.tsx
@ -2059,6 +2060,7 @@ msgid "Don't stress — we're here now."
msgstr ""
#: src/components/ai/DelegatedTaskCard.tsx
#: src/components/CookingMode.tsx
#: src/components/media/MediaCard.tsx
#: src/pages/HeuteBrief.tsx
#: src/pages/ProjectSessionTimeline.tsx
@ -2312,6 +2314,10 @@ msgstr "विफलता प्रबंधन"
msgid "Fast"
msgstr ""
#: src/components/ThreadCard.tsx
msgid "Fewer {label}"
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx
msgid "Find models"
msgstr "मॉडल खोजें"
@ -3071,6 +3077,10 @@ msgstr ""
msgid "More"
msgstr ""
#: src/components/ThreadCard.tsx
msgid "More {label}"
msgstr ""
#: src/components/ComputerMaintenanceActions.tsx
msgid "More computer actions"
msgstr ""
@ -3189,6 +3199,10 @@ msgstr "नई स्किल"
msgid "New space"
msgstr ""
#: src/components/CookingMode.tsx
msgid "Next"
msgstr ""
#: src/lib/routine-line.ts
msgid "Next {date}"
msgstr ""
@ -4289,6 +4303,10 @@ msgstr "साझा दस्तावेज़"
msgid "Sharing the draft…"
msgstr ""
#: src/components/ThreadCard.tsx
msgid "Show {hidden} more"
msgstr ""
#. placeholder {0}: group.bots.length
#: src/pages/Shell.tsx
msgid "Show all {0}"
@ -4476,6 +4494,10 @@ msgstr ""
msgid "Start the conversation."
msgstr ""
#: src/components/CookingMode.tsx
msgid "Start timer"
msgstr ""
#: src/pages/shell/composer.tsx
msgid "Start voice conversation"
msgstr ""
@ -4498,6 +4520,10 @@ msgstr ""
msgid "Staying in my specialty…"
msgstr ""
#: src/components/CookingMode.tsx
msgid "Step {position} of {total}"
msgstr ""
#: src/components/teach/SkillDraftCard.tsx
msgid "Steps"
msgstr "चरण"
@ -4517,6 +4543,10 @@ msgstr "बोलना रोकें"
msgid "Stop teaching"
msgstr "सिखाना रोकें"
#: src/components/CookingMode.tsx
msgid "Stop timer"
msgstr ""
#. js-lingui-explicit-id
#: src/lib/browser-notifications.ts
msgid "Stopped."

View File

@ -757,6 +757,7 @@ msgid "Avatar Studio"
msgstr "아바타 스튜디오"
#: src/components/browser/InAppBrowser.tsx
#: src/components/CookingMode.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/pages/Onboarding.tsx
#: src/pages/PluginsOverlay.tsx
@ -2059,6 +2060,7 @@ msgid "Don't stress — we're here now."
msgstr ""
#: src/components/ai/DelegatedTaskCard.tsx
#: src/components/CookingMode.tsx
#: src/components/media/MediaCard.tsx
#: src/pages/HeuteBrief.tsx
#: src/pages/ProjectSessionTimeline.tsx
@ -2312,6 +2314,10 @@ msgstr "실패했을 때 처리"
msgid "Fast"
msgstr ""
#: src/components/ThreadCard.tsx
msgid "Fewer {label}"
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx
msgid "Find models"
msgstr "모델 찾기"
@ -3071,6 +3077,10 @@ msgstr ""
msgid "More"
msgstr ""
#: src/components/ThreadCard.tsx
msgid "More {label}"
msgstr ""
#: src/components/ComputerMaintenanceActions.tsx
msgid "More computer actions"
msgstr "컴퓨터 작업 더보기"
@ -3189,6 +3199,10 @@ msgstr "새 스킬"
msgid "New space"
msgstr "새 스페이스"
#: src/components/CookingMode.tsx
msgid "Next"
msgstr ""
#: src/lib/routine-line.ts
msgid "Next {date}"
msgstr ""
@ -4289,6 +4303,10 @@ msgstr "공유 문서"
msgid "Sharing the draft…"
msgstr ""
#: src/components/ThreadCard.tsx
msgid "Show {hidden} more"
msgstr ""
#. placeholder {0}: group.bots.length
#: src/pages/Shell.tsx
msgid "Show all {0}"
@ -4476,6 +4494,10 @@ msgstr ""
msgid "Start the conversation."
msgstr ""
#: src/components/CookingMode.tsx
msgid "Start timer"
msgstr ""
#: src/pages/shell/composer.tsx
msgid "Start voice conversation"
msgstr ""
@ -4498,6 +4520,10 @@ msgstr ""
msgid "Staying in my specialty…"
msgstr ""
#: src/components/CookingMode.tsx
msgid "Step {position} of {total}"
msgstr ""
#: src/components/teach/SkillDraftCard.tsx
msgid "Steps"
msgstr "실행 단계"
@ -4517,6 +4543,10 @@ msgstr "읽기 중지"
msgid "Stop teaching"
msgstr "가르치기 종료"
#: src/components/CookingMode.tsx
msgid "Stop timer"
msgstr ""
#. js-lingui-explicit-id
#: src/lib/browser-notifications.ts
msgid "Stopped."

View File

@ -757,6 +757,7 @@ msgid "Avatar Studio"
msgstr "Estúdio de avatar"
#: src/components/browser/InAppBrowser.tsx
#: src/components/CookingMode.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/pages/Onboarding.tsx
#: src/pages/PluginsOverlay.tsx
@ -2059,6 +2060,7 @@ msgid "Don't stress — we're here now."
msgstr ""
#: src/components/ai/DelegatedTaskCard.tsx
#: src/components/CookingMode.tsx
#: src/components/media/MediaCard.tsx
#: src/pages/HeuteBrief.tsx
#: src/pages/ProjectSessionTimeline.tsx
@ -2312,6 +2314,10 @@ msgstr "Tratamento de falhas"
msgid "Fast"
msgstr ""
#: src/components/ThreadCard.tsx
msgid "Fewer {label}"
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx
msgid "Find models"
msgstr "Encontrar modelos"
@ -3071,6 +3077,10 @@ msgstr ""
msgid "More"
msgstr ""
#: src/components/ThreadCard.tsx
msgid "More {label}"
msgstr ""
#: src/components/ComputerMaintenanceActions.tsx
msgid "More computer actions"
msgstr ""
@ -3189,6 +3199,10 @@ msgstr "Nova habilidade"
msgid "New space"
msgstr ""
#: src/components/CookingMode.tsx
msgid "Next"
msgstr ""
#: src/lib/routine-line.ts
msgid "Next {date}"
msgstr ""
@ -4289,6 +4303,10 @@ msgstr "Documentos compartilhados"
msgid "Sharing the draft…"
msgstr ""
#: src/components/ThreadCard.tsx
msgid "Show {hidden} more"
msgstr ""
#. placeholder {0}: group.bots.length
#: src/pages/Shell.tsx
msgid "Show all {0}"
@ -4476,6 +4494,10 @@ msgstr ""
msgid "Start the conversation."
msgstr ""
#: src/components/CookingMode.tsx
msgid "Start timer"
msgstr ""
#: src/pages/shell/composer.tsx
msgid "Start voice conversation"
msgstr ""
@ -4498,6 +4520,10 @@ msgstr ""
msgid "Staying in my specialty…"
msgstr ""
#: src/components/CookingMode.tsx
msgid "Step {position} of {total}"
msgstr ""
#: src/components/teach/SkillDraftCard.tsx
msgid "Steps"
msgstr "Passos"
@ -4517,6 +4543,10 @@ msgstr "Pare de Falar"
msgid "Stop teaching"
msgstr "Parar de ensinar"
#: src/components/CookingMode.tsx
msgid "Stop timer"
msgstr ""
#. js-lingui-explicit-id
#: src/lib/browser-notifications.ts
msgid "Stopped."

View File

@ -757,6 +757,7 @@ msgid "Avatar Studio"
msgstr "Студия аватаров"
#: src/components/browser/InAppBrowser.tsx
#: src/components/CookingMode.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/pages/Onboarding.tsx
#: src/pages/PluginsOverlay.tsx
@ -2059,6 +2060,7 @@ msgid "Don't stress — we're here now."
msgstr ""
#: src/components/ai/DelegatedTaskCard.tsx
#: src/components/CookingMode.tsx
#: src/components/media/MediaCard.tsx
#: src/pages/HeuteBrief.tsx
#: src/pages/ProjectSessionTimeline.tsx
@ -2312,6 +2314,10 @@ msgstr "Обработка сбоев"
msgid "Fast"
msgstr ""
#: src/components/ThreadCard.tsx
msgid "Fewer {label}"
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx
msgid "Find models"
msgstr "Найти модели"
@ -3071,6 +3077,10 @@ msgstr ""
msgid "More"
msgstr "Ещё"
#: src/components/ThreadCard.tsx
msgid "More {label}"
msgstr ""
#: src/components/ComputerMaintenanceActions.tsx
msgid "More computer actions"
msgstr "Больше действий с компьютером"
@ -3189,6 +3199,10 @@ msgstr "Новый навык"
msgid "New space"
msgstr "Новое пространство"
#: src/components/CookingMode.tsx
msgid "Next"
msgstr ""
#: src/lib/routine-line.ts
msgid "Next {date}"
msgstr ""
@ -4289,6 +4303,10 @@ msgstr "Общие документы"
msgid "Sharing the draft…"
msgstr ""
#: src/components/ThreadCard.tsx
msgid "Show {hidden} more"
msgstr ""
#. placeholder {0}: group.bots.length
#: src/pages/Shell.tsx
msgid "Show all {0}"
@ -4476,6 +4494,10 @@ msgstr ""
msgid "Start the conversation."
msgstr ""
#: src/components/CookingMode.tsx
msgid "Start timer"
msgstr ""
#: src/pages/shell/composer.tsx
msgid "Start voice conversation"
msgstr ""
@ -4498,6 +4520,10 @@ msgstr ""
msgid "Staying in my specialty…"
msgstr ""
#: src/components/CookingMode.tsx
msgid "Step {position} of {total}"
msgstr ""
#: src/components/teach/SkillDraftCard.tsx
msgid "Steps"
msgstr "Шаги"
@ -4517,6 +4543,10 @@ msgstr "Остановить озвучивание"
msgid "Stop teaching"
msgstr "Остановить обучение"
#: src/components/CookingMode.tsx
msgid "Stop timer"
msgstr ""
#. js-lingui-explicit-id
#: src/lib/browser-notifications.ts
msgid "Stopped."

View File

@ -757,6 +757,7 @@ msgid "Avatar Studio"
msgstr "Avatar Stüdyosu"
#: src/components/browser/InAppBrowser.tsx
#: src/components/CookingMode.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/pages/Onboarding.tsx
#: src/pages/PluginsOverlay.tsx
@ -2059,6 +2060,7 @@ msgid "Don't stress — we're here now."
msgstr ""
#: src/components/ai/DelegatedTaskCard.tsx
#: src/components/CookingMode.tsx
#: src/components/media/MediaCard.tsx
#: src/pages/HeuteBrief.tsx
#: src/pages/ProjectSessionTimeline.tsx
@ -2312,6 +2314,10 @@ msgstr "Hata yönetimi"
msgid "Fast"
msgstr ""
#: src/components/ThreadCard.tsx
msgid "Fewer {label}"
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx
msgid "Find models"
msgstr "Model bul"
@ -3071,6 +3077,10 @@ msgstr ""
msgid "More"
msgstr ""
#: src/components/ThreadCard.tsx
msgid "More {label}"
msgstr ""
#: src/components/ComputerMaintenanceActions.tsx
msgid "More computer actions"
msgstr ""
@ -3189,6 +3199,10 @@ msgstr "Yeni beceri"
msgid "New space"
msgstr ""
#: src/components/CookingMode.tsx
msgid "Next"
msgstr ""
#: src/lib/routine-line.ts
msgid "Next {date}"
msgstr ""
@ -4289,6 +4303,10 @@ msgstr "Paylaşılan belgeler"
msgid "Sharing the draft…"
msgstr ""
#: src/components/ThreadCard.tsx
msgid "Show {hidden} more"
msgstr ""
#. placeholder {0}: group.bots.length
#: src/pages/Shell.tsx
msgid "Show all {0}"
@ -4476,6 +4494,10 @@ msgstr ""
msgid "Start the conversation."
msgstr ""
#: src/components/CookingMode.tsx
msgid "Start timer"
msgstr ""
#: src/pages/shell/composer.tsx
msgid "Start voice conversation"
msgstr ""
@ -4498,6 +4520,10 @@ msgstr ""
msgid "Staying in my specialty…"
msgstr ""
#: src/components/CookingMode.tsx
msgid "Step {position} of {total}"
msgstr ""
#: src/components/teach/SkillDraftCard.tsx
msgid "Steps"
msgstr "Adımlar"
@ -4517,6 +4543,10 @@ msgstr "Seslendirmeyi durdur"
msgid "Stop teaching"
msgstr "Öğretmeyi durdur"
#: src/components/CookingMode.tsx
msgid "Stop timer"
msgstr ""
#. js-lingui-explicit-id
#: src/lib/browser-notifications.ts
msgid "Stopped."

View File

@ -757,6 +757,7 @@ msgid "Avatar Studio"
msgstr "头像工作室"
#: src/components/browser/InAppBrowser.tsx
#: src/components/CookingMode.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/pages/Onboarding.tsx
#: src/pages/PluginsOverlay.tsx
@ -2059,6 +2060,7 @@ msgid "Don't stress — we're here now."
msgstr ""
#: src/components/ai/DelegatedTaskCard.tsx
#: src/components/CookingMode.tsx
#: src/components/media/MediaCard.tsx
#: src/pages/HeuteBrief.tsx
#: src/pages/ProjectSessionTimeline.tsx
@ -2312,6 +2314,10 @@ msgstr "失败处理"
msgid "Fast"
msgstr ""
#: src/components/ThreadCard.tsx
msgid "Fewer {label}"
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx
msgid "Find models"
msgstr "查找模型"
@ -3071,6 +3077,10 @@ msgstr ""
msgid "More"
msgstr ""
#: src/components/ThreadCard.tsx
msgid "More {label}"
msgstr ""
#: src/components/ComputerMaintenanceActions.tsx
msgid "More computer actions"
msgstr ""
@ -3189,6 +3199,10 @@ msgstr "新建技能"
msgid "New space"
msgstr "新建工作区"
#: src/components/CookingMode.tsx
msgid "Next"
msgstr ""
#: src/lib/routine-line.ts
msgid "Next {date}"
msgstr ""
@ -4289,6 +4303,10 @@ msgstr "共享文档"
msgid "Sharing the draft…"
msgstr ""
#: src/components/ThreadCard.tsx
msgid "Show {hidden} more"
msgstr ""
#. placeholder {0}: group.bots.length
#: src/pages/Shell.tsx
msgid "Show all {0}"
@ -4476,6 +4494,10 @@ msgstr ""
msgid "Start the conversation."
msgstr ""
#: src/components/CookingMode.tsx
msgid "Start timer"
msgstr ""
#: src/pages/shell/composer.tsx
msgid "Start voice conversation"
msgstr ""
@ -4498,6 +4520,10 @@ msgstr ""
msgid "Staying in my specialty…"
msgstr ""
#: src/components/CookingMode.tsx
msgid "Step {position} of {total}"
msgstr ""
#: src/components/teach/SkillDraftCard.tsx
msgid "Steps"
msgstr "步骤"
@ -4517,6 +4543,10 @@ msgstr "停止朗读"
msgid "Stop teaching"
msgstr "停止示范"
#: src/components/CookingMode.tsx
msgid "Stop timer"
msgstr ""
#. js-lingui-explicit-id
#: src/lib/browser-notifications.ts
msgid "Stopped."

View File

@ -247,7 +247,7 @@ export const builtinAgentTools: ConnectorTool[] = [
{
name: "show_card",
description:
"Show the user a card instead of a list in prose: a title, short facts, a checklist they can tick off, options they tap, or up to three buttons. Use it for everyday things worth keeping in view, such as a shopping list, a plan for the day or a choice between options. A tap on an option or button arrives as the user's next message. Keep text short; the card is the answer, so do not repeat it in your reply.",
'Show the user a card instead of a list in prose: a title, short facts, a servings stepper, a checklist they tick off, numbered steps with timers, options they tap, or up to three buttons. Use it for everyday things worth keeping in view. Templates: a recipe is title + meta ["30 min"] + stepper {label "Servings", value} + checklist of ingredients with amount and unit + steps with timer_minutes + start_label "Cook" + actions ["Add to shopping list", "Every week"]; a shopping list is a checklist; a day plan is steps starting with the time; a decision is facts plus options. A tap on an option or button arrives as the user\'s next message. Keep text short; the card is the answer, so do not repeat it in your reply. At most five parts including the title.',
inputSchema: {
type: "object",
properties: {
@ -271,11 +271,60 @@ export const builtinAgentTools: ConnectorTool[] = [
},
maxItems: 8,
},
stepper: {
type: "object",
description:
"A number the user turns up or down, such as servings. Checklist amounts are written for this value and scale with it.",
properties: {
label: { type: "string", maxLength: 80 },
value: { type: "integer", minimum: 1, maximum: 99 },
},
required: ["label", "value"],
},
checklist: {
type: "array",
items: { type: "string", minLength: 1, maxLength: 80 },
description:
'Entries to tick off. An ingredient is { label: "Flour", amount: 200, unit: "g" }; a counted one is { label: "Egg", plural: "Eggs", amount: 1 }.',
items: {
anyOf: [
{ type: "string", minLength: 1, maxLength: 80 },
{
type: "object",
properties: {
label: { type: "string", minLength: 1, maxLength: 80 },
amount: { type: "number", exclusiveMinimum: 0 },
unit: { type: "string", maxLength: 20 },
plural: { type: "string", maxLength: 80 },
},
required: ["label"],
},
],
},
maxItems: 30,
},
steps: {
type: "array",
items: {
anyOf: [
{ type: "string", minLength: 1, maxLength: 300 },
{
type: "object",
properties: {
text: { type: "string", minLength: 1, maxLength: 300 },
timer_minutes: { type: "number", exclusiveMinimum: 0, maximum: 1440 },
},
required: ["text"],
},
],
},
maxItems: 20,
},
start_label: {
type: "string",
maxLength: 80,
description:
'A button that walks through the steps one at a time in full screen, such as "Cook".',
},
options: {
type: "array",
items: { type: "string", minLength: 1, maxLength: 80 },

View File

@ -295,6 +295,36 @@ export function inferScript(
},
];
}
if (lower.includes("pancake recipe")) {
return [
{
assistant: "",
toolCalls: [
{
name: "show_card",
args: {
title: "Pancakes",
meta: ["25 min"],
stepper: { label: "Servings", value: 2 },
checklist: [
{ label: "Flour", amount: 200, unit: "g" },
{ label: "Egg", plural: "Eggs", amount: 1 },
{ label: "Milk", amount: 300, unit: "ml" },
],
steps: [
"Whisk flour, egg and milk until smooth.",
{ text: "Let the batter rest.", timer_minutes: 10 },
"Fry thin pancakes in a hot pan.",
],
start_label: "Cook",
actions: ["Every week"],
},
},
],
complete: true,
},
];
}
if (lower.includes("delegate to the bot named")) {
const name = namedBot(prompt) ?? "Researcher";
const brief =

View File

@ -6,8 +6,15 @@ import * as z from "zod";
*/
export const CARD_LIMITS = {
sections: 5,
facts: 8,
checklistItems: 30,
steps: 20,
stepText: 300,
/** Longer lists show this many entries until the user opens the rest. */
collapsedEntries: 8,
stepperMax: 99,
timerSeconds: 24 * 60 * 60,
selectOptions: 6,
actions: 3,
label: 80,
@ -34,15 +41,63 @@ export const CardFactsSchema = z.object({
.max(CARD_LIMITS.facts),
});
/** "200 g" of something, scaled by the card's stepper; `plural` names more than one ("Eggs"). */
export const CardQuantitySchema = z.object({
amount: z.number().positive().max(100_000),
unit: z.string().trim().max(20).optional(),
plural: Label.optional(),
});
export type CardQuantity = z.infer<typeof CardQuantitySchema>;
export const CardChecklistSchema = z.object({
type: z.literal("checklist"),
id: SectionId,
items: z
.array(z.object({ id: SectionId, label: Label, checked: z.boolean().optional() }))
.array(
z.object({
id: SectionId,
label: Label,
quantity: CardQuantitySchema.optional(),
checked: z.boolean().optional(),
}),
)
.min(1)
.max(CARD_LIMITS.checklistItems),
});
/** A number the user turns up or down, such as servings; quantities scale from `base`. */
export const CardStepperSchema = z.object({
type: z.literal("stepper"),
id: SectionId,
label: Label,
base: z.number().int().min(1).max(CARD_LIMITS.stepperMax),
value: z.number().int().min(1).max(CARD_LIMITS.stepperMax),
});
/** Numbered steps, optionally timed; the card remembers the current step and running timers. */
export const CardStepsSchema = z.object({
type: z.literal("steps"),
id: SectionId,
steps: z
.array(
z.object({
id: SectionId,
text: z.string().trim().min(1).max(CARD_LIMITS.stepText),
timerSeconds: z.number().int().min(1).max(CARD_LIMITS.timerSeconds).optional(),
/** When a started timer runs out; stored so it survives a reload or another device. */
timerEndsAt: z.string().datetime().optional(),
}),
)
.min(1)
.max(CARD_LIMITS.steps),
current: z
.number()
.int()
.min(0)
.max(CARD_LIMITS.steps - 1)
.optional(),
});
export const CardSelectSchema = z.object({
type: z.literal("select"),
id: SectionId,
@ -55,11 +110,14 @@ export const CardSelectSchema = z.object({
selected: z.array(SectionId).optional(),
});
/** "reply" sends the label, with any selection, to the bot as the user's answer. */
/**
* "reply" sends the label, with any selection, to the bot as the user's answer. "steps" opens the
* card's steps one at a time in full screen (cooking mode) and never reaches the bot.
*/
export const CardActionSchema = z.object({
id: SectionId,
label: Label,
kind: z.literal("reply"),
kind: z.enum(["reply", "steps"]),
});
export const CardActionsSchema = z.object({
@ -70,7 +128,9 @@ export const CardActionsSchema = z.object({
export const CardSectionSchema = z.discriminatedUnion("type", [
CardHeaderSchema,
CardFactsSchema,
CardStepperSchema,
CardChecklistSchema,
CardStepsSchema,
CardSelectSchema,
CardActionsSchema,
]);
@ -87,7 +147,7 @@ export const CardBlockSchema = z.object({
kind: z.literal("card"),
id: SectionId.optional(),
lines: z.array(z.object({ k: z.string(), v: z.string() })).optional(),
sections: z.array(CardSectionSchema).max(5).optional(),
sections: z.array(CardSectionSchema).max(CARD_LIMITS.sections).optional(),
answer: CardAnswerSchema.optional(),
});
export type CardBlock = z.infer<typeof CardBlockSchema>;
@ -108,5 +168,25 @@ export const CardActSchema = z.discriminatedUnion("type", [
selected: z.boolean().optional(),
}),
z.object({ type: z.literal("action"), actionId: SectionId }),
z.object({
type: z.literal("stepper"),
sectionId: SectionId,
value: z.number().int().min(1).max(CARD_LIMITS.stepperMax),
}),
z.object({
type: z.literal("step"),
sectionId: SectionId,
index: z
.number()
.int()
.min(0)
.max(CARD_LIMITS.steps - 1),
}),
z.object({
type: z.literal("timer"),
sectionId: SectionId,
stepId: SectionId,
running: z.boolean(),
}),
]);
export type CardAct = z.infer<typeof CardActSchema>;

View File

@ -1,6 +1,13 @@
import type { CardBlock } from "@rakazo/contracts";
import { describe, expect, it } from "vitest";
import { applyCardAct, buildCardFromTool, cardHistoryText } from "./cards.js";
import {
applyCardAct,
buildCardFromTool,
cardHistoryText,
cardScale,
checklistItemDisplay,
formatCardAmount,
} from "./cards.js";
function card(args: Parameters<typeof buildCardFromTool>[0]): CardBlock {
const built = buildCardFromTool(args, "c1");
@ -104,3 +111,128 @@ describe("applyCardAct", () => {
expect(cardHistoryText({ kind: "card", lines: [{ k: "Mail", v: "sent" }] })).toBe("Mail: sent");
});
});
describe("recipe cards", () => {
const recipe = () =>
card({
title: "Pancakes",
stepper: { label: "Servings", value: 2 },
checklist: [
{ label: "Flour", amount: 200, unit: "g" },
{ label: "Egg", plural: "Eggs", amount: 1 },
"Salt",
],
steps: ["Mix everything.", { text: "Rest the batter.", timer_minutes: 10 }],
start_label: "Cook",
actions: ["Every week"],
});
it("lays a recipe out as header, servings, ingredients, steps and actions", () => {
const block = recipe();
expect(block.sections?.map((part) => part.type)).toEqual([
"header",
"stepper",
"checklist",
"steps",
"actions",
]);
expect(block.sections?.[3]).toMatchObject({ steps: [{ id: "s1" }, { timerSeconds: 600 }] });
expect(block.sections?.[4]).toMatchObject({
actions: [
{ id: "start", kind: "steps" },
{ id: "a1", kind: "reply" },
],
});
});
it("scales 200 g to 300 g and one egg to 1½ eggs for three servings", () => {
const scaled = applyCardAct(recipe(), { type: "stepper", sectionId: "stepper", value: 3 });
if (!scaled.ok) throw new Error(scaled.error);
const factor = cardScale(scaled.block);
const list = scaled.block.sections?.[2];
if (list?.type !== "checklist") throw new Error("no checklist");
expect(list.items.map((item) => checklistItemDisplay(item, factor))).toEqual([
{ amount: "300 g", label: "Flour" },
{ amount: "1½", label: "Eggs" },
{ label: "Salt" },
]);
expect(cardHistoryText(scaled.block)).toContain("Servings: 3");
expect(cardHistoryText(scaled.block)).toContain("open: 300 g Flour, 1½ Eggs, Salt");
});
it("writes amounts the way a cook would", () => {
expect(formatCardAmount(1)).toBe("1");
expect(formatCardAmount(0.5)).toBe("½");
expect(formatCardAmount(2.34)).toBe("2⅓");
expect(formatCardAmount(2.9)).toBe("3");
expect(formatCardAmount(12.6)).toBe("13");
expect(formatCardAmount(299.99)).toBe("300");
expect(formatCardAmount(0.05)).toBe("0.1");
expect(
checklistItemDisplay({ label: "Egg", quantity: { amount: 1, plural: "Eggs" } }, 1),
).toEqual({
amount: "1",
label: "Egg",
});
});
it("remembers the step and a timer's end, idempotently", () => {
const now = new Date("2026-10-04T12:00:00Z");
const moved = applyCardAct(recipe(), { type: "step", sectionId: "steps", index: 1 });
if (!moved.ok) throw new Error(moved.error);
const started = applyCardAct(
moved.block,
{ type: "timer", sectionId: "steps", stepId: "s2", running: true },
now,
);
if (!started.ok) throw new Error(started.error);
expect(started.block.sections?.[3]).toMatchObject({
current: 1,
steps: [{}, { timerEndsAt: "2026-10-04T12:10:00.000Z" }],
});
const again = applyCardAct(
started.block,
{ type: "timer", sectionId: "steps", stepId: "s2", running: true },
new Date("2026-10-04T12:05:00Z"),
);
expect(again).toMatchObject({ ok: true, changed: false });
const stopped = applyCardAct(
started.block,
{
type: "timer",
sectionId: "steps",
stepId: "s2",
running: false,
},
new Date("2026-10-04T12:05:00Z"),
);
const stoppedSteps = stopped.ok ? stopped.block.sections?.[3] : undefined;
expect(stoppedSteps?.type === "steps" && stoppedSteps.steps[1]?.timerEndsAt).toBeFalsy();
expect(stopped).toMatchObject({ ok: true, changed: true });
expect(
applyCardAct(moved.block, { type: "timer", sectionId: "steps", stepId: "s1", running: true }),
).toMatchObject({ ok: false });
expect(applyCardAct(recipe(), { type: "action", actionId: "start" })).toMatchObject({
ok: false,
});
});
it("refuses more than five parts and a start without steps", () => {
expect(
buildCardFromTool(
{
title: "Too much",
facts: [{ label: "a", value: "b" }],
stepper: { label: "Servings", value: 2 },
checklist: ["x"],
steps: ["y"],
actions: ["z"],
},
"c1",
),
).toMatchObject({ error: expect.stringContaining("at most 5") });
expect(
buildCardFromTool({ title: "No steps", checklist: ["x"], start_label: "Cook" }, "c1"),
).toHaveProperty("error");
});
});

View File

@ -1,5 +1,9 @@
import type { CardAct, CardBlock, CardSection } from "@rakazo/contracts";
import { CardBlockSchema } from "@rakazo/contracts";
import type { CardAct, CardBlock, CardQuantity, CardSection } from "@rakazo/contracts";
import { CARD_LIMITS, CardBlockSchema } from "@rakazo/contracts";
type CardChecklistItem = Extract<CardSection, { type: "checklist" }>["items"][number];
type CardStep = Extract<CardSection, { type: "steps" }>["steps"][number];
type CardAction = Extract<CardSection, { type: "actions" }>["actions"][number];
/** What `show_card` accepts: plain content in a fixed order, never layout. */
export interface ShowCardArgs {
@ -7,7 +11,10 @@ export interface ShowCardArgs {
subtitle?: unknown;
meta?: unknown;
facts?: unknown;
stepper?: unknown;
checklist?: unknown;
steps?: unknown;
start_label?: unknown;
options?: unknown;
multiple?: unknown;
actions?: unknown;
@ -18,6 +25,48 @@ const strings = (value: unknown): string[] =>
const text = (value: unknown) => (typeof value === "string" ? value.trim() : "");
const field = (value: unknown, key: string): unknown =>
value && typeof value === "object" ? (value as Record<string, unknown>)[key] : undefined;
const positive = (value: unknown) =>
typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
function checklistItems(value: unknown): CardChecklistItem[] {
if (!Array.isArray(value)) return [];
const items: CardChecklistItem[] = [];
for (const raw of value) {
const label = typeof raw === "string" ? raw.trim() : text(field(raw, "label"));
if (!label) continue;
const amount = positive(field(raw, "amount"));
const unit = text(field(raw, "unit"));
const plural = text(field(raw, "plural"));
items.push({
id: `i${items.length + 1}`,
label,
...(amount
? { quantity: { amount, ...(unit ? { unit } : {}), ...(plural ? { plural } : {}) } }
: {}),
});
}
return items;
}
function stepItems(value: unknown): CardStep[] {
if (!Array.isArray(value)) return [];
const steps: CardStep[] = [];
for (const raw of value) {
const stepText = typeof raw === "string" ? raw.trim() : text(field(raw, "text"));
if (!stepText) continue;
const minutes = positive(field(raw, "timer_minutes"));
steps.push({
id: `s${steps.length + 1}`,
text: stepText,
...(minutes ? { timerSeconds: Math.round(minutes * 60) } : {}),
});
}
return steps;
}
/** Build a validated card from tool arguments; the error is phrased for the model. */
export function buildCardFromTool(
args: ShowCardArgs,
@ -27,25 +76,31 @@ export function buildCardFromTool(
if (!title) return { error: "title is required." };
const facts = Array.isArray(args.facts)
? args.facts
.map((row) => ({
label: text((row as { label?: unknown })?.label),
value: text((row as { value?: unknown })?.value),
}))
.map((row) => ({ label: text(field(row, "label")), value: text(field(row, "value")) }))
.filter((row) => row.label && row.value)
: [];
const checklist = strings(args.checklist);
const stepperLabel = text(field(args.stepper, "label"));
const stepperValue = positive(field(args.stepper, "value"));
const checklist = checklistItems(args.checklist);
const steps = stepItems(args.steps);
const startLabel = text(args.start_label);
const options = strings(args.options);
const actions = strings(args.actions);
const multiple = args.multiple === true;
if (!facts.length && !checklist.length && !options.length && !actions.length) {
if (!facts.length && !checklist.length && !steps.length && !options.length && !actions.length) {
return {
error: "Add facts, checklist, options or actions; a card with only a title says nothing.",
error:
"Add facts, checklist, steps, options or actions; a card with only a title says nothing.",
};
}
if (options.length === 1) return { error: "options needs at least two choices." };
if (multiple && options.length && !actions.length) {
return { error: 'A multiple choice needs an action to send it, such as "Send".' };
}
if (args.stepper !== undefined && (!stepperLabel || !stepperValue)) {
return { error: 'stepper needs a label and a whole number value, such as "Servings", 2.' };
}
if (startLabel && !steps.length) return { error: "start_label needs steps to walk through." };
const subtitle = text(args.subtitle);
const meta = strings(args.meta);
const sections: CardSection[] = [
@ -57,13 +112,12 @@ export function buildCardFromTool(
},
];
if (facts.length) sections.push({ type: "facts", rows: facts });
if (checklist.length) {
sections.push({
type: "checklist",
id: "checklist",
items: checklist.map((label, index) => ({ id: `i${index + 1}`, label })),
});
if (stepperLabel && stepperValue) {
const value = Math.round(stepperValue);
sections.push({ type: "stepper", id: "stepper", label: stepperLabel, base: value, value });
}
if (checklist.length) sections.push({ type: "checklist", id: "checklist", items: checklist });
if (steps.length) sections.push({ type: "steps", id: "steps", steps });
if (options.length) {
sections.push({
type: "select",
@ -72,11 +126,15 @@ export function buildCardFromTool(
options: options.map((label, index) => ({ id: `o${index + 1}`, label })),
});
}
if (actions.length) {
sections.push({
type: "actions",
actions: actions.map((label, index) => ({ id: `a${index + 1}`, label, kind: "reply" })),
});
const allActions: CardAction[] = [
...(startLabel ? [{ id: "start", label: startLabel, kind: "steps" as const }] : []),
...actions.map((label, index) => ({ id: `a${index + 1}`, label, kind: "reply" as const })),
];
if (allActions.length) sections.push({ type: "actions", actions: allActions });
if (sections.length > CARD_LIMITS.sections) {
return {
error: `A card holds at most ${CARD_LIMITS.sections} parts including the title; leave out facts, options or actions.`,
};
}
const parsed = CardBlockSchema.safeParse({ kind: "card", id, sections });
if (!parsed.success) {
@ -107,18 +165,69 @@ function replaceSection(block: CardBlock, next: CardSection, index: number): Car
return { ...block, sections };
}
function sectionIndex(block: CardBlock, type: CardSection["type"], id: string) {
return (block.sections ?? []).findIndex(
(candidate) => candidate.type === type && "id" in candidate && candidate.id === id,
);
}
/**
* Apply one tap. Checklists stay usable after the card is answered (a shopping list is still a
* shopping list); a choice or an action answers the card once.
* Apply one tap. Checklists, servings, steps and timers stay usable after the card is answered
* (a shopping list is still a shopping list); a choice or an action answers the card once.
* Every act names the state it wants, so a retried tap changes nothing.
*/
export function applyCardAct(block: CardBlock, act: CardAct): CardActResult {
export function applyCardAct(
block: CardBlock,
act: CardAct,
now: Date = new Date(),
): CardActResult {
const sections = block.sections ?? [];
if (act.type === "stepper") {
const index = sectionIndex(block, "stepper", act.sectionId);
const stepper = sections[index];
if (stepper?.type !== "stepper") return { ok: false, error: "No such stepper." };
if (stepper.value === act.value) return { ok: true, changed: false, block };
return {
ok: true,
changed: true,
block: replaceSection(block, { ...stepper, value: act.value }, index),
};
}
if (act.type === "step" || act.type === "timer") {
const index = sectionIndex(block, "steps", act.sectionId);
const list = sections[index];
if (list?.type !== "steps") return { ok: false, error: "No such steps." };
if (act.type === "step") {
if (act.index >= list.steps.length) return { ok: false, error: "No such step." };
if ((list.current ?? 0) === act.index) return { ok: true, changed: false, block };
return {
ok: true,
changed: true,
block: replaceSection(block, { ...list, current: act.index }, index),
};
}
const step = list.steps.find((candidate) => candidate.id === act.stepId);
if (!step?.timerSeconds) return { ok: false, error: "This step has no timer." };
const running = Boolean(step.timerEndsAt && Date.parse(step.timerEndsAt) > now.getTime());
if (running === act.running) return { ok: true, changed: false, block };
const steps = list.steps.map((candidate) => {
if (candidate.id !== step.id) return candidate;
const { timerEndsAt: _ended, ...rest } = candidate;
return act.running
? {
...rest,
timerEndsAt: new Date(now.getTime() + (step.timerSeconds ?? 0) * 1000).toISOString(),
}
: rest;
});
return { ok: true, changed: true, block: replaceSection(block, { ...list, steps }, index) };
}
if (act.type === "toggle") {
const index = sections.findIndex(
(candidate) => candidate.type === "checklist" && candidate.id === act.sectionId,
);
const list = sections[index];
if (!list || list.type !== "checklist") return { ok: false, error: "No such checklist." };
if (list?.type !== "checklist") return { ok: false, error: "No such checklist." };
if (!list.items.some((item) => item.id === act.itemId)) {
return { ok: false, error: "No such item." };
}
@ -135,7 +244,7 @@ export function applyCardAct(block: CardBlock, act: CardAct): CardActResult {
(candidate) => candidate.type === "select" && candidate.id === act.sectionId,
);
const select = sections[index];
if (!select || select.type !== "select") return { ok: false, error: "No such choice." };
if (select?.type !== "select") return { ok: false, error: "No such choice." };
const option = select.options.find((candidate) => candidate.id === act.optionId);
if (!option) return { ok: false, error: "No such option." };
if (select.multiple) {
@ -163,6 +272,7 @@ export function applyCardAct(block: CardBlock, act: CardAct): CardActResult {
const actions = section(block, "actions");
const action = actions?.actions.find((candidate) => candidate.id === act.actionId);
if (!action) return { ok: false, error: "No such action." };
if (action.kind !== "reply") return { ok: false, error: "This action opens on the device." };
const select = section(block, "select");
const chosen = select?.options
.filter((option) => select.selected?.includes(option.id))
@ -188,9 +298,16 @@ export function cardHistoryText(block: CardBlock): string {
parts.push([part.title, part.subtitle, ...(part.meta ?? [])].filter(Boolean).join(" · "));
} else if (part.type === "facts") {
parts.push(part.rows.map((row) => `${row.label}: ${row.value}`).join("; "));
} else if (part.type === "stepper") {
parts.push(`${part.label}: ${part.value}`);
} else if (part.type === "steps") {
parts.push(`step ${(part.current ?? 0) + 1}/${part.steps.length}`);
} else if (part.type === "checklist") {
const factor = cardScale(block);
const done = part.items.filter((item) => item.checked).length;
const open = part.items.filter((item) => !item.checked).map((item) => item.label);
const open = part.items
.filter((item) => !item.checked)
.map((item) => checklistItemText(item, factor));
parts.push(
`checklist ${done}/${part.items.length} done${open.length ? `, open: ${open.join(", ")}` : ""}`,
);
@ -208,3 +325,83 @@ export function cardHistoryText(block: CardBlock): string {
if (block.answer) parts.push(`answered: ${block.answer.text}`);
return `[card: ${parts.join(" — ")}]`;
}
/** How much the card's quantities scale: the stepper's value over the amount they were written for. */
export function cardScale(block: CardBlock): number {
const stepper = block.sections?.find((part) => part.type === "stepper");
return stepper?.type === "stepper" ? stepper.value / stepper.base : 1;
}
const FRACTIONS: Array<[number, string]> = [
[0, ""],
[1 / 4, "¼"],
[1 / 3, "⅓"],
[1 / 2, "½"],
[2 / 3, "⅔"],
[3 / 4, "¾"],
[1, ""],
];
/**
* A scaled amount as a cook would write it: kitchen fractions below ten ("1½"), whole numbers
* above, and steps of five from a hundred on ("300 g", never "299.99 g").
*/
export function formatCardAmount(amount: number): string {
if (amount >= 100) return String(Math.round(amount / 5) * 5);
if (amount >= 10) return String(Math.round(amount));
let whole = Math.floor(amount);
const rest = amount - whole;
const [value, glyph] = FRACTIONS.reduce((best, candidate) =>
Math.abs(candidate[0] - rest) < Math.abs(best[0] - rest) ? candidate : best,
);
if (value === 1) whole += 1;
if (whole === 0) return glyph || formatSmallAmount(amount);
return `${whole}${glyph}`;
}
/** Below an eighth, kitchen fractions would round a pinch away; keep one decimal instead. */
function formatSmallAmount(amount: number): string {
return String(Math.max(0.1, Math.round(amount * 10) / 10));
}
/** A checklist entry's amount and name at the card's current scale. */
export function checklistItemDisplay(
item: { label: string; quantity?: CardQuantity },
factor: number,
): { amount?: string; label: string } {
if (!item.quantity) return { label: item.label };
const formatted = formatCardAmount(item.quantity.amount * factor);
const many = !item.quantity.unit && formatted !== "1" && Number.parseFloat(formatted) >= 1;
return {
amount: [formatted, item.quantity.unit].filter(Boolean).join(" "),
label: many && item.quantity.plural ? item.quantity.plural : item.label,
};
}
function checklistItemText(item: { label: string; quantity?: CardQuantity }, factor: number) {
const shown = checklistItemDisplay(item, factor);
return shown.amount ? `${shown.amount} ${shown.label}` : shown.label;
}
/** Seconds left on a started step timer, or null when it is not running. */
export function cardTimerRemaining(timerEndsAt: string | undefined, now: number): number | null {
if (!timerEndsAt) return null;
return Math.max(0, Math.ceil((Date.parse(timerEndsAt) - now) / 1000));
}
/** A timer as a clock: "9:58" or "1:05:00". */
export function formatCardClock(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const rest = String(seconds % 60).padStart(2, "0");
return hours ? `${hours}:${String(minutes).padStart(2, "0")}:${rest}` : `${minutes}:${rest}`;
}
/** A timer's length in the user's language, such as "10 min". */
export function formatCardTimerLength(seconds: number, locale: string): string {
const unit = (value: number, name: "second" | "minute" | "hour") =>
new Intl.NumberFormat(locale, { style: "unit", unit: name }).format(value);
if (seconds < 60) return unit(seconds, "second");
const minutes = Math.round(seconds / 60);
return minutes % 60 === 0 ? unit(minutes / 60, "hour") : unit(minutes, "minute");
}

3
pnpm-lock.yaml generated
View File

@ -170,6 +170,9 @@ importers:
expo-image-picker:
specifier: 57.0.20
version: 57.0.20(expo@57.0.26)
expo-keep-awake:
specifier: 57.0.2
version: 57.0.2(expo@57.0.26)(react@19.2.3)
expo-linking:
specifier: 57.0.11
version: 57.0.11(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3)