diff --git a/interaction-lab/all-for-adhd/app.ts b/interaction-lab/all-for-adhd/app.ts index 3e51a21..0bed13a 100644 --- a/interaction-lab/all-for-adhd/app.ts +++ b/interaction-lab/all-for-adhd/app.ts @@ -7,17 +7,19 @@ * window instead of a confirmation dialog. Japanese-only while prototyping. */ -import { createElement, Sun, Moon, CalendarDays, ListTodo, Users, Settings, Plus, Play, Clock3, ArrowRight, MoreHorizontal, Flag, type IconNode } from "lucide"; +import { createElement, Sun, Moon, CalendarDays, ListTodo, Users, Settings, Plus, Play, Clock3, ArrowRight, ArrowUpRight, ChevronLeft, ChevronRight, Repeat2, MessageSquare, Check, Search, MoreHorizontal, Flag, X, Pause, TriangleAlert, GripVertical, type IconNode } from "lucide"; import type { CharacterState, Quest } from "../../types/questforge.ts"; import { el, replaceChildren, svg } from "../relay-forge/primitives/dom.ts"; import { celebrate } from "./celebrate.ts"; import { getLocale, setLocale, LOCALE_METADATA, SUPPORTED_LOCALES } from "../../i18n.ts"; import { tx } from "./copy.ts"; import { questImageEditor, type QuestImagePort } from "./images.ts"; -import type { RelayForgeRuntime } from "../relay-forge/production.ts"; +import { normalizeProfileRecord, type RelayForgeRuntime } from "../relay-forge/production.ts"; +import { resizeAvatarImage } from "../relay-forge/primitives/image-resize.ts"; import { productivityWorkspace } from "../relay-forge/screens/productivity.ts"; import { createBillingUi } from "../relay-forge/screens/billing.ts"; import { createAnalyticsUi } from "../relay-forge/screens/analytics.ts"; +import { billingView } from "./billing-view.ts"; import { forgeScreens, type ForgeScreen } from "./forge.ts"; import { parseCapture, isBig } from "./capture.ts"; import { iconChoices, iconFor } from "./emoji.ts"; @@ -105,11 +107,11 @@ const VIEWS: readonly { id: View; label: string }[] = [ /** The rest of Relay Forge, reached from Settings so the five tabs stay calm. */ const EXTRAS: readonly { id: Extra; label: string; hint: string }[] = [ { id: "profile", label: tx("プロフィール"), hint: tx("表示名・ユーザー名・ひとこと") }, - { id: "network", label: tx("つながり"), hint: tx("タスクと人と連携の関係を図で見る") }, + { id: "network", label: tx("つながり"), hint: tx("止まっているタスクと、その理由が分かります") }, { id: "battle", label: tx("バトル"), hint: tx("終えたタスクで戦うゲーム") }, { id: "connections", label: tx("連携"), hint: tx("Google などの外部サービスと同期") }, - { id: "skills", label: tx("スキル"), hint: tx("Agent が使える MCP ツールの一覧") }, - { id: "billing", label: tx("プラン"), hint: tx("利用プランと支払い") }, + { id: "skills", label: tx("スキル"), hint: tx("Agentが使えるMCPツールの一覧") }, + { id: "billing", label: tx("利用プラン"), hint: tx("利用プランと支払い") }, ]; const FORGE: readonly Extra[] = ["network", "battle", "connections", "skills"]; const isExtra = (view: View): view is Extra => EXTRAS.some(item => item.id === view); @@ -127,10 +129,10 @@ const shortDateLabel = new Intl.DateTimeFormat(LOCALE, { month: "numeric", day: const noon = (date: string) => new Date(`${date}T12:00:00Z`); const shortDate = (date: string) => shortDateLabel.format(noon(date)); /** Week grid scale: pixels per minute. */ -const GRID_PX = 1.6; +const GRID_PX = 64 / 60; /** Side-by-side cards per overlap in the week grid before the rest fold away. */ const MAX_LANES = 2; -const MONTH_PILLS = 3; +const MONTH_PILLS = 2; const store = { get(key: string): string | null { try { return localStorage.getItem(key); } catch { return null; } }, set(key: string, value: string | null): void { try { if (value === null) localStorage.removeItem(key); else localStorage.setItem(key, value); } catch { /* storage is a convenience */ } }, @@ -154,9 +156,12 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { let notify: { settings: NotifySettings; device: boolean } | null = null; let notifyLoading = false, notifyError = false; const linkDrafts = new Map(); - let removingAgent: string | null = null; + let memberOpen = ""; + let settingsPage: "" | "notify" | "mcp" | "time" | "analytics" = ""; let revokingClient: string | null = null; let profile: RelayForgeRuntime["profile"] = null; + let avatarSrc = ""; + let avatarFor = 0; // Folded step lists stay open across rerenders. const openFolds = new Set(); let search = ""; @@ -170,13 +175,13 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { let refreshError = false; let repaintSheet: (() => void) | null = null; // Free stretches the person chose to rest in, and later stretches whose suggestions were opened (this session only). - const rested = new Set(); - const openGaps = new Set(); let workOpen = false; // The Now card's task, for the S shortcut. let focusQuest: Quest | null = null; // Relay Forge's detailed views live inside the tab they belong to. - let allMode: "list" | "table" | "notes" = "list"; + let allMode: "list" | "board" | "gantt" | "table" | "notes" = "list"; + let boardAll = false; + let boardCol = 0; let busy = false; let game = store.get("a4-game") !== "off"; let cheer = store.get("a4-cheer") !== "off"; @@ -220,6 +225,7 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { quests: port.quests, render: () => render(), alive: () => !disposed, writeLocked: () => busy, refreshWorkspace: async () => { await port.refresh?.(); }, }); + const billingPage = billingView(billingUi.api, () => render(), message => say(message)); const forge = runtime ? forgeScreens({ runtime: () => port.runtime!, quests: port.quests, busy: () => busy, render: () => render(), announce: message => say(message), @@ -231,6 +237,7 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { refresh: async () => { await port.refresh?.(); }, workTime: quest => workUi.totals(quest), relatedNotes: id => workUi.relatedNotes(id), + openMcpSettings: () => { settingsPage = "mcp"; go("settings"); }, }) : null; if (runtime) void workUi.load(); const analyticsUi = createAnalyticsUi({ port: runtime?.analyticsPort ?? null, render: () => render(), alive: () => !disposed }); @@ -240,48 +247,75 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { const main = el("main", { class: "a4-main", id: "a4-main", tabindex: -1 }); const dockHost = el("div", { class: "a4-dock-host" }); const nav = el("nav", { class: "a4-tabs", "aria-label": tx("画面") }); - const captureInput = el("input", { class: "a4-capture-input", type: "text", name: "title", placeholder: tx("思いついたら書く"), "aria-label": tx("やることを書き出す"), autocomplete: "off", enterkeyhint: "done", maxlength: 200 }); + const captureInput = el("input", { class: "a4-capture-input", type: "text", name: "title", placeholder: tx("例:明日15時 30分 画面案を見比べる"), "aria-label": tx("やることを書き出す"), autocomplete: "off", enterkeyhint: "done", maxlength: 200 }); const captureToday = el("button", { type: "button", class: "a4-chip", "aria-pressed": "false" }, tx("今日やる")); const captureParse = el("p", { class: "a4-parse", id: "a4-parse", "aria-live": "polite" }); captureInput.setAttribute("aria-describedby", "a4-parse"); - const captureForm = el("form", { class: "a4-capture" }, captureInput, captureToday, el("button", { type: "submit", class: "a4-button" }, tx("追加")), captureParse); + const captureError = el("p", { class: "a4-save-error", role: "alert", hidden: true }); + const captureAdded = el("ul", { class: "a4-capture-added", "aria-live": "polite" }); + const captureForm = el("form", { class: "a4-capture" }, captureInput, captureParse, captureError, + el("div", { class: "a4-capture-row" }, captureToday, el("button", { type: "submit", class: "a4-button a4-primary" }, tx("追加する"))), captureAdded); + let added: { quest: Quest; where: string; agent: AllForAgent | null }[] = []; + function paintAdded(): void { + replaceChildren(captureAdded, ...added.map((item, index) => { + const last = index === added.length - 1; + const agent = item.agent; + return el("li", {}, glyph(Check), el("span", {}, item.quest.icon ? item.quest.icon + " " : "", item.quest.title, " · ", item.where), + // Tasks cannot be deleted, so undo puts the new one away and hands its text back. + last ? button(tx("元に戻す"), () => { + void run(async () => { + await port.update(item.quest, { lifecycleState: "archived" }); + added = added.filter(other => other !== item); + if (!captureInput.value.trim()) captureInput.value = item.quest.title; + paintAdded(); paintParse(); captureInput.focus(); + }, tx("元に戻せませんでした")); + }, "link") : null, + last && agent ? button(tx("{name}にステップに分けてもらう", { name: agent.name }), () => { askToSplit(item.quest, agent); item.agent = null; paintAdded(); }) : null); + })); + } const addButton = el("button", { type: "button", class: "a4-button a4-top-add" }, glyph(Plus), tx("タスクを追加")); const mobileAdd = el("button", { type: "button", class: "a4-mobile-add", "aria-label": tx("タスクを追加") }, glyph(Plus)); - const themeButton = el("button", { type: "button", class: "a4-icon-button", "aria-label": tx("表示テーマ") }, glyph(Moon)); + const isDark = () => document.documentElement.dataset.theme === "dark" || (!document.documentElement.dataset.theme && matchMedia("(prefers-color-scheme: dark)").matches); + const themeButton = el("button", { type: "button", class: "a4-icon-button", "aria-label": tx("表示テーマ") }, glyph(isDark() ? Sun : Moon)); themeButton.addEventListener("click", () => { - const dark = document.documentElement.dataset.theme === "dark" || (!document.documentElement.dataset.theme && matchMedia("(prefers-color-scheme: dark)").matches); + const dark = isDark(); document.documentElement.dataset.theme = dark ? "light" : "dark"; store.set("qf-relay-forge-theme", dark ? "light" : "dark"); replaceChildren(themeButton, glyph(dark ? Moon : Sun)); paintThemeColor(); }); const breadcrumb = el("span", { class: "a4-breadcrumb" }); - const header = el("header", { class: "a4-top" }, breadcrumb, el("div", { class: "a4-top-actions" }, themeButton, addButton)); + const header = el("header", { class: "a4-top" }, el("span", { class: "a4-top-brand" }, "Guilduo"), breadcrumb, el("div", { class: "a4-top-actions" }, themeButton, addButton)); const brand = el("a", { class: "a4-brand", href: "#now" }, el("img", { src: "/assets/brand/guilduo-mark-ink.svg", class: "a4-brand-light", alt: "", width: 19, height: 26 }), el("img", { src: "/assets/brand/guilduo-mark-gold.svg", class: "a4-brand-dark", alt: "", width: 19, height: 26 }), "Guilduo"); brand.addEventListener("click", event => { event.preventDefault(); go("now"); }); const rail = el("aside", { class: "a4-rail" }, brand, nav, el("div", { class: "a4-profile" }, el("span", { class: "a4-avatar", "aria-hidden": "true" }, port.self().label.slice(0, 2)), - el("span", {}, port.self().label))); - const banner = port.demo ? el("p", { class: "a4-demo" }, tx("デモ(保存されません) "), el("a", { href: location.pathname }, tx("サインイン"))) : null; + el("span", { class: "a4-profile-copy" }, el("strong", {}, port.self().label), el("small", {}, tx("個人ワークスペース"))))); + const banner = port.demo ? el("p", { class: "a4-demo" }, el("span", {}, tx("デモです。変更は保存されません。")), el("a", { href: location.pathname }, tx("Googleでサインイン"))) : null; replaceChildren(root, el("div", { class: "a4-app" }, banner, rail, header, main, mobileAdd, dockHost, toast)); let captureDialog: HTMLDialogElement | null = null; function openCapture(): void { if (sheet) { closeSheet?.(openCapture); return; } if (captureDialog?.open) { captureInput.focus(); return; } const opener = document.activeElement; - const close = el("button", { type: "button", class: "a4-button" }, tx("閉じる")); - const dialog = el("dialog", { class: "a4-capture-dialog", "aria-label": tx("タスクを追加") }, + const close = el("button", { type: "button", class: "a4-icon-button", "aria-label": tx("閉じる") }, glyph(X)); + const dialog = el("dialog", { class: "a4-capture-dialog a4-add-dialog", "aria-label": tx("タスクを追加") }, el("div", { class: "a4-sheet-head" }, el("h2", { class: "a4-subtitle" }, tx("タスクを追加")), close), captureForm); close.addEventListener("click", () => { if (!busy) dialog.close(); }); dialog.addEventListener("cancel", event => { if (busy) event.preventDefault(); }); - dialog.addEventListener("close", () => { dialog.remove(); captureDialog = null; if (opener instanceof HTMLElement && opener.isConnected) opener.focus(); }); + dialog.addEventListener("close", () => { dialog.remove(); captureDialog = null; added = []; captureError.hidden = true; paintAdded(); paintParse(); if (opener instanceof HTMLElement && opener.isConnected) opener.focus(); }); captureDialog = dialog; document.body.append(dialog); dialog.showModal(); captureInput.focus(); } + function addOn(date: string, time = ""): void { + openCapture(); + captureInput.value = `${Number(date.slice(5, 7))}月${Number(date.slice(8))}日 ${time ? time + " " : ""}`; + paintParse(); + } addButton.addEventListener("click", openCapture); mobileAdd.addEventListener("click", openCapture); @@ -295,7 +329,7 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { const paintParse = () => { const parsed = readCapture(); const read = parsed.date !== (captureToday.getAttribute("aria-pressed") === "true" ? port.today() : "") || parsed.time || parsed.minutes || parsed.tags.length > 0; - if (!captureInput.value.trim() || !read) { captureParse.textContent = ""; return; } + if (!captureInput.value.trim() || !read) { captureParse.textContent = !captureInput.value.trim() && added.length ? tx("空のままEnterで閉じます。") : ""; return; } const day = !parsed.date ? tx("受け箱") : parsed.date === port.today() ? tx("今日") : parsed.date === addDays(port.today(), 1) ? tx("明日") : dayLabel.format(noon(parsed.date)); const when = parsed.time ? `${parsed.time}${parsed.minutes ? `–${clock(toMinutes(parsed.time) + parsed.minutes)}` : ""}` : ""; replaceChildren(captureParse, iconFor(parsed.title) ? `${iconFor(parsed.title)} ` : "", el("b", {}, parsed.title), " · ", @@ -305,23 +339,32 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { captureToday.addEventListener("click", paintParse); captureForm.addEventListener("submit", event => { event.preventDefault(); - if (!captureInput.value.trim() || busy) return; + if (busy) return; + if (!captureInput.value.trim()) { captureDialog?.close(); return; } const parsed = readCapture(); const icon = iconFor(parsed.title); + captureError.hidden = true; void run(async () => { - const created = await port.create({ - kind: "todo", title: parsed.title.slice(0, 80), notes: "", difficulty: "medium", impact: "medium", lifecycleState: "active", - nextAction: "", estimatedMinutes: parsed.minutes, dueDate: "", tags: parsed.tags, icon, iconSource: icon ? "auto" : "", - scheduledDate: parsed.date, scheduledTime: parsed.time, planningMode: "on_date", planningState: parsed.date ? "scheduled" : "backlog", - }); - captureInput.value = ""; - captureDialog?.close(); - paintParse(); - const message = !parsed.date ? tx("受け箱に追加しました") : parsed.date === port.today() ? tx("今日のやることに追加しました") : tx("{day}に追加しました", { day: dayLabel.format(noon(parsed.date)) }); + let created: Quest; + try { + created = await port.create({ + kind: "todo", title: parsed.title.slice(0, 80), notes: "", difficulty: "medium", impact: "medium", lifecycleState: "active", + nextAction: "", estimatedMinutes: parsed.minutes, dueDate: "", tags: parsed.tags, icon, iconSource: icon ? "auto" : "", + scheduledDate: parsed.date, scheduledTime: parsed.time, planningMode: "on_date", planningState: parsed.date ? "scheduled" : "backlog", + }); + } catch { + // The dialog covers the toast, so the failure is said here and the line stays as typed. + captureError.textContent = tx("追加できませんでした。入力は残しています。"); captureError.hidden = false; + return; + } + const day = !parsed.date ? tx("受け箱") : parsed.date === port.today() ? tx("今日") : parsed.date === addDays(port.today(), 1) ? tx("明日") : dayLabel.format(noon(parsed.date)); // Big work is easier to start in steps; one tap asks an Agent to split it through the usual handoff. - const agent = port.agents().find(item => item.active); - if (agent && isBig(parsed.title, parsed.minutes)) say(message, () => askToSplit(created, agent), tx("{name}にステップに分けてもらう", { name: agent.name })); - else say(message); + const agent = port.agents().find(item => item.active) ?? null; + added.push({ quest: created, where: [day, parsed.time].filter(Boolean).join(" "), agent: agent && isBig(parsed.title, parsed.minutes) ? agent : null }); + captureInput.value = ""; + captureToday.setAttribute("aria-pressed", "false"); + paintAdded(); paintParse(); + if (captureDialog?.open) captureInput.focus(); else say(tx("追加しました")); }, tx("追加できませんでした。入力は残しています。")); }); @@ -332,15 +375,25 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { }, tx("{name} に渡しました", { name: agent.name })); } - function say(message: string, undo?: () => void, actionLabel = tx("元に戻す")): void { - window.clearTimeout(toastTimer); - replaceChildren(toast, el("span", {}, message), undo ? (() => { + let toastRest = 0; + const hideToast = () => { window.clearTimeout(toastTimer); toast.hidden = true; }; + const armToast = () => { window.clearTimeout(toastTimer); if (toastRest > 0) toastTimer = window.setTimeout(() => { toast.hidden = true; }, toastRest); }; + for (const type of ["pointerenter", "focusin"] as const) toast.addEventListener(type, () => window.clearTimeout(toastTimer)); + for (const type of ["pointerleave", "focusout"] as const) toast.addEventListener(type, () => { if (!toast.hidden) armToast(); }); + function say(message: string, undo?: () => void, actionLabel = tx("元に戻す"), sticky = false): void { + replaceChildren(toast, sticky ? glyph(TriangleAlert) : null, el("span", {}, message), undo ? (() => { const button = el("button", { type: "button", class: "a4-link" }, actionLabel); - button.addEventListener("click", () => { undo(); toast.hidden = true; }); + button.addEventListener("click", () => { hideToast(); undo(); }); return button; + })() : null, sticky ? (() => { + const close = el("button", { type: "button", class: "a4-icon-button", "aria-label": tx("閉じる") }, glyph(X)); + close.addEventListener("click", hideToast); + return close; })() : null); + toast.dataset.sticky = String(sticky); toast.hidden = false; - toastTimer = window.setTimeout(() => { toast.hidden = true; }, undo ? UNDO_MS : 3000); + toastRest = sticky ? 0 : undo ? UNDO_MS : 3000; + armToast(); } async function run(action: () => Promise, failure: string): Promise { @@ -366,10 +419,12 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { tx("{time}に「{title}」を入れました", { time: proposal.time, title: quest.title }), previousPlan(quest)); } + let lastLevel: number | null = null; function complete(quest: Quest): void { if (pending.has(quest.id)) return; if (focusId === quest.id) focusId = null; store.set(`a4-start:${quest.id}`, null); + store.set(`a4-paused:${quest.id}`, null); stopTracking(quest); const send = () => { if (disposed) return; @@ -379,9 +434,14 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { void run(async () => { try { const result = await port.complete(quest); - if (game && result.character) say(tx("完了! Lv {level} · XP {xp}/{next}", { level: result.character.level, xp: result.character.xp, next: result.character.nextXp })); + // Experience is only known after the save, so the second toast is kept for a level-up. + const level = result.character?.level ?? null; + if (game && level !== null && lastLevel !== null && level > lastLevel) say(tx("レベルが上がりました · Lv {level}", { level })); + if (level !== null) lastLevel = level; + } catch { + say(tx("完了を保存できませんでした"), () => complete(quest), tx("もう一度"), true); } finally { pending.delete(quest.id); } - }, tx("完了を保存できませんでした。もう一度試してください。")); + }, tx("完了を保存できませんでした")); }; pending.set(quest.id, window.setTimeout(send, UNDO_MS)); const today = port.today(); @@ -392,11 +452,15 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { : focused ? { x: focused.left + focused.width / 2, y: focused.top + focused.height / 2 } : { x: innerWidth / 2, y: innerHeight * 0.6 }; celebrate(origin.x, origin.y, dayDone); } - say(dayDone ? tx("今日の分、ぜんぶ終わりました!") : game ? tx("完了! よくできました") : tx("完了しました"), () => { - const timer = pending.get(quest.id); - if (!timer) return; - window.clearTimeout(timer); - pending.delete(quest.id); + // Completions inside the undo window share one toast, and one undo takes them all back. + const batch = [...pending].filter(([, timer]) => timer !== 0).map(([id]) => id); + say(dayDone ? tx("今日の分、ぜんぶ終わりました!") : batch.length > 1 ? tx("{count}件 完了しました", { count: batch.length }) : cheer ? tx("おつかれさま! ひとつ前へ。") : tx("完了しました"), () => { + for (const id of batch) { + const timer = pending.get(id); + if (!timer) continue; + window.clearTimeout(timer); + pending.delete(id); + } render(); }); render(); @@ -428,10 +492,16 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { const stopTracking = (quest: Quest) => { if (runtime && workUi.trackedQuest() === quest.id) workUi.track(null); }; + const pausedMinutes = (quest: Quest) => Number(store.get(`a4-paused:${quest.id}`)) || 0; function pause(quest: Quest): void { + // Shown on the card only; the time record itself is kept by the tracker. + store.set(`a4-paused:${quest.id}`, String(Math.max(1, pausedMinutes(quest) + Math.round((Date.now() - startedAtOf(quest)) / 60_000)))); store.set(`a4-start:${quest.id}`, null); stopTracking(quest); - update(quest, { assignee: { ...quest.assignee, handoffState: "none" } }, tx("一時停止しました。続きはいつでもここから。")); + void run(async () => { + const saved = await port.update(quest, { assignee: { ...quest.assignee, handoffState: "none" } }); + say(tx("一時停止しました。続きはいつでもここから。"), () => start(saved)); + }, tx("保存できませんでした。もう一度試してください。")); } /* ------------------------------ pieces ------------------------------ */ @@ -471,11 +541,32 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { return node; } + /** The focus card's running clock: elapsed time, what is left of the estimate, and a thin bar. */ + function runTimer(quest: Quest): HTMLElement { + const startedAt = startedAtOf(quest); + const goal = quest.estimatedMinutes * 60; + const value = el("strong", { "data-a4-elapsed": "" }); + const left = el("span", { class: "a4-run-left" }); + const bar = el("span", { class: "a4-progress", "aria-hidden": "true" }); + const paint = () => { + const seconds = Math.max(0, Math.floor((Date.now() - startedAt) / 1000)); + value.textContent = `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`; + left.textContent = goal > seconds ? tx("あと{time}", { time: minutesLabel(Math.ceil((goal - seconds) / 60)) }) : ""; + bar.style.setProperty("--a4-done", `${goal > 0 ? Math.min(100, (seconds / goal) * 100) : 0}%`); + }; + paint(); + const node = el("div", { class: "a4-run", role: "timer", "aria-label": goal > 0 ? tx("経過時間。目安は{time}", { time: minutesLabel(quest.estimatedMinutes) }) : tx("経過時間") }, + el("p", {}, value, el("small", {}, tx("経過")), left), bar); + (node as HTMLElement & { a4Paint?: () => void }).a4Paint = paint; + return node; + } + function decisionNotice(): HTMLElement | null { const ai = agentSummary(quests(), port.today()); const count = quests().filter(needsDecision).length; if (count === 0 && ai.blocked.length === 0) return null; - const link = el("a", { class: "a4-notice", href: "#guild" }, tx("確認 {review}件 · 停止 {blocked}件", { review: count, blocked: ai.blocked.length }), el("span", { "aria-hidden": "true" }, " →")); + const link = el("a", { class: "a4-notice", href: "#guild" }, glyph(MessageSquare), el("strong", {}, tx("あなたの判断 {count}件", { count: count + ai.blocked.length })), + el("span", {}, tx("確認 {review} · 止まっている {blocked}", { review: count, blocked: ai.blocked.length })), glyph(ChevronRight)); link.addEventListener("click", event => { event.preventDefault(); go("guild"); }); return link; } @@ -489,7 +580,7 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { if (isDone(quest)) return "done"; if (needsDecision(quest)) return "decision"; if (quest.assignee.type !== "self") return "agent"; - if (view === "now" && (quest.id === focusQuest?.id || quest.id === focusQuest?.parentQuestId)) return "focus"; + if ((view === "now" || view === "plan" || view === "all") && (quest.id === focusQuest?.id || quest.id === focusQuest?.parentQuestId)) return "focus"; return isWorking(quest) ? "working" : quest.kind === "daily" ? "daily" : "self"; } @@ -664,10 +755,11 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { const badge = !started || index > at ? status(tx("これから"), "none") : index < at ? status(tx("済み"), "accepted") : status(AGENT_STATE[state] ?? state, state); const ask = multi && !locked ? el("input", { class: "a4-relay-ask", value: leg.ask, maxlength: 60, placeholder: tx("頼みごと(例:下書き)"), "aria-label": tx("{name}への頼みごと", { name }) }) : null; ask?.addEventListener("input", () => { legs[index] = { ...legs[index]!, ask: ask.value }; paintHint(); }); - const remove = locked ? null : el("button", { type: "button", class: "a4-relay-x", "aria-label": tx("{name}を外す", { name }) }, "×"); + const remove = locked ? null : el("button", { type: "button", class: "a4-relay-x", "aria-label": tx("{name}を外す", { name }) }, glyph(X)); remove?.addEventListener("click", () => { legs.splice(index, 1); paint(); }); const node = el("li", { class: "a4-relay-node", "data-kind": "agent", "data-index": String(index), "data-status": !started || index > at ? "next" : index < at ? "done" : "now", "data-draggable": String(multi && !locked), "aria-label": tx("{index}番目 {name}", { index: index + 1, name }) + (leg.ask ? `${tx(":")}${leg.ask}` : "") }, + multi && !locked ? el("span", { class: "a4-relay-grip", "aria-hidden": "true" }, glyph(GripVertical)) : null, el("span", { class: "a4-avatar", "data-kind": "agent", "aria-hidden": "true" }, name.slice(0, 1)), el("div", { class: "a4-relay-body" }, el("div", { class: "a4-relay-line" }, el("strong", {}, name), badge), @@ -699,13 +791,49 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { function paintHint(): void { const { started } = position(); const first = legs[0]; - hint.textContent = !changed() ? (legs.length === 0 && editable ? (multi ? tx("Agentを足すと、順番にバトンを渡します。最後はあなたが確認します。") : tx("Agentを足すと、そのAgentに渡します。終わったらあなたが確認します。")) : "") + hint.textContent = !changed() ? (legs.length === 0 && editable ? (multi ? tx("Agentを足すと、順番にバトンを渡します。最後はあなたが確認します。") : tx("Agentに任せると、終わったところであなたが確認します。")) : "") : legs.length === 0 ? tx("保存すると自分の担当に戻します。") : !started || (!multi && first?.agentId !== quest.assignee.id) ? (legs.length > 1 ? tx("保存すると {first} に渡します。終わったら {rest} へ回り、最後にあなたが確認します。", { first: nameOf(first!.agentId), rest: legs.slice(1).map(leg => nameOf(leg.agentId)).join(" → ") }) : tx("保存すると {first} に渡します。終わったら、最後にあなたが確認します。", { first: nameOf(first!.agentId) })) : tx("保存すると、このあとの順番を変えます。"); } + // Candidates read as rows: who, what they do, and how much they already hold. + const loadOf = (id: string) => quests().filter(q => isOpen(q) && q.assignee.type === "agent" && q.assignee.id === id).length; + function candidates(): HTMLElement { + if (agents.length === 0) { + const register = withArrow(button(tx("ギルドで登録する"), () => { if (closeSheet) closeSheet(() => go("guild")); else go("guild"); }, "link")); + return el("div", { class: "a4-relay-palette", role: "group", "aria-label": tx("足すAgent") }, el("p", {}, tx("使えるAgentがいません。")), register); + } + return el("div", { class: "a4-relay-palette", role: "group", "aria-label": tx("足すAgent") }, el("p", { class: "a4-step-label" }, tx("足すAgent")), + el("ul", {}, ...agents.map(agent => { + const load = loadOf(agent.id); + const row = el("button", { type: "button", class: "a4-relay-cand" }, + el("span", { class: "a4-avatar", "data-kind": "agent", "aria-hidden": "true" }, agent.name.slice(0, 1)), + el("span", { class: "a4-relay-cand-copy" }, el("strong", {}, agent.name), agent.role ? el("small", {}, agent.role) : null), + el("span", { class: "a4-meta" }, load ? tx("{count}件 進行中", { count: load }) : tx("手が空いています"))); + row.addEventListener("click", () => { if (Date.now() > suppressClickUntil) addLeg(agent.id); }); + if (multi) dragify(row, to => addLeg(agent.id, to)); + return el("li", {}, row); + }))); + } + const openPalette = () => { + palette = !palette; paint(); + const box = host.querySelector(".a4-relay-palette"); + // Phones keep the footer over the sheet's end; bring the list up and start on its first row. + box?.scrollIntoView({ block: "nearest" }); + box?.querySelector("button")?.focus({ preventScroll: true }); + }; + const addButton = () => { const node = el("button", { type: "button", class: "a4-button a4-relay-add", "aria-expanded": String(palette) }, glyph(Plus), tx("Agentを足す")); node.addEventListener("click", openPalette); return node; }; function paint(): void { const { started, at, state } = position(); + if (legs.length === 0) { + // Your own task: one line, and the way to hand it on. + replaceChildren(list, el("li", { class: "a4-relay-solo" }, + el("span", { class: "a4-avatar", "data-kind": "self", "aria-hidden": "true" }, port.self().label.slice(0, 1)), + el("span", { class: "a4-relay-cand-copy" }, el("strong", {}, port.self().label), el("small", {}, tx("あなたが進める"))), + editable ? addButton() : null), palette && editable ? el("li", {}, candidates()) : null); + paintHint(); + return; + } const items: HTMLElement[] = [selfNode(tx("{name}(あなた)", { name: port.self().label }), null)]; legs.forEach((leg, index) => { items.push(index === 0 ? link(null, at, tx("渡す")) : link(index - 1, at)); @@ -715,19 +843,8 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { items.push(link(null, at, legs.length > 0 ? tx("終わったら") : "")); items.push(selfNode(legs.length > 0 ? tx("あなたが最後の確認") : tx("あなたが進める"), last && state === "review_required" ? status(tx("あなたの番"), "review_required") : last && state === "accepted" ? status(tx("完了"), "accepted") : null, true)); - const add = editable && legs.length < max ? (() => { - const button = el("button", { type: "button", class: "a4-relay-add", "aria-expanded": String(palette) }, tx("+ Agent を足す")); - button.addEventListener("click", () => { palette = !palette; paint(); list.querySelector(".a4-relay-palette button")?.focus(); }); - return button; - })() : null; - const chips = palette && add ? el("li", { class: "a4-relay-palette", role: "group", "aria-label": tx("足すAgent") }, ...(agents.length === 0 - ? [el("span", { class: "a4-muted" }, tx("使えるAgentがいません。登録は詳しい画面で行います。"))] - : agents.map(agent => { - const chip = el("button", { type: "button", class: "a4-chip", title: agent.role }, agent.name); - chip.addEventListener("click", () => { if (Date.now() > suppressClickUntil) addLeg(agent.id); }); - if (multi) dragify(chip, to => addLeg(agent.id, to)); - return chip; - }))) : null; + const add = editable && legs.length < max ? addButton() : null; + const chips = palette && add ? el("li", {}, candidates()) : null; replaceChildren(list, ...items, add ? el("li", { class: "a4-relay-tail" }, add) : null, chips); paintHint(); } @@ -767,7 +884,18 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { node.addEventListener("click", () => pickIcon(value)); return node; }; - replaceChildren(iconPane, ...iconChoices(title.value, recentIcons()).map(value => choice(value, value)), choice("", tx("なし"))); + // Any other emoji is typed behind a link, one emoji only. + const typed = el("input", { class: "a4-input a4-icon-typed", maxlength: 16, "aria-label": tx("ほかの絵文字を入力"), hidden: true }); + const typedHint = el("p", { class: "a4-meta", hidden: true }, tx("絵文字を1つだけ入れられます。")); + const other = button(tx("ほかの絵文字を入力"), () => { typed.hidden = false; typedHint.hidden = false; other.setAttribute("aria-expanded", "true"); typed.focus(); }, "link"); + other.setAttribute("aria-expanded", "false"); + typed.addEventListener("input", () => { + const parts = [...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(typed.value.trim())].map(part => part.segment); + if (parts.length === 1 && /\p{Extended_Pictographic}/u.test(parts[0]!)) pickIcon(parts[0]!); + }); + replaceChildren(iconPane, el("p", { class: "a4-step-label" }, tx("絵文字")), + el("div", { class: "a4-icon-choices" }, ...iconChoices(title.value, icon ? [icon, ...recentIcons()] : recentIcons()).map(value => choice(value, value))), + el("div", { class: "a4-icon-more" }, choice("", tx("なし")), other), typed, typedHint); } iconButton.addEventListener("click", () => showIcons(iconPane.hidden === true)); paintIcon(); @@ -909,17 +1037,29 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { ...Object.values(panes), done, memo); const mine = quest.assignee.type === "self" && !isDone(forDay(quest, today)); + const nextBox = el("div", { class: "a4-sheet-nextbox" }); + step.replaceWith(nextBox); + nextBox.append(el("label", { class: "a4-sheet-nextcopy" }, el("span", { class: "a4-step-label" }, tx("次の一歩")), step)); + if (mine) nextBox.append(button(tx("今やる"), () => requestClose(() => { start(forDay(quest, port.today())); go("now"); }))); const steps = el("div", { class: "a4-sheet-steps" }); + const stepCount = el("span", { class: "a4-meta" }); const drafts = children(quest).map(q => ({ quest: q as Quest | null, title: q.title, done: isDone(forDay(q, today)), removed: false })); const stepInput = el("input", { class: "a4-input", placeholder: tx("ステップを追加"), "aria-label": tx("小さなステップを追加"), maxlength: 200, enterkeyhint: "done" }); - const stepForm = el("form", { class: "a4-step-form" }, stepInput, el("button", { type: "submit", class: "a4-button" }, tx("追加"))); - const paintSteps = () => replaceChildren(steps, ...drafts.filter(d => !d.removed).map(draft => { - const check = el("input", { type: "checkbox", checked: draft.done, disabled: busy || (draft.quest != null && (draft.quest.assignee.type !== "self" || (draft.quest.kind === "daily" && draft.done))), "aria-label": draft.title }); - check.addEventListener("change", () => { draft.done = check.checked; }); - return el("div", { class: "a4-step-draft" }, el("label", { class: "a4-option" }, check, draft.title), - button(tx("しまう"), () => { draft.removed = true; paintSteps(); }, "link")); - })); - stepForm.addEventListener("submit", event => { + const stepForm = el("div", { class: "a4-step-add" }, glyph(Plus), stepInput); + const paintSteps = () => { + const shown = drafts.filter(d => !d.removed); + stepCount.textContent = shown.length ? `${shown.filter(d => d.done).length} / ${shown.length}` : ""; + replaceChildren(steps, ...shown.map(draft => { + const check = el("input", { type: "checkbox", checked: draft.done, disabled: busy || (draft.quest != null && (draft.quest.assignee.type !== "self" || (draft.quest.kind === "daily" && draft.done))), "aria-label": draft.title }); + check.addEventListener("change", () => { draft.done = check.checked; paintSteps(); }); + const away = el("button", { type: "button", class: "a4-icon-button", "aria-label": tx("「{title}」をしまう", { title: draft.title }) }, glyph(X)); + away.addEventListener("click", () => { draft.removed = true; paintSteps(); }); + return el("div", { class: "a4-step-draft" }, el("label", { class: "a4-option" }, check, draft.title), away); + })); + }; + // Enter adds a step; it must not submit the sheet. + stepInput.addEventListener("keydown", event => { + if (event.key !== "Enter" || event.isComposing) return; event.preventDefault(); const value = stepInput.value.trim(); if (!value || busy) return; @@ -937,12 +1077,16 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { const images = questImageEditor(port.images, quest); const repeatOptions: [Quest["repeat"], string][] = [["none", tx("繰り返さない")], ["daily", tx("毎日")], ["weekdays", tx("平日")], ["weekly", tx("毎週")], ["monthly", tx("毎月")]]; const repeat = el("select", { class: "a4-input", "aria-label": tx("繰り返し") }, ...repeatOptions.map(([value, label]) => el("option", { value, selected: value === quest.repeat }, label))); - form.append(el("details", { class: "a4-more" }, el("summary", { class: "a4-chip" }, tx("繰り返し")), repeat), images.element); - const close = el("button", { type: "button", class: "a4-button" }, tx("閉じる")); - const dialog = el("dialog", { class: "a4-sheet", "aria-label": tx("詳しく:{title}", { title: quest.title }) }, - el("div", { class: "a4-sheet-head" }, - parent ? button(tx("← 「{title}」の一部", { title: shortTitle(parent.title) }), () => openSheet(parent), "link") : el("h2", { id: "a4-sheet-title", class: "a4-eyebrow" }, tx("詳しく")), close), - form, + panes.date.append(el("label", { class: "a4-pane-field" }, el("span", { class: "a4-step-label" }, tx("繰り返し")), repeat)); + const close = el("button", { type: "button", class: "a4-icon-button", "aria-label": tx("閉じる") }, glyph(X)); + // Putting away is reversible from search and the toast, so it needs no confirm step. + const menu = el("details", { class: "a4-menu" }, el("summary", { class: "a4-icon-button", "aria-label": tx("ほかの操作") }, glyph(MoreHorizontal)), + el("div", { class: "a4-menu-list" }, + quest.lifecycleState === "archived" + ? button(tx("戻す"), () => requestClose(() => update(quest, { lifecycleState: "active" }, tx("やることに戻しました"), { lifecycleState: "archived" })), "link") + : button(tx("しまう"), () => requestClose(() => update(quest, { lifecycleState: "archived" }, tx("しまいました。「やること」の検索から戻せます。"), { lifecycleState: quest.lifecycleState })), "link"), + el("a", { class: "a4-link", href: RELAY_FORGE_URL }, tx("詳しい画面で開く")))); + form.append(...([ quest.proposal ? (proposal => el("section", { class: "a4-group a4-proposal" }, el("h3", { class: "a4-step-label" }, tx("{name}の提案", { name: proposal.label })), el("p", { class: "a4-proposal-when" }, `${shortDay(proposal.date)} ${proposal.time}–${clock(toMinutes(proposal.time) + proposal.minutes)}`, proposal.note ? el("span", { class: "a4-meta" }, proposal.note) : null), @@ -950,16 +1094,17 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { button(tx("この時間に入れる"), () => requestClose(() => acceptProposal(quest))), button(tx("断る"), () => requestClose(() => update(quest, { proposal: null, expectedUpdatedAt: quest.updatedAt }, tx("提案を断りました"))), "link"))))(quest.proposal) : null, depList, + el("section", { class: "a4-group a4-sheet-stepsec" }, el("h3", { class: "a4-step-label" }, tx("小さなステップ"), stepCount), steps, mine ? stepForm : null), + done, memo, el("section", { class: "a4-group" }, el("h3", { class: "a4-step-label" }, tx("担当")), relay.node), - el("section", { class: "a4-group" }, el("h3", { class: "a4-step-label" }, tx("小さなステップ")), steps, mine ? stepForm : null), + images.element] as (HTMLElement | null)[]).filter(node => node !== null)); + const dialog = el("dialog", { class: "a4-sheet", "aria-label": tx("詳しく:{title}", { title: quest.title }) }, + el("div", { class: "a4-sheet-head" }, + parent ? button(tx("← 「{title}」の一部", { title: shortTitle(parent.title) }), () => openSheet(parent), "link") : el("h2", { id: "a4-sheet-title", class: "a4-sheet-name" }, tx("タスクの詳細")), menu, close), + form, el("div", { class: "a4-actions a4-sheet-foot" }, - el("button", { type: "submit", class: "a4-button a4-primary", form: "a4-sheet-form" }, tx("保存")), - mine ? button(tx("今やる"), () => requestClose(() => { start(forDay(quest, port.today())); go("now"); })) : null, - // Putting away is reversible from search and the toast, so it needs no confirm step. - quest.lifecycleState === "archived" - ? button(tx("戻す"), () => requestClose(() => update(quest, { lifecycleState: "active" }, tx("やることに戻しました"), { lifecycleState: "archived" })), "link") - : button(tx("しまう"), () => requestClose(() => update(quest, { lifecycleState: "archived" }, tx("しまいました。「やること」の検索から戻せます。"), { lifecycleState: quest.lifecycleState })), "link"), - el("a", { class: "a4-link", href: RELAY_FORGE_URL }, tx("詳しい画面で開く")))); + button(tx("キャンセル"), () => requestClose(), "link"), + el("button", { type: "submit", class: "a4-button a4-primary", form: "a4-sheet-form" }, tx("変更を保存")))); const error = el("p", { class: "a4-save-error", role: "alert", hidden: true }); form.append(error); const body = el("div", { class: "a4-sheet-body" }); @@ -1078,76 +1223,151 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { /* ------------------------------ screens ------------------------------ */ - function chooseTask(): void { - if (sheet) { closeSheet?.(chooseTask); return; } - const candidates = nextCandidates(quests(), port.today()); - const close = button(tx("閉じる"), () => dialog.close()); - const dialog = el("dialog", { class: "a4-capture-dialog", "aria-label": tx("別のタスク") }, - el("div", { class: "a4-sheet-head" }, el("h2", { class: "a4-subtitle" }, tx("別のタスク")), close), - el("ul", { class: "a4-list" }, ...candidates.map(q => { - const pick = button(q.title, () => { focusId = q.id; dialog.close(); render(); }); - return el("li", {}, pick); - })), button(tx("タスク"), () => { dialog.close(); go("all"); }, "link")); + /** A centered picker on wide screens and a bottom sheet on phones. */ + function picker(title: string, sub: string, ...body: (HTMLElement | null)[]): HTMLDialogElement { + const close = el("button", { type: "button", class: "a4-icon-button", "aria-label": tx("閉じる") }, glyph(X)); + const dialog = el("dialog", { class: "a4-capture-dialog a4-picker", "aria-label": title }, + el("div", { class: "a4-sheet-head" }, el("div", {}, el("h2", { class: "a4-subtitle" }, title), sub ? el("p", { class: "a4-meta" }, sub) : null), close), ...body); + close.addEventListener("click", () => dialog.close()); const opener = document.activeElement; dialog.addEventListener("close", () => { dialog.remove(); if (opener instanceof HTMLElement && opener.isConnected) opener.focus(); }); document.body.append(dialog); dialog.showModal(); + return dialog; + } + function pickRow(icon: string | HTMLElement, title: string, hint: string, tail: IconNode | null, action: () => void): HTMLElement { + const node = el("button", { type: "button", class: "a4-pick" }, typeof icon === "string" ? el("span", { class: "a4-pick-icon", "aria-hidden": "true" }, icon) : icon, + el("span", { class: "a4-pick-copy" }, el("strong", {}, title), hint ? el("small", {}, hint) : null), tail ? glyph(tail) : null); + node.addEventListener("click", action); + return el("li", {}, node); } + function chooseTask(): void { + if (sheet) { closeSheet?.(chooseTask); return; } + const today = port.today(); + const candidates = nextCandidates(quests(), today).filter(q => q.id !== focusQuest?.id); + const at = nowMinutes(); + const next = candidates.filter(q => q.scheduledTime && toMinutes(q.scheduledTime) > at).sort((a, b) => a.scheduledTime.localeCompare(b.scheduledTime))[0]; + // A few short untimed tasks first; everything else waits behind one line. + const quick = candidates.filter(q => !q.scheduledTime).sort((a, b) => (a.estimatedMinutes || 999) - (b.estimatedMinutes || 999)).slice(0, 3); + const rest = candidates.filter(q => !quick.includes(q)); + const row = (q: Quest) => { + const parent = q.parentQuestId ? byId(q.parentQuestId) : null; + return pickRow(q.icon || "", q.title, [parent ? parent.title : q.scheduledTime || tx("時刻未定"), q.estimatedMinutes > 0 ? minutesLabel(q.estimatedMinutes) : ""].filter(Boolean).join(" · "), ChevronRight, + () => { focusId = q.id; dialog.close(); render(); }); + }; + const more = rest.length === 0 ? null : el("details", { class: "a4-pick-more", open: quick.length === 0 }, + el("summary", {}, tx("ほかのタスク {count}件", { count: rest.length })), el("ul", { class: "a4-picks" }, ...rest.map(row))); + const dialog = picker(tx("今やるタスクを選ぶ"), next ? tx("次の予定 {time} まで、あと{left}", { time: next.scheduledTime, left: minutesLabel(toMinutes(next.scheduledTime) - at) }) : "", + candidates.length === 0 ? el("div", { class: "a4-pick-empty" }, el("p", {}, tx("今日の候補はありません。")), button(tx("タスクを見る"), () => { dialog.close(); go("all"); })) + : el("ul", { class: "a4-picks" }, ...quick.map(row)), more, + candidates.length === 0 ? null : (() => { const all = button(tx("タスクの一覧から選ぶ"), () => { dialog.close(); go("all"); }, "link"); all.classList.add("a4-pick-all"); all.append(glyph(ArrowRight)); return all; })()); + } + + /** What to do with a free stretch: place something that fits, write a new task, or leave it open. */ + function chooseGap(gap: { start: number; minutes: number }): void { + const today = port.today(); + const proposed = proposalFor(quests(), gap, today); + const fits = groups(quests(), today).inbox.filter(q => q.estimatedMinutes > 0 && q.estimatedMinutes <= gap.minutes && q.id !== proposed?.id).slice(0, 3); + const put = (q: Quest) => { dialog.close(); if (q.proposal) acceptProposal(q); else update(q, { ...plan(today), scheduledTime: clock(gap.start), expectedUpdatedAt: q.updatedAt }, tx("{time}に「{title}」を入れました", { time: clock(gap.start), title: q.title }), previousPlan(q)); }; + const dialog = picker(tx("この余白をどう使う?"), `${clock(gap.start)}–${clock(gap.start + gap.minutes)} · ${minutesLabel(gap.minutes)}`, + el("ul", { class: "a4-picks" }, + proposed?.proposal ? pickRow(el("span", { class: "a4-avatar", "data-kind": "agent", "aria-hidden": "true" }, proposed.proposal.label.slice(0, 1)), proposed.title, + `${minutesLabel(proposed.proposal.minutes)} · ${tx("{name}の提案", { name: proposed.proposal.label })}`, Plus, () => put(proposed)) : null, + ...fits.map(q => pickRow(q.icon || "", q.title, minutesLabel(q.estimatedMinutes), Plus, () => put(q))), + pickRow(glyphNode(Plus), tx("新しく書く"), tx("この時間に入れるタスクを追加します"), null, () => { dialog.close(); openCapture(); captureInput.value = `${clock(gap.start)} `; paintParse(); }), + pickRow(glyphNode(Sun), tx("ひと息つく"), tx("何も入れず、この時間を残します。"), null, () => { + dialog.close(); rests.add(gap.start); store.set("a4-rest:" + today, [...rests].join(",")); say(tx("この時間は、そのまま空けておきます")); render(); + }))); + } + const glyphNode = (icon: IconNode) => el("span", { class: "a4-pick-icon", "aria-hidden": "true" }, glyph(icon)); + const rests = new Set((store.get("a4-rest:" + port.today()) ?? "").split(",").filter(Boolean).map(Number)); + function aiRail(): HTMLElement { const ai = agentSummary(quests(), port.today()); - return el("aside", { class: "a4-ai", "aria-label": tx("AIの状況") }, - el("div", { class: "a4-group-head" }, el("h2", { class: "a4-subtitle" }, tx("AIの状況")), button(tx("ギルド"), () => go("guild"), "link")), - el("div", { class: "a4-ai-counts" }, ...[[tx("確認待ち"), ai.review.length], [tx("止まっている"), ai.blocked.length], [tx("作業中"), ai.working.length]].map(([label, count]) => - el("div", {}, el("strong", {}, String(count)), el("span", {}, String(label))))), - ...ai.visible.map(q => { - const item = button("", () => { if (needsDecision(q)) go("guild", q.id); else openSheet(q); }); - item.className = "a4-ai-row"; - const state = needsDecision(q) ? tx("確認待ち") : AGENT_STATE[q.assignee.handoffState] ?? tx("担当"); - item.append(el("span", { class: "a4-avatar a4-avatar-agent", "aria-hidden": "true" }, (q.assignee.label || "AI").slice(0, 2)), - el("span", { class: "a4-ai-copy" }, el("small", {}, q.assignee.label || "Agent", " · ", state), el("strong", {}, q.title))); - return item; + const asks = [...quests().filter(needsDecision), ...ai.blocked]; + const open = (label: string, action: () => void) => { const node = button(label, action, "link"); node.append(glyph(ArrowRight)); return node; }; + return el("aside", { class: "a4-ai", "aria-label": tx("ギルド") }, + el("div", { class: "a4-group-head" }, glyph(Users), el("h2", { class: "a4-subtitle" }, tx("ギルド")), + asks.length ? el("span", { class: "a4-ask-count" }, tx("あなたの判断 {count}件", { count: asks.length })) : null), + ...asks.slice(0, 3).map(q => { + const blocked = !needsDecision(q); + const note = blocked ? q.handoff?.blockedReason || q.nextAction : ""; + const name = q.assignee.label || "Agent"; + const act = button(blocked ? tx("判断する") : tx("内容を確認"), () => go("guild", q.id)); + act.append(glyph(ArrowRight)); + return el("div", { class: "a4-ask" }, el("p", { class: "a4-ask-state" }, blocked ? tx("止まっています") : tx("確認待ち")), + el("strong", {}, q.title), note ? el("p", { class: "a4-ask-note" }, note) : null, + el("div", { class: "a4-ask-row" }, el("span", { class: "a4-avatar a4-avatar-agent", "aria-hidden": "true" }, name.slice(0, 1)), el("span", {}, name), act)); }), - ai.visible.length ? null : el("p", { class: "a4-muted" }, tx("いま、あなたの判断を待っているものはありません。")), - ai.ready.length ? el("p", { class: "a4-muted" }, tx("受け取り待ち {count}件", { count: ai.ready.length })) : null, - button(tx("ギルドを開く"), () => go("guild"), "link")); + asks.length ? null : el("p", { class: "a4-muted" }, tx("いま、あなたの判断を待っているものはありません。")), + el("div", { class: "a4-ai-foot" }, + ai.working.length + ai.ready.length ? el("p", { class: "a4-muted" }, tx("作業中 {working}件 · 受け取り待ち {ready}件", { working: ai.working.length, ready: ai.ready.length })) : null, + open(tx("ギルドを開く"), () => go("guild")))); + } + + function currentFocus(): Quest | null { + const candidates = nextCandidates(quests(), port.today()); + return candidates.find(isWorking) ?? candidates.find(q => q.id === focusId) ?? candidates[0] ?? null; } function nowView(): HTMLElement { const today = port.today(); - const candidates = nextCandidates(quests(), today); - const focus = candidates.find(isWorking) ?? candidates.find(q => q.id === focusId) ?? candidates[0] ?? null; + const focus = currentFocus(); focusQuest = focus; const focusParent = focus?.parentQuestId ? byId(focus.parentQuestId) : null; const focusDisplay = focusParent ?? focus; const { anytime, timed } = dayPlan(displayQuests(), today); const todays = [...anytime, ...timed]; const next = timed.find(q => !isDone(q) && q.id !== focus?.id && q.id !== focusParent?.id && toMinutes(q.scheduledTime) > nowMinutes()); - const summary = el("p", { class: "a4-summary" }, tx("今日 {done} / {total} 完了 · 予定 {time}", { - done: todays.filter(isDone).length, total: todays.length, time: minutesLabel(plannedMinutes(todays.filter(q => !isDone(q)))) })); + const doneToday = todays.filter(isDone); + const doneCount = doneToday.length; + const summary = el("p", { class: "a4-summary" }, el("strong", {}, `${doneCount} / ${todays.length}`), el("span", {}, tx("完了")), + el("span", { class: "a4-progress", "aria-hidden": "true", style: `--a4-done: ${todays.length ? Math.round(doneCount / todays.length * 100) : 0}%` }), + el("span", {}, tx("残り {time}", { time: minutesLabel(plannedMinutes(todays.filter(q => !isDone(q)))) }))); + const at = nowMinutes(); + const focusSteps = focusDisplay ? children(focusDisplay) : []; + const focusSpan = focusDisplay?.scheduledTime ? `${focusDisplay.scheduledTime}–${clock(endOf(focusDisplay))}` : ""; + // Inside its planned slot the card counts down to the slot's end; otherwise it shows the estimate. + const focusLeft = focusDisplay?.scheduledTime && toMinutes(focusDisplay.scheduledTime) <= at && endOf(focusDisplay) > at ? tx("あと{time}", { time: minutesLabel(endOf(focusDisplay) - at) }) : ""; + const withIcon = (node: HTMLElement, icon: IconNode, first = false) => { if (first) node.prepend(glyph(icon)); else node.append(glyph(icon)); return node; }; const heading = el("div", { class: "a4-page-heading" }, - el("h1", { class: "a4-date", tabindex: -1 }, dateLabel.format(noon(today)), el("small", {}, weekdayLong.format(noon(today)))), summary); + el("h1", { class: "a4-date", tabindex: -1 }, dateLabel.format(noon(today)), el("small", {}, weekdayLong.format(noon(today)))), todays.length ? summary : null); const running = focus && isWorking(focus); + const paused = focus && !running ? pausedMinutes(focus) : 0; + const pausedLeft = paused && focus && focus.estimatedMinutes > paused ? tx("あと{time}", { time: minutesLabel(focus.estimatedMinutes - paused) }) : ""; const card = focus ? el("article", { class: "a4-focus", "data-running": String(running) }, - el("div", { class: "a4-focus-head" }, el("span", { class: "a4-focus-label" }, glyph(running ? Play : Flag), running ? tx("作業中") : tx("今の1件")), + el("div", { class: "a4-focus-head" }, el("span", { class: "a4-focus-label" }, glyph(running ? Play : paused ? Pause : Flag), running ? tx("いま取り組んでいます") : paused ? tx("一時停止中 · {time} 進めました", { time: minutesLabel(paused) }) : tx("今の1件")), + focusSpan ? el("span", { class: "a4-focus-span" }, focusSpan) : null, (() => { const more = button("", () => openSheet(focus)); more.className = "a4-icon-button"; more.setAttribute("aria-label", tx("詳しく")); more.append(glyph(MoreHorizontal)); return more; })()), el("div", { class: "a4-focus-title" }, iconCircle(focusDisplay!, "sm"), titleButton(focusDisplay!)), - el("p", { class: "a4-step" }, el("span", { class: "a4-step-label" }, tx("次の一歩")), el("span", {}, focus.nextAction || (focusParent ? focus.title : tx("まずは、できるところから。")))), + el("div", { class: "a4-step" }, + el("p", { class: "a4-step-head" }, el("span", { class: "a4-step-label" }, tx("次の一歩")), + focusSteps.length ? el("span", {}, tx("ステップ {done}/{total}", { done: focusSteps.filter(isDone).length, total: focusSteps.length })) : null), + el("p", { class: "a4-step-text" }, focus.nextAction || (focusParent ? focus.title : tx("まずは、できるところから。")))), el("div", { class: "a4-actions" }, - button(running ? tx("完了") : tx("始める"), () => running ? complete(focus) : start(focus), "primary"), - running ? timer(focus, true) : focus.estimatedMinutes ? el("span", { class: "a4-focus-time" }, glyph(Clock3), minutesLabel(focus.estimatedMinutes)) : button(tx("+目安"), () => openSheet(focus), "link"), - button(running ? tx("一時停止") : tx("別のタスク"), () => running ? pause(focus) : chooseTask(), "link"))) - : el("div", { class: "a4-empty" }, el("h2", { class: "a4-subtitle" }, tx("今日の1件を、ここから。")), - el("p", { class: "a4-muted" }, tx("タスクから今日やるものを選びましょう。")), button(tx("タスクを追加"), openCapture, "primary"), button(tx("タスク"), () => go("all"), "link")); + running ? withIcon(button(tx("完了する"), () => complete(focus), "primary"), Check, true) : withIcon(button(paused ? tx("続ける") : tx("始める"), () => start(focus), "primary"), Play, true), + running ? el("span", { class: "a4-focus-time a4-run-inline" }, glyph(Clock3), (() => { const node = runTimer(focus); node.querySelector("small")!.textContent = focus.estimatedMinutes > 0 ? `/ ${minutesLabel(focus.estimatedMinutes)}` : ""; return node; })()) : focusLeft || pausedLeft || focus.estimatedMinutes ? el("span", { class: "a4-focus-time" }, glyph(Clock3), focusLeft || pausedLeft || minutesLabel(focus.estimatedMinutes)) : button(tx("+目安"), () => openSheet(focus), "link"), + running ? button(tx("一時停止"), () => pause(focus), "link") : withIcon(button(tx("別のタスク"), chooseTask, "link"), ChevronRight))) + : doneToday.length > 0 + // Everything planned for today is done: say so, and do not push another task. + ? el("div", { class: "a4-focus a4-all-done" }, + el("div", { class: "a4-focus-head" }, el("span", { class: "a4-focus-label" }, glyph(Sun), tx("今日"))), + el("h2", { class: "a4-focus-title" }, tx("今日の分は終わりました")), + el("p", { class: "a4-muted" }, tx("{count}件 完了 · 合計 {time}", { count: doneToday.length, time: minutesLabel(doneToday.reduce((sum, q) => sum + q.estimatedMinutes, 0)) })), + el("div", { class: "a4-actions" }, button(tx("明日の予定を見る"), () => { planDate = addDays(today, 1); go("plan"); }), button(tx("もう1件だけ選ぶ"), chooseTask, "link"))) + : el("div", { class: "a4-empty a4-empty-focus" }, glyph(Sun), el("h2", { class: "a4-subtitle" }, tx("今日の1件を、ここから。")), + el("p", { class: "a4-muted" }, tx("頭にあることを書き出すところから。")), withIcon(button(tx("タスクを追加"), openCapture, "primary"), Plus, true)); const nextRow = next ? button("", () => openSheet(next), "link") : el("p", { class: "a4-next-preview a4-muted" }); nextRow.className = "a4-next-preview"; nextRow.append(el("span", { class: "a4-step-label" }, tx("次の予定"))); - if (next) nextRow.append(el("strong", {}, next.scheduledTime), el("span", { class: "a4-next-title" }, next.title), glyph(ArrowRight)); + if (next) nextRow.append(el("strong", {}, next.scheduledTime), el("span", { class: "a4-next-title" }, next.title), + el("span", { class: "a4-next-left" }, tx("あと{time}", { time: minutesLabel(toMinutes(next.scheduledTime) - at) })), glyph(ArrowRight)); else nextRow.append(el("span", {}, tx("時刻の決まった予定はありません。"))); return el("div", { class: "a4-today-page" }, heading, refreshError ? el("p", { class: "a4-refresh-error", role: "status" }, tx("再取得できませんでした。表示中の内容を残しています。"), button(tx("もう一度"), onFocus, "link")) : null, el("div", { class: "a4-today-grid" }, - el("section", { class: "a4-home" }, decisionNotice(), el("div", { class: "a4-now" }, card, nextRow), - el("div", { class: "a4-today-flow" }, todayTimeline(), runtime && focus ? workFold(focus) : null)), + el("section", { class: "a4-home" }, el("div", { class: "a4-now" }, card, focus || todays.length ? nextRow : null, decisionNotice()), + focus || todays.length ? el("div", { class: "a4-today-flow" }, todayTimeline(), runtime && focus ? workFold(focus) : null) : null), aiRail())); } @@ -1157,30 +1377,41 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { const month = planDate.slice(0, 7); const start = weekOf(`${month}-01`)[0]!; const days = Array.from({ length: 42 }, (_, i) => addDays(start, i)); - if (days[35]!.slice(0, 7) !== month) days.length = 35; const openDay = (date: string) => { planDate = date; planMode = "day"; store.set("a4-plan-mode", null); render(); }; const cells = days.map(date => { const { anytime, timed } = dayPlan(list, date); - const items = [...timed, ...anytime]; - const shown = items.slice(0, items.length > MONTH_PILLS ? MONTH_PILLS - 1 : MONTH_PILLS); + // Habits sit on the line above the month, so the cells keep to planned work. + const items = [...timed, ...anytime].filter(q => q.kind !== "daily").sort((a, b) => Number(isDone(a)) - Number(isDone(b))); + const shown = items.slice(0, MONTH_PILLS); const head = el("button", { type: "button", class: "a4-wk-day a4-mo-day", "aria-pressed": String(date === planDate), "data-today": String(date === today), "aria-label": calendarLabel(date, items) }, el("span", { class: "a4-day-num" }, String(Number(date.slice(8)))), - dayDots(items)); - // Phones show dots only, so a tap opens the day itself. - head.addEventListener("click", () => { if (matchMedia("(max-width: 760px)").matches) openDay(date); else { planDate = date; render(); } }); + loadOf(items) > 0 ? loadMarks(items)[0]! : null); + // Phones show the load only; the chosen day's tasks are listed under the grid. + head.addEventListener("click", () => { planDate = date; render(); }); const hidden = items.length - shown.length; const more = hidden === 0 ? null : el("button", { type: "button", class: "a4-mo-more", "aria-label": tx("{day} ほか{count}件を日表示で見る", { day: dayLabel.format(noon(date)), count: hidden }) }, tx("+{count}件", { count: hidden })); more?.addEventListener("click", () => openDay(date)); return el("div", { class: "a4-mo-cell", "data-selected": String(date === planDate), "data-outside": String(date.slice(0, 7) !== month) }, head, - ...shown.map(q => el("div", { class: "a4-wk-item a4-mo-item", "data-tone": tone(q), "data-hue": hue(q) }, - q.scheduledTime ? el("span", { class: "a4-meta" }, q.scheduledTime) : null, titleButton(q))), + ...shown.map(q => el("div", { class: "a4-mo-item", "data-tone": tone(q) }, titleButton(q))), more); }); - return el("div", { class: "a4-mo" }, - ...weekOf(start).map(date => el("span", { class: "a4-day-name a4-mo-name" }, weekdayLabel.format(noon(date)))), - ...cells); + const chosen = (({ anytime, timed }) => [...timed, ...anytime])(dayPlan(list, planDate)).filter(q => q.kind !== "daily" && !isDone(q)); + const openLink = button(tx("この日を開く"), () => openDay(planDate), "link"); + openLink.append(glyph(ArrowRight)); + return el("div", { class: "a4-mo-wrap" }, + el("div", { class: "a4-mo" }, + ...weekOf(start).map(date => el("span", { class: "a4-day-name a4-mo-name" }, weekdayLabel.format(noon(date)))), + ...cells), + el("section", { class: "a4-mo-preview" }, + el("div", { class: "a4-group-head" }, el("h2", { class: "a4-subtitle" }, `${dateLabel.format(noon(planDate))} ${weekdayLong.format(noon(planDate))}`), openLink), + chosen.length === 0 ? el("p", { class: "a4-muted" }, tx("予定なし")) : el("ul", { class: "a4-wl" }, ...chosen.map(q => { + const node = el("button", { type: "button", class: "a4-wl-item", "data-tone": tone(q), "data-quest-id": q.id }, + el("span", { class: "a4-wl-time" }, q.scheduledTime || tx("未定")), el("span", { class: "a4-wl-title" }, q.icon ? q.icon + " " : "", q.title)); + node.addEventListener("click", () => openSheet(q)); + return el("li", {}, node); + })))); } const shiftMonth = (date: string, months: number) => { @@ -1192,7 +1423,7 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { /** Recorded time for the task in focus and its history, folded under 今; the time settings live in 設定. */ function workFold(focus: Quest): HTMLElement { const fold = el("details", { class: "a4-after a4-extra a4-work-fold", open: workOpen }, - el("summary", { class: "a4-subtitle" }, tx("時間の記録")), + el("summary", { class: "a4-subtitle" }, glyph(Clock3), tx("時間の記録")), el("p", { class: "a4-muted" }, tx("この作業の記録:{time}", { time: workUi.totals(focus) })), el("p", {}, button(tx("記録の履歴"), () => workUi.history(), "link"))); fold.addEventListener("toggle", () => { workOpen = fold.open; }); @@ -1200,10 +1431,57 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { } /** Week at a glance: an "anytime" row, then an hourly axis with cards sized by duration. */ + /** A day's load counts planned work; habits that come back every day are left out. */ + const loadOf = (items: readonly Quest[]) => plannedMinutes(items.filter(q => q.kind !== "daily")); + const loadMarks = (items: readonly Quest[]) => [ + // The bar fills at six planned hours. + el("span", { class: "a4-progress a4-day-load", "aria-hidden": "true", style: `--a4-done: ${Math.min(100, Math.round(loadOf(items) / 3.6))}%` }), + el("span", { class: "a4-day-total" }, loadOf(items) > 0 ? minutesLabel(loadOf(items)) : tx("予定なし"))]; + + /** Habits appear once above the week or month instead of in every day. */ + function dailyLine(list: readonly Quest[]): HTMLElement | null { + const dailies = list.filter(q => q.kind === "daily" && q.lifecycleState !== "archived" && !q.parentQuestId); + if (dailies.length === 0) return null; + return el("div", { class: "a4-daily-line" }, el("span", { class: "a4-daily" }, glyph(Repeat2), tx("毎日")), + ...dailies.map(q => { const node = button("", () => openSheet(q), "link"); node.append(q.icon ? q.icon + " " : "", el("strong", {}, q.title), q.estimatedMinutes > 0 ? el("small", {}, minutesLabel(q.estimatedMinutes)) : ""); return node; })); + } + + /** The week on a phone: one block per day from today on, earlier days folded. Finished work is only counted. */ + function weekList(list: readonly Quest[]): HTMLElement { + const today = port.today(); + const blank = weekOf(planDate).every(date => { const plan = dayPlan(list, date); return plan.timed.length + plan.anytime.filter(q => q.kind !== "daily").length === 0; }); + const block = (date: string) => { + const plan = dayPlan(list, date); + const items = [...plan.timed, ...plan.anytime].filter(q => q.kind !== "daily"); + const done = items.filter(isDone).length; + const head = el("button", { type: "button", class: "a4-wl-date", "data-today": String(date === today), "aria-label": calendarLabel(date, items) }, + el("span", { class: "a4-day-name" }, weekdayLabel.format(noon(date))), el("span", { class: "a4-day-num" }, String(Number(date.slice(8))))); + head.addEventListener("click", () => { planDate = date; planMode = "day"; store.set("a4-plan-mode", null); render(); window.scrollTo(0, 0); }); + return el("li", { class: "a4-wl-day" }, head, + el("div", { class: "a4-wl-plan" }, + blank ? (date < today ? null : (() => { const add = button(tx("予定を入れる"), () => addOn(date), "link"); add.classList.add("a4-wl-add"); add.append(glyph(Plus)); return add; })()) + : el("p", { class: "a4-wl-top" }, ...loadMarks(items), done ? el("span", { class: "a4-wl-done" }, tx("完了 {count}", { count: done })) : null), + ...items.filter(q => !isDone(q)).map(q => { + const node = el("button", { type: "button", class: "a4-wl-item", "data-tone": tone(q), "data-quest-id": q.id }, + el("span", { class: "a4-wl-time" }, q.scheduledTime || tx("未定")), el("span", { class: "a4-wl-title" }, q.icon ? q.icon + " " : "", q.title)); + node.addEventListener("click", () => openSheet(q)); + return node; + }))); + }; + const days = weekOf(planDate); + const past = days.filter(date => date < today); + const shown = days.filter(date => past.length === 7 || date >= today); + const fold = el("details", { class: "a4-earlier", open: openFolds.has("week-past") }, + el("summary", {}, tx("それより前"), el("span", {}, tx("{count}日", { count: past.length }))), el("ul", { class: "a4-wl" }, ...past.map(block))); + fold.addEventListener("toggle", () => { if (fold.open) openFolds.add("week-past"); else openFolds.delete("week-past"); }); + return el("div", { class: "a4-wl-wrap" }, past.length && past.length < 7 && !blank ? fold : null, el("ul", { class: "a4-wl" }, ...shown.map(block))); + } + function weekGrid(list: readonly Quest[]): HTMLElement { const today = port.today(); const days = weekOf(planDate); const plans = days.map(date => dayPlan(list, date)); + const blank = plans.every(plan => plan.timed.length + plan.anytime.filter(q => q.kind !== "daily").length === 0); const timed = plans.flatMap(p => p.timed); const first = Math.min(8, ...timed.map(q => Math.floor(toMinutes(q.scheduledTime) / 60))); const last = Math.min(24, Math.max(20, ...timed.map(q => Math.ceil(endOf(q) / 60)))); @@ -1214,29 +1492,30 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { const info = [quest.scheduledTime ? `${quest.scheduledTime}–${clock(endOf(quest))}` : "", length >= 30 || !quest.scheduledTime ? (quest.estimatedMinutes > 0 ? minutesLabel(quest.estimatedMinutes) : "") : "", steps.length > 0 ? `${steps.filter(isDone).length}/${steps.length}` : ""].filter(Boolean).join(" · "); const node = el("button", { type: "button", class: "a4-wk-item", "data-tone": tone(quest), "data-quest-id": quest.id, "aria-label": quest.title + " · " + date + " · " + info, ...extra }, - el("span", { class: "a4-wk-text" }, el("span", { class: "a4-wk-title" }, quest.icon ? quest.icon + " " : "", quest.title), info ? el("span", { class: "a4-meta" }, info) : null)); + el("span", { class: "a4-wk-text" }, el("span", { class: "a4-wk-title" }, quest.icon ? quest.icon + " " : "", quest.title), + !quest.scheduledTime && quest.estimatedMinutes > 0 ? el("span", { class: "a4-meta" }, minutesLabel(quest.estimatedMinutes)) : null), + isDone(quest) ? glyph(Check) : null); node.addEventListener("click", () => openSheet(quest)); return node; }; const head = days.map((date, index) => { - const total = plannedMinutes([...plans[index]!.anytime, ...plans[index]!.timed]); const node = el("button", { type: "button", class: "a4-wk-day", "aria-pressed": String(date === planDate), "data-today": String(date === today), "aria-label": calendarLabel(date, [...plans[index]!.anytime, ...plans[index]!.timed]) }, el("span", { class: "a4-day-name" }, weekdayLabel.format(noon(date))), el("span", { class: "a4-day-num" }, String(Number(date.slice(8)))), - dayDots([...plans[index]!.anytime, ...plans[index]!.timed]), - el("span", { class: "a4-day-total", "aria-hidden": "true" }, total > 0 ? minutesLabel(total) : "")); + ...(blank ? [] : loadMarks([...plans[index]!.anytime, ...plans[index]!.timed]))); node.addEventListener("click", () => { planDate = date; render(); window.scrollTo(0, 0); }); return node; }); const now = new Date(); const nowMinutes = now.getHours() * 60 + now.getMinutes(); + const hours = Array.from({ length: last - first }, (_, i) => el("span", { style: `top: ${top((first + i) * 60)}` }, clock((first + i) * 60))); const columns = days.map((date, index) => { // Overlapping cards split the column into lanes; past MAX_LANES the rest fold into "+N件". const clusters: { start: number; end: number; items: { quest: Quest; lane: number }[]; lanes: number }[] = []; let laneEnds: number[] = []; for (const quest of plans[index]!.timed) { - const start = toMinutes(quest.scheduledTime), end = Math.max(Math.min(1440, endOf(quest)), start + 48 / GRID_PX); + const start = toMinutes(quest.scheduledTime), end = Math.max(Math.min(1440, endOf(quest)), start + 30 / GRID_PX); let current = clusters[clusters.length - 1]; if (!current || start >= current.end) { current = { start, end, items: [], lanes: 0 }; clusters.push(current); laneEnds = []; } let lane = laneEnds.findIndex(laneEnd => laneEnd <= start); @@ -1252,25 +1531,31 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { const shown = folded ? cluster.items.filter(item => item.lane < MAX_LANES - 1) : cluster.items; const place = (lane: number) => `left: calc(${(lane / lanes) * 100}% + 3px); width: calc(${100 / lanes}% - 6px)`; const cards = shown.map(({ quest, lane }) => { - const start = toMinutes(quest.scheduledTime), height = Math.max(44, (Math.min(1440, endOf(quest)) - start) * GRID_PX - 4); - return card(quest, date, { "data-short": String(height < 60), "data-narrow": String(lanes > 1), style: `top: ${top(start)}; height: ${height}px; ${place(lane)}` }); + const start = toMinutes(quest.scheduledTime), height = Math.max(28, (Math.min(1440, endOf(quest)) - start) * GRID_PX - 2); + return card(quest, date, { "data-short": String(height < 44), "data-narrow": String(lanes > 1), style: `top: calc(${top(start)} + 1px); height: ${height}px; ${place(lane)}` }); }); if (!folded) return cards; const hidden = cluster.items.length - shown.length; const more = el("button", { type: "button", class: "a4-wk-more", "aria-label": tx("{day} ほか{count}件を日表示で見る", { day: dayLabel.format(noon(date)), count: hidden }), - style: `top: ${top(cluster.start)}; height: ${Math.max(44, (cluster.end - cluster.start) * GRID_PX - 4)}px; ${place(MAX_LANES - 1)}` }, tx("+{count}件", { count: hidden })); + style: `top: ${top(cluster.start)}; height: ${Math.max(28, (cluster.end - cluster.start) * GRID_PX - 2)}px; ${place(MAX_LANES - 1)}` }, tx("+{count}件", { count: hidden })); more.addEventListener("click", () => { planDate = date; planMode = "day"; store.set("a4-plan-mode", null); render(); }); return [...cards, more]; }); const nowLine = date === today && nowMinutes >= first * 60 && nowMinutes <= last * 60 ? el("div", { class: "a4-wk-now", style: `top: ${top(nowMinutes)}`, "aria-hidden": "true" }) : null; - return el("div", { class: "a4-wk-col", "data-selected": String(date === planDate) }, nowLine, ...items); + if (nowLine) hours.push(el("span", { class: "a4-wk-now-time", style: `top: ${top(nowMinutes)}` }, clock(nowMinutes))); + const col = el("div", { class: "a4-wk-col", "data-selected": String(date === today) }, nowLine, ...items); + col.addEventListener("click", event => { + if (event.target !== col || date < today) return; + addOn(date, clock(first * 60 + Math.floor(event.offsetY / GRID_PX / 30) * 30)); + }); + return col; }); - const hours = Array.from({ length: last - first }, (_, i) => el("span", { style: `top: ${top((first + i) * 60)}` }, clock((first + i) * 60))); - return el("div", { class: "a4-wk-scroll", tabindex: 0, role: "region", "aria-label": tx("この週") }, - el("div", { class: "a4-wk", style: `--a4-wk-height: ${(last - first) * 60 * GRID_PX + 44}px; --a4-wk-hour: ${60 * GRID_PX}px` }, + return el("div", { class: "a4-wk-scroll", tabindex: 0, role: "region", "aria-label": tx("この週"), "data-blank": String(blank) }, + blank ? el("p", { class: "a4-wk-hint" }, tx("時間を選ぶと、そこに予定を入れられます。")) : null, + el("div", { class: "a4-wk", style: `--a4-wk-height: ${(last - first) * 60 * GRID_PX}px; --a4-wk-hour: ${60 * GRID_PX}px` }, el("span", { class: "a4-wk-corner" }), ...head, - el("span", { class: "a4-wk-label" }, tx("いつでも")), - ...plans.map((plan, index) => el("div", { class: "a4-wk-allday", "data-selected": String(days[index] === planDate) }, ...plan.anytime.map(q => card(q, days[index]!)))), + ...(blank ? [] : [el("span", { class: "a4-wk-label" }, tx("未定")), + ...plans.map((plan, index) => el("div", { class: "a4-wk-allday" }, ...plan.anytime.filter(q => q.kind !== "daily").map(q => card(q, days[index]!))))]), el("div", { class: "a4-wk-hours", "aria-hidden": "true" }, ...hours), ...columns)); } @@ -1283,35 +1568,34 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { * A free stretch ahead today, offered as a choice: one inbox task that fits, or a rest. Only the next * stretch shows its choices; later ones open on request so the same suggestion is not repeated. */ - function freeRow(gap: { start: number; minutes: number }): HTMLElement { + function freeRow(gap: { start: number; minutes: number }, offer: boolean): HTMLElement { const today = port.today(); - const key = `${today}:${gap.start}`; - const time = el("span", { class: "a4-slot-time" }, clock(gap.start)); - const pill = el("span", { class: "a4-slot-pill", "aria-hidden": "true" }); - if (rested.has(key)) return el("li", { class: "a4-free", "data-rest": "" }, time, pill, el("p", { class: "a4-free-head" }, tx("休憩 {time}", { time: minutesLabel(gap.minutes) }))); - const head = el("p", { class: "a4-free-head" }, tx("{time} 空いています", { time: minutesLabel(gap.minutes) })); - if (!openGaps.has(key)) { - const more = button(tx("提案を見る"), () => { openGaps.add(key); render(); }, "link"); - return el("li", { class: "a4-free" }, time, pill, el("div", {}, head, more)); + if (rests.has(gap.start)) { + const rest = button(tx("休憩 {time}", { time: minutesLabel(gap.minutes) }), () => { rests.delete(gap.start); store.set("a4-rest:" + today, [...rests].join(",") || null); render(); }, "link"); + rest.setAttribute("aria-label", tx("休憩 {time}", { time: minutesLabel(gap.minutes) }) + " · " + tx("やめる")); + return el("li", { class: "a4-free a4-rest" }, rest); } - const proposed = proposalFor(quests(), gap, today); - const fit = fitForGap(quests().filter(q => q !== proposed), gap.minutes, today); - const chip = (label: string, minutes: string, dot: HTMLElement | null, action: () => void) => { - const node = el("button", { type: "button", class: "a4-suggest", disabled: busy }, dot, el("span", {}, label), minutes ? el("small", {}, minutes) : null); - node.addEventListener("click", action); - return node; - }; - return el("li", { class: "a4-free" }, time, pill, - el("div", {}, head, - el("div", { class: "a4-suggests", role: "group", "aria-label": tx("空き時間にすること") }, - proposed?.proposal ? chip(proposed.title, `${minutesLabel(proposed.proposal.minutes)} · ${tx("{name}の提案", { name: proposed.proposal.label })}`, iconCircle(proposed, "sm"), () => acceptProposal(proposed)) : null, - fit ? chip(fit.title, minutesLabel(fit.estimatedMinutes), iconCircle(fit, "sm"), () => - update(fit, { ...plan(today), scheduledTime: clock(gap.start), expectedUpdatedAt: fit.updatedAt }, tx("{time}に「{title}」を入れました", { time: clock(gap.start), title: fit.title }), previousPlan(fit))) : null, - chip(tx("休む"), "", null, () => { - rested.add(key); - say(tx("休憩にしました"), () => { rested.delete(key); render(); }); - render(); - })))); + const add = button(tx("予定を入れる"), () => chooseGap(gap), "link"); + add.append(glyph(Plus)); + const proposed = offer ? proposalFor(quests(), gap, today) : null; + const fit = offer && !proposed ? fitForGap(quests(), gap.minutes, today) : null; + const pick = proposed ?? fit; + const take = pick ? button(tx("ここに入れる"), () => proposed?.proposal ? acceptProposal(proposed) + : update(pick, { ...plan(today), scheduledTime: clock(gap.start), expectedUpdatedAt: pick.updatedAt }, tx("{time}に「{title}」を入れました", { time: clock(gap.start), title: pick.title }), previousPlan(pick)), "link") : null; + return el("li", { class: "a4-free" }, + el("div", { class: "a4-free-line" }, el("p", { class: "a4-free-head" }, tx("{time}の余白", { time: minutesLabel(gap.minutes) })), add), + pick ? el("div", { class: "a4-free-pick" }, + el("div", {}, el("small", {}, proposed?.proposal ? `${tx("{name}の提案", { name: proposed.proposal.label })} · ${minutesLabel(proposed.proposal.minutes)}` : `${tx("受け箱から")} · ${minutesLabel(pick.estimatedMinutes)}`), el("strong", {}, pick.title)), take) : null); + } + + /** A timeline card's quiet line: how long, and what marks it as the one in hand. */ + function slotMeta(quest: Quest): HTMLElement | null { + const steps = children(quest); + const parts = [quest.estimatedMinutes > 0 ? minutesLabel(quest.estimatedMinutes) : "", + steps.length > 0 ? tx("ステップ {done}/{total}", { done: steps.filter(isDone).length, total: steps.length }) : "", + quest.assignee.type !== "self" ? tx("担当 {name}", { name: quest.assignee.label || "Agent" }) : ""].filter(Boolean).join(" · "); + const mark = isWorking(quest) ? tx("作業中") : tone(quest) === "focus" ? tx("今の1件") : ""; + return mark || parts ? el("span", { class: "a4-meta" }, mark ? el("strong", {}, mark) : null, parts) : null; } /** Today's rows for the Now screen: evenly spaced cards; duration shows in the pill's height. */ @@ -1324,11 +1608,11 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { const start = toMinutes(quest.scheduledTime), end = endOf(quest); if (!nowShown && at < start) { rows.push(nowLine(at)); nowShown = true; } const gap = free.find(g => g.before === quest.id); - if (gap) rows.push(freeRow(gap)); + if (gap) rows.push(freeRow(gap, view === "now" && gap === free[0])); rows.push(el("li", { class: "a4-slot", "data-tone": tone(quest), "data-hue": hue(quest), ...running(quest, end - start), style: `--a4-cap: ${Math.round(Math.max(40, Math.min(96, 24 + (end - start) * 0.6)))}px` }, el("span", { class: "a4-slot-time" }, quest.scheduledTime, el("small", {}, clock(end))), el("span", { class: "a4-slot-pill", "aria-hidden": "true" }, quest.icon ?? ""), - el("div", { class: "a4-slot-card" }, rowText(quest, false), checkButton(quest, date)))); + el("div", { class: "a4-slot-card" }, el("div", { class: "a4-row-text" }, titleButton(quest), slotMeta(quest)), checkButton(quest, date)))); } if (!nowShown && timed.length > 0) rows.push(nowLine(at)); return rows; @@ -1341,23 +1625,25 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { const earlier = date === today ? day.timed.filter(q => !isWorking(q) && endOf(q) <= nowMinutes()) : []; const timed = day.timed.filter(q => !earlier.includes(q)); const fold = el("details", { class: "a4-earlier", open: openFolds.has("past:" + date), "data-fold": "past:" + date }, - el("summary", {}, tx("それより前({count})", { count: earlier.length })), + el("summary", {}, tx("それより前"), el("span", {}, String(earlier.length))), el("ol", { class: "a4-timeline", "data-even": "" }, ...homeRows(earlier, false, date))); fold.addEventListener("toggle", () => { if (fold.open) openFolds.add("past:" + date); else openFolds.delete("past:" + date); }); return el("div", { class: "a4-day-flow" }, - day.anytime.length ? el("section", { class: "a4-anytime" }, el("h3", { class: "a4-step-label" }, tx("いつでも")), - el("ul", { class: "a4-list" }, ...day.anytime.map(q => el("li", { class: "a4-anytime-row", "data-tone": tone(q) }, - iconCircle(q, "sm"), rowText(q), checkButton(q, date))))) : null, earlier.length ? fold : null, el("div", { class: "a4-today-panel" }, timed.length ? el("ol", { class: "a4-timeline", "data-even": "" }, ...homeRows(timed, date === today, date)) - : el("p", { class: "a4-muted" }, tx("時刻の決まった予定はありません。")))); + : el("p", { class: "a4-muted" }, tx("時刻の決まった予定はありません。"))), + day.anytime.length ? el("section", { class: "a4-anytime" }, el("h3", { class: "a4-step-label" }, tx("時刻未定"), el("span", {}, String(day.anytime.length))), + el("ul", { class: "a4-list" }, ...day.anytime.map(q => el("li", { class: "a4-anytime-row", "data-tone": tone(q) }, + iconCircle(q, "sm"), titleButton(q), + q.kind === "daily" ? el("span", { class: "a4-daily" }, glyph(Repeat2), tx("毎日")) : null, + q.estimatedMinutes > 0 ? el("span", { class: "a4-meta" }, minutesLabel(q.estimatedMinutes)) : null, checkButton(q, date))))) : null); } function todayTimeline(): HTMLElement { return el("section", { class: "a4-today", "aria-labelledby": "a4-today-title" }, el("div", { class: "a4-group-head" }, el("h2", { class: "a4-subtitle", id: "a4-today-title" }, tx("今日のタイムライン")), - button(tx("カレンダー"), () => go("plan"), "link")), dayTimeline(port.today())); + (() => { const link = button(tx("カレンダー"), () => go("plan"), "link"); link.append(glyph(ArrowUpRight)); return link; })()), dayTimeline(port.today())); } function calendarLabel(date: string, items: readonly Quest[]): string { @@ -1375,24 +1661,34 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { const dayName = (date: string) => date === today ? tx("今日") : dayLabel.format(noon(date)); const list = displayQuests(); const day = dayPlan(list, planDate); - const open = [...day.anytime, ...day.timed].filter(q => !isDone(q)); + const dayItems = [...day.anytime, ...day.timed]; + focusQuest = currentFocus(); + // The head sums what the view shows; over a week habits sit on their own line and are left out. + const spanDays = planMode === "day" ? [planDate] : planMode === "week" ? weekOf(planDate) : []; + const spanItems = spanDays.flatMap(date => { const plan = dayPlan(list, date); return [...plan.anytime, ...plan.timed]; }); + const headItems = planMode === "day" ? dayItems.filter(q => q.kind !== "daily").concat(dayItems.filter(q => q.kind === "daily").map(q => ({ ...q, estimatedMinutes: 0 }))) + : spanItems.filter(q => q.kind !== "daily"); const modeButton = (mode: PlanMode, label: string) => { const node = el("button", { type: "button", class: "a4-seg-button", "aria-pressed": String(planMode === mode) }, label); node.addEventListener("click", () => { planMode = mode; store.set("a4-plan-mode", mode === "day" ? null : mode); render(); }); return node; }; - const shift = (label: string, step: number, aria: string) => { - const node = button(label, () => { planDate = planMode === "month" ? shiftMonth(planDate, step) : addDays(planDate, step * 7); render(); }); + const shift = (icon: IconNode, step: number, aria: string) => { + const node = button("", () => { planDate = planMode === "month" ? shiftMonth(planDate, step) : addDays(planDate, step * 7); render(); }); + node.className = "a4-icon-button"; node.setAttribute("aria-label", aria); + node.append(glyph(icon)); return node; }; + const weekBlank = weekOf(planDate).every(date => { const plan = dayPlan(list, date); return plan.anytime.length + plan.timed.length === 0; }); const week = el("div", { class: "a4-week", role: "group", "aria-label": tx("この週") }, ...weekOf(planDate).map(date => { const items = (({ anytime, timed }) => [...anytime, ...timed])(dayPlan(list, date)); const node = el("button", { type: "button", class: "a4-day", "aria-pressed": String(date === planDate), "data-today": String(date === today), "aria-label": calendarLabel(date, items) }, el("span", { class: "a4-day-name" }, weekdayLabel.format(noon(date))), el("span", { class: "a4-day-num" }, String(Number(date.slice(8)))), - dayDots(items)); + // Load, not a count: the bar fills at six planned hours. + ...(weekBlank ? [] : loadMarks(items))); node.addEventListener("click", () => { planDate = date; render(); window.scrollTo(0, 0); }); return node; })); @@ -1400,54 +1696,76 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { // Undated work sits beside the day (below it on phones), one tap from being planned. const all = groups(quests(), today); - const place = (quest: Quest) => { - const node = el("button", { type: "button", class: "a4-add", "aria-label": tx("{title} を{day}に入れる", { title: quest.title, day: dayName(planDate) }), disabled: busy }, tx("+")); + const place = (quest: Quest, move = false) => { + if (planMode !== "day") { + const pick = button(tx("日付を選ぶ"), () => openSheet(quest)); + pick.classList.add("a4-place"); + return pick; + } + const node = el("button", { type: "button", class: "a4-button a4-place", "aria-label": tx("{title} を{day}に入れる", { title: quest.title, day: dayName(planDate) }), disabled: busy }, move ? tx("{day}日に移す", { day: Number(planDate.slice(8)) }) : tx("{day}日に入れる", { day: Number(planDate.slice(8)) })); node.addEventListener("click", () => update(quest, { ...plan(planDate), expectedUpdatedAt: quest.updatedAt }, tx("{day}に入れました", { day: dayName(planDate) }), previousPlan(quest))); return node; }; - const sideList = (items: readonly Quest[]) => el("ul", { class: "a4-list" }, ...items.map(q => el("li", { class: "a4-row" }, el("div", { class: "a4-row-main" }, rowText(q), place(q))))); + const sideList = (items: readonly Quest[], overdue = false) => el("ul", { class: "a4-list" }, ...items.map(q => el("li", { class: "a4-inbox-row" }, + el("div", { class: "a4-inbox-title" }, iconCircle(q, "sm"), titleButton(q)), + el("div", { class: "a4-inbox-act" }, el("span", { class: "a4-meta" }, overdue && q.scheduledDate ? tx("予定日 {date}", { date: dateLabel.format(noon(q.scheduledDate)) }) : q.estimatedMinutes > 0 ? minutesLabel(q.estimatedMinutes) : ""), place(q, overdue))))); const review = planDate === today ? all.review : []; + // Below 1380px the inbox is one line that opens a sheet. + const openInbox = () => { + const close = button(tx("閉じる"), () => dialog.close(), "link"); + const dialog = el("dialog", { class: "a4-capture-dialog a4-inbox-sheet", "aria-label": tx("受け箱") }, + el("div", { class: "a4-sheet-head" }, el("h2", { class: "a4-subtitle" }, tx("受け箱"), el("span", {}, String(all.inbox.length))), close), + el("div", { class: "a4-inbox-body" }, + all.inbox.length === 0 ? el("p", { class: "a4-muted" }, tx("受け箱は空です。")) : sideList(all.inbox), + review.length === 0 ? null : el("h3", { class: "a4-inbox-sub" }, tx("日付を見直す"), el("span", {}, String(review.length))), + review.length === 0 ? null : sideList(review, true))); + dialog.addEventListener("click", event => { if (event.target instanceof HTMLElement && event.target.closest(".a4-place, .a4-row-title")) dialog.close(); }); + dialog.addEventListener("close", () => dialog.remove()); + document.body.append(dialog); + dialog.showModal(); + }; + const inboxLine: HTMLElement | null = all.inbox.length + review.length === 0 ? null : button("", openInbox); + inboxLine?.classList.add("a4-inbox-line"); + inboxLine?.append(el("strong", {}, tx("受け箱")), el("span", {}, String(all.inbox.length)), + ...(review.length ? [el("span", { "aria-hidden": "true" }, "·"), el("span", { class: "a4-inbox-line-review" }, tx("日付を見直す"), " ", String(review.length))] : []), glyph(ChevronRight)); return el("section", { class: "a4-view a4-plan", "data-mode": planMode }, - el("div", { class: "a4-plan-main" }, el("div", { class: "a4-plan-head" }, - el("h1", { class: "a4-title", tabindex: -1 }, planMode !== "day" ? monthLabel.format(noon(planDate)) : planDate === today ? tx("今日 {day}", { day: dayLabel.format(noon(planDate)) }) : dayLabel.format(noon(planDate))), + el("div", { class: "a4-plan-title" }, + el("h1", { class: "a4-title", tabindex: -1 }, planMode === "month" ? monthLabel.format(noon(planDate)) : planMode === "week" ? `${dateLabel.format(noon(weekOf(planDate)[0]!))} – ${dateLabel.format(noon(weekOf(planDate)[6]!))}` : `${dateLabel.format(noon(planDate))} ${weekdayLong.format(noon(planDate))}`), + el("p", { class: "a4-plan-sub" }, headItems.length ? tx("{count}件 · {time} · {done}件完了", { count: headItems.length, time: minutesLabel(plannedMinutes(headItems)), done: headItems.filter(isDone).length }) : planMode === "month" ? "" : tx("予定なし"))), el("div", { class: "a4-plan-nav" }, el("div", { class: "a4-seg", role: "group", "aria-label": tx("表示") }, modeButton("day", tx("日")), modeButton("week", tx("週")), modeButton("month", tx("月"))), - planDate === today ? null : button(tx("今日へ"), () => { planDate = today; render(); }, "link"), - planMode === "month" ? shift("‹", -1, tx("前の月")) : shift("‹", -1, tx("前の週")), - planMode === "month" ? shift("›", 1, tx("次の月")) : shift("›", 1, tx("次の週")))), - ...(planMode === "month" ? [monthGrid(list)] : planMode === "week" ? [reviewStrip(), weekGrid(list)] : [week, - el("p", { class: "a4-lead" }, day.timed.length + day.anytime.length === 0 - ? tx("この日の予定はありません。受け箱から+で入れるか、上の欄に書き出しましょう。") - : open.length === 0 ? tx("この日の分はすべて終わりました。") : tx("残り {count} 件 · 目安 {time}", { count: open.length, time: minutesLabel(plannedMinutes(open)) })), - dayTimeline(planDate)])), + planMode === "month" ? shift(ChevronLeft, -1, tx("前の月")) : shift(ChevronLeft, -1, tx("前の週")), + planMode === "month" ? shift(ChevronRight, 1, tx("次の月")) : shift(ChevronRight, 1, tx("次の週")), + button(tx("今日"), () => { planDate = today; render(); }, "link"))), + el("div", { class: "a4-plan-main" }, + ...(planMode === "month" ? [dailyLine(list), inboxLine, monthGrid(list)] : planMode === "week" ? [dailyLine(list), inboxLine, weekGrid(list), weekList(list)] : [week, inboxLine, + // An empty day says so once; past days are not offered to fill. + ...(dayItems.length > 0 ? [dayTimeline(planDate)] : planDate < today ? [el("p", { class: "a4-day-empty" }, tx("この日の予定はありませんでした。"))] + : [el("div", { class: "a4-day-empty" }, el("p", {}, tx("まだ予定はありません。空いている時間も、大切に。")), + (() => { const add = button(tx("{day}日に追加", { day: Number(planDate.slice(8)) }), () => addOn(planDate)); add.prepend(glyph(Plus)); return add; })())])])), el("aside", { class: "a4-plan-side", "aria-label": tx("日付のないもの") }, - el("h2", { class: "a4-subtitle" }, tx("受け箱({count})", { count: all.inbox.length })), + el("h2", { class: "a4-subtitle" }, tx("受け箱"), el("span", {}, String(all.inbox.length))), all.inbox.length === 0 ? el("p", { class: "a4-muted" }, tx("受け箱は空です。")) : sideList(all.inbox), - review.length === 0 ? null : el("section", { class: "a4-group" }, - el("h2", { class: "a4-subtitle" }, tx("日付を見直す({count})", { count: review.length })), - el("p", { class: "a4-muted" }, tx("予定日を過ぎたものです。+で今日に入れるか、タイトルから日付を変えます。")), - sideList(review)))); + review.length === 0 ? null : (() => { + const fold = el("details", { class: "a4-earlier a4-review-fold", open: openFolds.has("review") }, + el("summary", {}, tx("日付を見直す"), el("span", {}, String(review.length))), + el("p", { class: "a4-muted" }, tx("予定日を過ぎたものです。今日に入れるか、タイトルから日付を変えます。")), + sideList(review, true)); + fold.addEventListener("toggle", () => { if (fold.open) openFolds.add("review"); else openFolds.delete("review"); }); + return fold; + })())); } /** Three numbers for this week (Spine's review): finished, planned time, moved later. */ - function reviewStrip(): HTMLElement { - const items = weekOf(planDate).flatMap(date => { const day = dayPlan(displayQuests(), date); return [...day.anytime, ...day.timed]; }); - const stat = (label: string, value: string) => el("div", { class: "a4-review-stat" }, el("dt", {}, label), el("dd", {}, value)); - return el("section", { class: "a4-review", "aria-labelledby": "a4-review-title" }, - el("h2", { class: "a4-subtitle", id: "a4-review-title" }, tx("今週のふりかえり")), - el("dl", {}, stat(tx("タスク"), tx("{count}件", { count: items.length })), stat(tx("予定した時間"), minutesLabel(plannedMinutes(items))), stat(tx("終えた"), tx("{count}件", { count: items.filter(isDone).length })))); - } - - /** Dailies with the last two weeks as dots, filled on done days. */ function habitSection(): HTMLElement | null { const today = port.today(); const habits = quests().filter(q => q.kind === "daily" && isOpen(q) && q.assignee.type === "self"); if (habits.length === 0) return null; - return el("section", { class: "a4-group" }, - el("h2", { class: "a4-subtitle" }, tx("習慣({count})", { count: habits.length })), + const fold = el("details", { class: "a4-group-fold", open: openFolds.has("group:habit") }, + el("summary", {}, tx("習慣"), el("span", {}, String(habits.length))), el("p", { class: "a4-muted" }, tx("この2週間。埋まった点が、できた日です。")), el("ul", { class: "a4-list a4-habits" }, ...habits.map(q => { const days = habitDays(q, today); @@ -1455,61 +1773,181 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { el("span", { class: "a4-habit-dots", role: "img", "data-tone": "self", "data-hue": hue(q), "aria-label": tx("この2週間で {count} 日", { count: days.filter(Boolean).length }) }, ...days.map(done => el("i", { "data-done": String(done) }))))); }))); + fold.addEventListener("toggle", () => { if (fold.open) openFolds.add("group:habit"); else openFolds.delete("group:habit"); }); + return fold; + } + + /** One line per task: tick, emoji, title, then when and how long on the right. */ + function taskRow(quest: Quest, bucket: Bucket | "done"): HTMLElement { + const today = port.today(); + const minutes = quest.estimatedMinutes > 0 ? minutesLabel(quest.estimatedMinutes) : ""; + const when = bucket === "today" ? quest.scheduledTime || (quest.kind === "daily" ? "" : tx("時刻未定")) + : quest.scheduledDate && quest.scheduledDate !== today ? [dateLabel.format(noon(quest.scheduledDate)), quest.scheduledTime].filter(Boolean).join(" ") : ""; + const info = [when, minutes, quest.assignee.type !== "self" ? tx("担当 {name}", { name: quest.assignee.label || "Agent" }) : ""].filter(Boolean).join(" · "); + const quick = bucket === "inbox" || bucket === "review" + ? button(tx("今日に入れる"), () => update(quest, { ...plan(today), expectedUpdatedAt: quest.updatedAt }, tx("今日に入れました"), previousPlan(quest)), "link") : null; + return el("li", { class: "a4-task-row", "data-tone": tone(quest), "data-bucket": bucket }, checkButton(quest), iconCircle(quest), titleButton(quest), + el("span", { class: "a4-meta" }, tone(quest) === "focus" ? el("strong", {}, tx("今の1件")) : null, + quest.kind === "daily" ? el("span", { class: "a4-daily" }, glyph(Repeat2), tx("毎日")) : null, info), quick); } function allView(): HTMLElement { - const all = groups(quests(), port.today()); - const section = (bucket: Bucket, title: string, hint = "") => { - if (!all[bucket].length) return null; - const label = tx("{title}({count})", { title, count: all[bucket].length }); - const list = el("ul", { class: "a4-list" }, ...all[bucket].map(q => row(q, bucket))); - if (["review", "later", "agent"].includes(bucket)) { - const key = "group:" + bucket; - const fold = el("details", { class: "a4-group-fold", open: openFolds.has(key) }, el("summary", {}, label), hint ? el("p", { class: "a4-muted" }, hint) : null, list); - fold.addEventListener("toggle", () => { if (fold.open) openFolds.add(key); else openFolds.delete(key); }); - return fold; - } - return el("section", { class: "a4-group" }, el("h2", { class: "a4-subtitle" }, label), hint ? el("p", { class: "a4-muted" }, hint) : null, list); + const today = port.today(); + focusQuest = currentFocus(); + const all = groups(quests(), today); + const finished = quests().filter(q => q.done && q.kind !== "reward" && q.kind !== "daily" && q.lifecycleState !== "archived" && !q.parentQuestId).slice(0, 50); + // Groups a person works from stay open; the rest open on request. + const section = (key: Bucket | "done", title: string, items: readonly Quest[], shut = false, hint = "") => { + if (!items.length) return null; + const id = (shut ? "group:" : "shut:") + key; + const fold = el("details", { class: "a4-group-fold", open: shut ? openFolds.has(id) : !openFolds.has(id) }, + el("summary", {}, title, el("span", {}, String(items.length))), hint ? el("p", { class: "a4-muted" }, hint) : null, + el("ul", { class: "a4-list" }, ...items.map(q => taskRow(q, key)))); + fold.addEventListener("toggle", () => { if (fold.open === shut) openFolds.add(id); else openFolds.delete(id); }); + return fold; }; const empty = Object.values(all).every(list => list.length === 0); // Search covers finished and put-away tasks too, so nothing is lost; typing repaints only the results. const results = el("div", { "aria-live": "polite" }); - const sections = el("div", {}); + const body = el("div", { class: "a4-all-body" }); const paintSearch = () => { const words = search.trim().toLowerCase(); - sections.hidden = words !== ""; - if (!words) { replaceChildren(results); return; } - const found = quests().filter(q => q.kind !== "reward" && `${q.title} ${q.notes ?? ""} ${q.nextAction ?? ""}`.toLowerCase().includes(words)).slice(0, 50); - replaceChildren(results, found.length === 0 ? el("p", { class: "a4-muted" }, tx("見つかりませんでした。")) : el("ul", { class: "a4-list" }, ...found.map(q => { - const away = q.lifecycleState === "archived" || isDone(q); - const state = q.lifecycleState === "archived" ? tx("しまったもの") : isDone(q) ? tx("完了") : ""; - return el("li", { class: "a4-row" }, el("div", { class: "a4-row-main" }, - el("div", { class: "a4-row-text" }, titleButton(q), el("span", { class: "a4-meta" }, [state, meta(q)].filter(Boolean).join(" · "))), - away ? button(tx("戻す"), () => update(q, { lifecycleState: "active" }, tx("やることに戻しました"), { lifecycleState: q.lifecycleState }), "link") : null)); - }))); + body.hidden = words !== ""; + if (!words) { replaceChildren(results); clear.hidden = true; return; } + const rank = (q: Quest) => q.lifecycleState === "archived" ? 2 : isDone(q) ? 1 : 0; + const all = quests().filter(q => q.kind !== "reward" && `${q.title} ${q.notes ?? ""} ${q.nextAction ?? ""}`.toLowerCase().includes(words)) + .sort((a, b) => rank(a) - rank(b)); + const found = all.slice(0, 50); + // The hit is marked; a hit only in the memo or next step shows that line so the result explains itself. + const mark = (text: string) => { + const parts: (string | HTMLElement)[] = []; + let rest = text; + for (let at = rest.toLowerCase().indexOf(words); at >= 0 && words; at = rest.toLowerCase().indexOf(words)) { + parts.push(rest.slice(0, at), el("mark", {}, rest.slice(at, at + words.length))); + rest = rest.slice(at + words.length); + } + return [...parts, rest]; + }; + const snippet = (text: string) => { + const at = text.toLowerCase().indexOf(words); + return (at > 24 ? "…" : "") + text.slice(Math.max(0, at - 24), at + words.length + 40); + }; + const row = (q: Quest) => { + const archived = q.lifecycleState === "archived"; + const title = titleButton(q); + replaceChildren(title, ...mark(q.title)); + const where = q.title.toLowerCase().includes(words) ? null + : (q.notes ?? "").toLowerCase().includes(words) ? [tx("メモ"), q.notes] as const : [tx("次の一歩"), q.nextAction] as const; + return el("li", { class: "a4-task-row a4-hit", "data-tone": tone(q), "data-state": archived ? "archived" : isDone(q) ? "done" : "open" }, + archived ? el("span", { class: "a4-check-space", "aria-hidden": "true" }) : checkButton(q), + el("div", { class: "a4-hit-text" }, title, where ? el("span", { class: "a4-hit-line" }, el("span", {}, where[0]), ...mark(snippet(where[1] ?? ""))) : null), + el("span", { class: "a4-meta" }, [archived ? tx("しまったもの") : isDone(q) ? tx("完了") : "", meta(q)].filter(Boolean).join(" · ")), + archived ? button(tx("戻す"), () => update(q, { lifecycleState: "active" }, tx("やることに戻しました"), { lifecycleState: q.lifecycleState }), "link") : null); + }; + clear.hidden = false; + replaceChildren(results, found.length === 0 ? el("div", { class: "a4-search-none" }, + el("h2", { class: "a4-subtitle" }, tx("「{text}」は見つかりませんでした", { text: search.trim() })), + el("p", { class: "a4-muted" }, tx("完了としまったものも探しました。")), + el("div", { class: "a4-actions" }, + (() => { const add = button(tx("「{text}」を追加", { text: search.trim() }), () => { openCapture(); captureInput.value = search.trim(); paintParse(); }); add.prepend(glyph(Plus)); return add; })(), + button(tx("検索を消す"), () => { search = ""; field.value = ""; paintSearch(); field.focus(); }, "link"))) + : el("div", { class: "a4-hits" }, el("p", { class: "a4-hit-count" }, tx("{count}件", { count: all.length })), el("ul", { class: "a4-list" }, ...found.map(row)), + all.length > found.length ? el("p", { class: "a4-muted" }, tx("ほか{count}件。言葉を足すと絞れます。", { count: all.length - found.length })) : null)); }; - const field = el("input", { class: "a4-input", type: "search", value: search, placeholder: tx("やることを探す"), "aria-label": tx("やることを探す") }); - field.addEventListener("input", () => { search = field.value; paintSearch(); }); - replaceChildren(sections, - empty ? el("p", { class: "a4-lead" }, tx("やることはありません。")) : null, - section("decision", tx("あなたの判断待ち"), tx("Agentや他の人からの確認依頼です。「確認する」から内容を見て判断します。")), - section("today", tx("今日")), - habitSection(), - section("review", tx("日付を見直す")), - section("inbox", tx("受け箱"), tx("日付のないものです。今日やるものだけ選べば十分です。")), - section("later", tx("これから")), - section("agent", tx("Agentが担当"))); - paintSearch(); - const head = el("div", { class: "a4-plan-head" }, el("h1", { class: "a4-title", tabindex: -1 }, tx("タスク")), - runtime ? modeSwitch([["list", tx("まとめ")], ["table", tx("表・ボード・ガント")], ["notes", tx("メモ")]], allMode, mode => { allMode = mode; }) : null); - if (allMode !== "list" && runtime) { - return el("section", { class: "a4-view a4-wide" }, head, + const field = el("input", { class: "a4-input", type: "search", value: search, placeholder: tx("タスクを探す"), "aria-label": tx("タスクを探す") }); + // Japanese input searches once the word is converted, not on every keystroke of the reading. + field.addEventListener("input", event => { if ((event as InputEvent).isComposing) return; search = field.value; paintSearch(); }); + field.addEventListener("compositionend", () => { search = field.value; paintSearch(); }); + const clear = el("button", { type: "button", class: "a4-icon-button a4-search-clear", "aria-label": tx("検索を消す"), hidden: !search.trim() }, glyph(X)); + clear.addEventListener("click", () => { search = ""; field.value = ""; paintSearch(); field.focus(); }); + const modes = modeSwitch([["list", tx("一覧")], ["board", tx("ボード")], ["gantt", tx("ガント")], ["notes", tx("メモ")]], allMode === "table" ? "list" : allMode, mode => { allMode = mode; }); + const head = el("div", { class: "a4-all-head" }, allMode === "notes" ? null : el("label", { class: "a4-search" }, glyph(Search), field, clear), modes, + el("h1", { class: "a4-sr", tabindex: -1 }, tx("タスク"))); + if ((allMode === "table" || allMode === "notes") && runtime) { + return el("section", { class: "a4-view a4-wide a4-all" }, head, embed(allMode === "table" ? forge!.render("list") : workUi.screen("notes").main)); } - return el("section", { class: "a4-view" }, head, el("div", { class: "a4-search" }, field), results, sections); + if (allMode === "notes") replaceChildren(body, el("p", { class: "a4-lead" }, tx("メモはサインイン後に使えます。"))); + else if (allMode === "board") replaceChildren(body, boardView(finished)); + else if (allMode === "gantt") replaceChildren(body, ganttView()); + else replaceChildren(body, + decisionNotice(), + empty ? finished.length ? el("p", { class: "a4-all-clear" }, tx("やることは全部終わりました。")) : noTasks() : null, + section("today", tx("今日"), all.today), + section("review", tx("日付を見直す"), all.review, true), + section("inbox", tx("受け箱"), all.inbox), + section("later", tx("これから"), all.later), + habitSection(), + section("agent", tx("Agentが担当"), all.agent.filter(q => !needsDecision(q)), true), + section("done", tx("完了"), finished, true)); + paintSearch(); + return el("section", { class: "a4-view a4-wide a4-all", "data-mode": allMode }, head, results, body); + } + + /** Nothing at all yet: one box, one way in. */ + const noTasks = () => el("div", { class: "a4-empty a4-empty-box" }, glyph(ListTodo), el("h2", { class: "a4-subtitle" }, tx("タスクはありません")), + el("p", { class: "a4-muted" }, tx("思いついたことから、書き出してみましょう。")), + (() => { const add = button(tx("タスクを追加"), openCapture, "primary"); add.prepend(glyph(Plus)); return add; })()); + + /** Four columns by where the work stands. The first shows a handful and opens the rest on request. */ + function boardView(finished: readonly Quest[]): HTMLElement { + const today = port.today(); + const list = quests().map(q => forDay(q, today)).filter(q => q.kind !== "reward" && q.lifecycleState !== "archived" && !q.humanRequest || needsDecision(q)); + const ai = agentSummary(quests(), today); + const asks = [...quests().filter(needsDecision), ...ai.blocked]; + const working = list.filter(q => isOpen(q) && !asks.includes(q) && q.assignee.handoffState === "working"); + const open = groups(quests(), today); + const ahead = [...open.today, ...open.review, ...open.inbox, ...open.later, ...open.agent].filter(q => !asks.some(a => a.id === q.id) && !working.some(w => w.id === q.id)); + const card = (quest: Quest, state = "") => { + const node = el("button", { type: "button", class: "a4-card", "data-tone": tone(quest), "data-quest-id": quest.id }, + el("strong", {}, quest.icon ? quest.icon + " " : "", quest.title), + el("span", { class: "a4-meta" }, + state ? el("b", {}, state) : tone(quest) === "focus" ? el("strong", {}, tx("今の1件")) : null, + quest.kind === "daily" ? el("span", { class: "a4-daily" }, glyph(Repeat2), tx("毎日")) : null, + quest.assignee.type !== "self" ? quest.assignee.label || "Agent" + : [quest.scheduledDate === today ? quest.scheduledTime || (quest.kind === "daily" ? "" : tx("時刻未定")) : quest.scheduledDate ? dateLabel.format(noon(quest.scheduledDate)) : "", quest.estimatedMinutes > 0 ? minutesLabel(quest.estimatedMinutes) : ""].filter(Boolean).join(" · ")), + isDone(quest) ? glyph(Check) : null); + node.addEventListener("click", () => asks.includes(quest) ? go("guild", quest.id) : openSheet(quest)); + return node; + }; + const more = ahead.length > 5 && !boardAll ? withArrow(button(tx("ほか {count}件を見る", { count: ahead.length - 5 }), () => { boardAll = true; render(); }, "link")) : null; + const names: string[] = [], counts: number[] = []; + const column = (title: string, count: number, ...items: (HTMLElement | null)[]) => { + names.push(title); counts.push(count); + return el("section", { class: "a4-col", "data-active": String(names.length - 1 === boardCol) }, el("h2", {}, title, el("span", {}, String(count))), ...items); + }; + const columns = [ + column(tx("これから"), ahead.length, ...(boardAll ? ahead : ahead.slice(0, 5)).map(q => card(q)), more), + column(tx("作業中"), working.length, ...working.map(q => card(q))), + column(tx("確認待ち"), asks.length, ...asks.map(q => card(q, needsDecision(q) ? tx("確認待ち") : tx("止まっています")))), + column(tx("完了"), finished.length, ...finished.slice(0, 20).map(q => card(q)))]; + // Phones show one column at a time. + const tabs = el("div", { class: "a4-col-tabs", role: "tablist", "aria-label": tx("ボード") }, ...names.map((name, index) => { + const tab = el("button", { type: "button", role: "tab", "aria-selected": String(index === boardCol) }, name, el("span", {}, String(counts[index]))); + tab.addEventListener("click", () => { boardCol = index; render(); }); + return tab; + })); + if (counts.every(count => count === 0)) return noTasks(); + return el("div", { class: "a4-board-wrap" }, tabs, el("div", { class: "a4-board" }, ...columns)); + } + const withArrow = (node: HTMLElement) => { node.append(glyph(ChevronRight)); return node; }; + + /** Seven days from today, one row per planned task of your own. */ + function ganttView(): HTMLElement { + const today = port.today(); + const days = Array.from({ length: 7 }, (_, i) => addDays(today, i)); + const rows = quests().map(q => forDay(q, today)).filter(q => isOpen(q) && q.assignee.type === "self" && q.kind !== "daily" && q.kind !== "reward" && !needsDecision(q) && days.includes(q.scheduledDate ?? "")) + .sort((a, b) => `${a.scheduledDate} ${a.scheduledTime || "99"}`.localeCompare(`${b.scheduledDate} ${b.scheduledTime || "99"}`)); + if (rows.length === 0) return el("div", { class: "a4-gantt-empty" }, el("h2", { class: "a4-subtitle" }, tx("この7日間に日付のあるタスクはありません")), + el("p", { class: "a4-muted" }, tx("日付を入れると、ここに並びます。")), withArrow(button(tx("一覧で見る"), () => { allMode = "list"; render(); }, "link"))); + return el("div", { class: "a4-gantt", role: "table", "aria-label": tx("ガント") }, + el("div", { class: "a4-gantt-row a4-gantt-head", role: "row" }, el("span", { role: "columnheader" }, tx("タスク")), + ...days.map(date => el("span", { role: "columnheader", "data-today": String(date === today) }, weekdayLabel.format(noon(date)), el("b", {}, String(Number(date.slice(8))))))), + ...rows.map(q => el("div", { class: "a4-gantt-row", role: "row", "data-tone": tone(q) }, + (() => { const title = titleButton(q); if (q.icon) title.prepend(q.icon + " "); return el("span", { role: "cell" }, title); })(), + el("span", { class: "a4-gantt-bar", role: "cell", style: `grid-column: ${days.indexOf(q.scheduledDate ?? "") + 2}` }, q.scheduledTime || (q.estimatedMinutes > 0 ? minutesLabel(q.estimatedMinutes) : ""))))); } - /** A small pill group that switches views inside one tab. */ function modeSwitch(options: readonly (readonly [T, string])[], current: T, choose: (mode: T) => void): HTMLElement { return el("div", { class: "a4-seg", role: "group", "aria-label": tx("表示") }, ...options.map(([mode, label]) => { const node = el("button", { type: "button", class: "a4-seg-button", "aria-pressed": String(current === mode) }, label); @@ -1585,6 +2023,20 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { /** One decision at a time, like the Now screen: what is asked, what to check, then a single clear answer. */ function decisionCard(quest: Quest, index: number, total: number, next: () => void): HTMLElement { const request = quest.humanRequest ?? null; + const name = (request ? quest.requester?.label : quest.assignee.label) || "Agent"; + const from = (label: string, stuck = false) => el("p", { class: "a4-dec-from", "data-stuck": String(stuck) }, + el("span", { class: "a4-avatar", "data-kind": "agent", "aria-hidden": "true" }, name.slice(0, 1)), label); + const onward = total > 1 ? withArrow(button(tx("次の判断へ"), next, "link")) : null; + onward?.classList.add("a4-dec-next"); + // Stopped work is a decision too: say why it stopped, then open the task to unblock it. + if (!needsDecision(quest)) { + const note = quest.handoff?.blockedReason || quest.nextAction; + const open = button(tx("判断する"), () => openSheet(quest), "primary"); + open.append(glyph(ArrowRight)); + return el("div", { class: "a4-dec" }, from(tx("{name}が止まっています", { name }), true), + el("h3", { class: "a4-dec-title" }, quest.title), note ? el("p", { class: "a4-dec-lead" }, note) : null, + el("div", { class: "a4-dec-actions" }, open, onward)); + } const source = request?.sourceQuestId ? byId(request.sourceQuestId) : null; const link = artifactLink(request?.artifactUrl || quest.handoff?.artifactUrl, quest); const detail = (label: string, value: string | undefined) => value ? el("p", { class: "a4-step" }, el("span", { class: "a4-step-label" }, label), value) : null; @@ -1592,7 +2044,9 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { const leg = relay?.legs[relay.at]; const nextLeg = relay?.legs[relay.at + 1]; const nextName = nextLeg ? port.agents().find(agent => agent.id === nextLeg.agentId)?.name ?? "Agent" : ""; - const details = [request?.checkTarget ? el("p", { class: "a4-step" }, request.checkTarget) : null, detail(tx("理由"), request?.reason), detail(tx("完了の条件"), quest.completionCriteria), detail(tx("報告"), request ? undefined : quest.handoff?.note)].filter(Boolean) as HTMLElement[]; + const lead = request?.checkTarget || quest.nextAction; + const notes = quest.notes || source?.notes || ""; + const details = [detail(tx("理由"), request?.reason), detail(tx("完了の条件"), quest.completionCriteria), detail(tx("報告"), request ? undefined : quest.handoff?.note)].filter(Boolean) as HTMLElement[]; let gallery = reviewImageGalleries.get(quest.id); if (gallery && !gallery.element.isConnected) { gallery.dispose(); reviewImageGalleries.delete(quest.id); gallery = undefined; } if (!gallery) { gallery = questImageEditor(port.images, quest, "review"); reviewImageGalleries.set(quest.id, gallery); } @@ -1605,27 +2059,26 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { check.addEventListener("change", () => { if (check.checked) confirmed.add(quest.id); else confirmed.delete(quest.id); sync(); }); reply.addEventListener("input", () => { replies.set(quest.id, reply.value); sync(); }); const revising = reviseFor === quest.id; - return el("div", { class: "a4-focus a4-decision" }, - el("p", { class: "a4-eyebrow" }, request ? tx("{name} からの確認依頼", { name: quest.requester?.label || "Agent" }) : leg?.ask ? tx("{name} が「{ask}」を終えました", { name: quest.assignee.label || "Agent", ask: leg.ask }) : tx("{name} が「終わった」と報告しています", { name: quest.assignee.label || "Agent" }), - total > 1 ? el("span", { class: "a4-meta" }, ` ${index + 1} / ${total}`) : null), - el("h2", { class: "a4-focus-title" }, quest.title), - gallery.element, - details.length > 0 ? el("details", { class: "a4-more" }, el("summary", {}, tx("詳しく")), el("div", { class: "a4-more-body" }, ...details)) : null, - el("div", { class: "a4-actions" }, - source ? button(tx("元のタスクを見る"), () => openSheet(source), "link") : null, - link), + const links = [source ? button(tx("元のタスク"), () => openSheet(source), "link") : null, link].filter(Boolean) as HTMLElement[]; + return el("div", { class: "a4-dec" }, + from(request ? tx("{name}からの確認依頼", { name }) : leg?.ask ? tx("{name} が「{ask}」を終えました", { name, ask: leg.ask }) : tx("{name}が「終わった」と報告しています", { name })), + el("h3", { class: "a4-dec-title" }, quest.title), + lead ? el("p", { class: "a4-dec-lead" }, lead) : null, + el("div", { class: "a4-dec-body" }, + notes ? el("div", { class: "a4-dec-notes" }, el("span", { class: "a4-step-label" }, tx("説明")), el("p", {}, notes)) : null, + gallery.element), + details.length + links.length > 0 ? el("div", { class: "a4-dec-more" }, + details.length > 0 ? el("details", { class: "a4-more" }, el("summary", {}, tx("詳しく")), el("div", { class: "a4-more-body" }, ...details)) : null, + el("span", { class: "a4-dec-links" }, ...links)) : null, request ? (() => { let editor = reviewImageEditors.get(quest.id); if (!editor) { editor = questImageEditor(port.images, quest, "response"); reviewImageEditors.set(quest.id, editor); } return editor.element; })() : null, el("label", { class: "a4-option" }, check, tx("中身を確認しました")), revising ? el("div", { class: "a4-field-row" }, el("span", { class: "a4-step-label" }, request ? tx("直してほしいこと") : tx("やり直してほしいこと")), reply) : null, - el("div", { class: "a4-actions" }, + el("div", { class: "a4-dec-actions" }, revising ? send : ok, - revising ? button(tx("やめる"), () => { reviseFor = null; render(); }, "link") : button(tx("直してほしい"), () => { reviseFor = quest.id; render(); main.querySelector(".a4-decision textarea")?.focus(); }), + revising ? button(tx("やめる"), () => { reviseFor = null; render(); }, "link") : button(tx("直してほしい"), () => { reviseFor = quest.id; render(); main.querySelector(".a4-dec textarea")?.focus(); }), request && !revising ? button(tx("あとで"), () => answer(quest, "defer"), "link") : null, - total > 1 && !revising ? button(tx("次の判断へ"), next, "link") : null)); + revising ? null : onward)); } - - /* ------------------------------ guild: people, activity, registration ------------------------------ */ - const timeLabel = (iso: string) => { const at = new Date(iso); if (Number.isNaN(at.getTime())) return ""; @@ -1659,50 +2112,71 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { : tx("{who} が「{title}」を更新しました", vars); return el("li", { class: "a4-activity" }, el("span", { class: "a4-activity-time" }, timeLabel(event.at)), - el("span", { class: "a4-avatar a4-avatar-sm", "data-kind": event.source === "mcp" ? "agent" : "self", "aria-hidden": "true" }, who.slice(0, 1)), + el("span", {}, line)); } /** A person or Agent and what they are on right now. */ /** Load as one stacked bar: open tasks by state, scaled against the busiest member so loads compare at a glance. */ - function loadBar(load: readonly Quest[], scale: number): HTMLElement | null { - if (load.length === 0) return null; - const all = port.quests(); - const state = (quest: Quest): string => needsDecision(quest) ? "review_required" - : quest.assignee.handoffState === "none" && blockers(quest, all).length > 0 ? "waiting" : quest.assignee.handoffState; - const parts = ["working", "review_required", "blocked", "waiting", "ready", "accepted", "none"] - .map(key => ({ key, count: load.filter(q => state(q) === key).length })).filter(part => part.count > 0); - const label = (key: string) => key === "none" ? tx("これから") : key === "waiting" ? tx("前の作業待ち") : AGENT_STATE[key] ?? key; - const summary = [tx("{count} 件を担当", { count: load.length }), ...parts.map(part => `${label(part.key)} ${part.count}`)].join(" · "); - return el("div", { class: "a4-load" }, - el("div", { class: "a4-load-track", role: "img", "aria-label": summary }, - el("div", { class: "a4-load-bar", style: `width: ${Math.max(6, (load.length / scale) * 100)}%` }, - ...parts.map(part => el("i", { "data-state": part.key, style: `flex-grow: ${part.count}` })))), - el("span", { class: "a4-meta", "aria-hidden": "true" }, summary)); - } - - function memberRow(kind: "self" | "human" | "agent", name: string, sub: string, doing: readonly Quest[], load: HTMLElement | null, inactive = false, agentId = ""): HTMLElement { - const shown = doing.slice(0, 3); - const confirming = agentId !== "" && removingAgent === agentId; - const remove = agentId === "" ? null : confirming ? null : button(tx("外す"), () => { removingAgent = agentId; render(); }, "link"); - return el("li", { class: "a4-row a4-member", "data-inactive": String(inactive) }, - el("div", { class: "a4-row-main" }, - el("span", { class: "a4-avatar", "data-kind": kind, "aria-hidden": "true" }, name.slice(0, 1)), - el("div", { class: "a4-row-text" }, el("strong", {}, name), sub ? el("span", { class: "a4-meta" }, sub) : null), remove), - confirming ? el("div", { class: "a4-conn-edit", role: "group", "aria-label": tx("{name}を外す", { name }) }, - el("p", { class: "a4-muted" }, tx("{name} をギルドから外します。", { name }) + (doing.length > 0 ? tx("担当中の {count} 件はそのまま残るので、必要なら担当を変えてください。", { count: doing.length }) : "") + tx("外した Agent は担当に選べなくなり、MCP の紐付けも使えなくなります。")), - el("div", { class: "a4-actions" }, - button(tx("外す"), () => { - void run(async () => { await port.removeAgent(agentId); removingAgent = null; say(tx("{name} を外しました", { name })); }, tx("外せませんでした。もう一度試してください。")); - }, "primary"), - button(tx("やめる"), () => { removingAgent = null; render(); }, "link"))) : null, - el("div", { class: "a4-doing" }, load, - shown.length === 0 ? el("span", { class: "a4-meta" }, inactive ? tx("停止中") : kind === "human" ? tx("担当しているタスクはありません") : tx("いまは手が空いています")) - : el("ul", { class: "a4-doing-list" }, ...shown.map(quest => el("li", {}, - el("span", { class: "a4-state", "data-state": kind === "agent" ? quest.assignee.handoffState : "working" }, - kind === "agent" ? AGENT_STATE[quest.assignee.handoffState] ?? quest.assignee.handoffState : tx("いまやっている")), - titleButton(quest)))), - doing.length > shown.length ? el("span", { class: "a4-meta" }, tx("ほか {count} 件", { count: doing.length - shown.length })) : null)); + /** One line per member: who, what they are on now, how many they hold. The row opens their work. */ + function memberRow(kind: "self" | "human" | "agent", key: string, name: string, now: readonly [string, string, string] | null, load: readonly Quest[], inactive = false, agentId = ""): HTMLElement { + const open = memberOpen === key; + // Registered but not linked over MCP yet: the Agent cannot pick up work until it is. + const unlinked = agentId !== "" && load.length === 0 && connections !== null && !connections.some(item => item.agentIds.includes(agentId)); + const row = el("button", { type: "button", class: "a4-member-row", "aria-expanded": String(open) }, + el("span", { class: "a4-avatar", "data-kind": kind, "aria-hidden": "true" }, kind === "self" ? name.slice(0, 2) : name.slice(0, 1)), + el("span", { class: "a4-member-copy" }, el("strong", {}, kind === "self" ? tx("{name}(あなた)", { name }) : name), + el("span", { class: "a4-member-now" }, now ? el("b", { "data-state": now[2] }, now[0]) : null, + now ? now[1] : inactive ? tx("停止中") : unlinked ? tx("まだMCPにつながっていません") : kind === "agent" ? tx("いまは手が空いています") : tx("担当しているタスクはありません"))), + load.length ? el("span", { class: "a4-member-count" }, tx("{count}件", { count: load.length })) : null, load.length ? glyph(ChevronRight) : null); + if (!load.length) { row.setAttribute("disabled", ""); row.removeAttribute("aria-expanded"); } + row.addEventListener("click", () => { memberOpen = open ? "" : key; render(); }); + const toMcp = () => { go("settings"); settingsPage = "mcp"; render(); }; + const link = unlinked ? withArrow(button(tx("つなぐ"), toMcp, "link")) : null; + link?.classList.add("a4-member-link"); + const menu = agentId === "" ? null : el("details", { class: "a4-menu a4-member-menu" }, + el("summary", { class: "a4-icon-button", "aria-label": tx("{name}の操作", { name }) }, glyph(MoreHorizontal)), + el("div", { class: "a4-menu-list" }, button(tx("MCP接続を設定"), toMcp, "link"), button(tx("外す"), () => { menu!.open = false; removeAgentDialog(agentId, name, load); }, "link"))); + return el("li", { class: "a4-member", "data-inactive": String(inactive), "data-open": String(open) }, + el("div", { class: "a4-member-line" }, row, link, menu), + !open ? null : el("div", { class: "a4-member-work" }, + el("ul", { class: "a4-doing-list" }, ...load.slice(0, 8).map(quest => el("li", {}, + kind === "agent" ? el("span", { class: "a4-state", "data-state": quest.assignee.handoffState }, AGENT_STATE[quest.assignee.handoffState] ?? quest.assignee.handoffState) : null, + titleButton(quest)))), + load.length > 8 ? el("span", { class: "a4-meta" }, tx("ほか {count} 件", { count: load.length - 8 })) : null)); + } + + /** Removing an Agent archives it; its open tasks come back to you unless you say otherwise. */ + function removeAgentDialog(agentId: string, name: string, load: readonly Quest[]): void { + const back = el("input", { type: "checkbox", checked: true }); + const close = el("button", { type: "button", class: "a4-icon-button", "aria-label": tx("閉じる") }, glyph(X)); + const stay = el("button", { type: "button", class: "a4-link" }, tx("やめる")); + const remove = el("button", { type: "button", class: "a4-button" }, tx("外す")); + const dialog = el("dialog", { class: "a4-capture-dialog a4-confirm", "aria-labelledby": "a4-remove-title" }, + el("div", { class: "a4-sheet-head" }, el("h2", { id: "a4-remove-title", class: "a4-subtitle" }, tx("{name}を外しますか?", { name })), close), + el("div", { class: "a4-confirm-body" }, + el("p", {}, tx("担当に選べなくなり、MCPの紐付けも使えなくなります。")), + load.length === 0 ? null : el("label", { class: "a4-option" }, back, el("strong", {}, tx("担当中の{count}件を自分に戻す", { count: load.length }))), + load.length === 0 ? null : el("ul", { class: "a4-confirm-list" }, ...load.slice(0, 3).map(quest => el("li", {}, + el("span", { class: "a4-state", "data-state": quest.assignee.handoffState }, AGENT_STATE[quest.assignee.handoffState] ?? quest.assignee.handoffState), quest.title)), + load.length > 3 ? el("li", { class: "a4-meta" }, tx("ほか {count} 件", { count: load.length - 3 })) : null)), + el("div", { class: "a4-actions a4-confirm-foot" }, stay, remove)); + close.addEventListener("click", () => dialog.close()); + stay.addEventListener("click", () => dialog.close()); + remove.addEventListener("click", () => { + dialog.close(); + void run(async () => { + if (back.checked) for (const quest of load) await port.update(quest, { assignee: { type: "self", id: port.self().id, label: port.self().label, handoffState: "none" } }); + await port.removeAgent(agentId); + memberOpen = ""; + say(tx("{name} を外しました", { name })); + }, tx("外せませんでした。もう一度試してください。")); + }); + dialog.addEventListener("close", () => dialog.remove()); + document.body.append(dialog); + dialog.showModal(); + // The safe choice takes the first focus. + stay.focus(); } const slug = (value: string) => value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80); @@ -1710,54 +2184,79 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { /** Register an Agent with plain choices; the raw scopes stay in Relay Forge. */ function openAgentForm(): void { sheet?.close(); - const name = el("input", { class: "a4-sheet-title", required: true, maxlength: 60, placeholder: tx("Agent の名前(例:Scribe)"), "aria-label": tx("Agent の名前") }); + const name = el("input", { class: "a4-input", required: true, maxlength: 60, placeholder: tx("例:Scribe"), "aria-label": tx("名前"), autocomplete: "off" }); const id = el("input", { class: "a4-input", required: true, maxlength: 80, pattern: "[a-z0-9](?:[a-z0-9-]*[a-z0-9])?", placeholder: tx("例:scribe"), "aria-label": "Agent ID", autocapitalize: "off", spellcheck: false }); - const role = el("input", { class: "a4-input", maxlength: 80, placeholder: tx("例:文書をまとめる"), "aria-label": tx("何をする Agent か") }); + const role = el("input", { class: "a4-input", maxlength: 80, placeholder: tx("例:文書をまとめる"), "aria-label": tx("何をするAgent?") }); const instructions = el("textarea", { class: "a4-input a4-textarea", rows: 3, maxlength: 2000, placeholder: tx("例:下書きは箇条書きで。数字は必ず出典を書く。"), "aria-label": tx("お願いしておくこと") }); + // A name with no ASCII (e.g. 書類係) still gets an ID, so registering never stops on an empty one. + const fallback = `agent-${Math.random().toString(36).slice(2, 6).padEnd(4, "0")}`; let idTouched = false; - name.addEventListener("input", () => { if (!idTouched) id.value = slug(name.value); }); + const idText = el("strong", {}); + const paintId = () => { if (!idTouched) id.value = slug(name.value) || fallback; idText.textContent = id.value; }; + name.addEventListener("input", paintId); id.addEventListener("input", () => { idTouched = true; }); + const errorOf = () => el("small", { class: "a4-field-error", role: "alert", hidden: true }); + const nameError = errorOf(), idError = errorOf(); + const idField = el("label", { class: "a4-field-row", hidden: true }, el("span", { class: "a4-step-label" }, "Agent ID"), id, + el("small", { class: "a4-muted" }, tx("英小文字・数字・ハイフン。MCPでAgentを指すときに使います。あとから変えられません。")), idError); + const idEdit = button(tx("変更"), () => { idLine.hidden = true; idField.hidden = false; id.focus(); }, "link"); + const idLine = el("p", { class: "a4-agent-id" }, el("span", {}, "ID"), idText, idEdit); const power = (value: string, label: string, hint: string, checked: boolean) => - el("label", { class: "a4-option a4-choice" }, el("input", { type: "radio", name: "a4-agent-power", value, checked }), el("span", {}, el("strong", {}, label), el("small", { class: "a4-muted" }, hint))); + el("label", { class: "a4-power" }, el("input", { type: "radio", name: "a4-agent-power", value, checked }), el("span", {}, el("strong", {}, label), el("small", {}, hint))); const review = el("input", { type: "checkbox", checked: true }); - const error = el("p", { class: "a4-error", role: "alert", hidden: true }); - const field = (label: string, control: HTMLElement, hint = "") => el("label", { class: "a4-field-row" }, el("span", { class: "a4-step-label" }, label), control, hint ? el("small", { class: "a4-muted" }, hint) : null); - const form = el("form", { class: "a4-sheet-form", id: "a4-agent-form", novalidate: true }, - name, - field(tx("何をする Agent ?"), role), - field("Agent ID", id, tx("英小文字・数字・ハイフン。MCP で Agent を指すときに使います。あとから変えられません。")), - el("fieldset", { class: "a4-field" }, el("legend", { class: "a4-step-label" }, tx("やってよいこと")), + // Waiting for your check only means something for an Agent that changes tasks. + const reviewRow = el("label", { class: "a4-option a4-agent-review" }, review, tx("終わったら、あなたの確認を待つ")); + const instructionsField = el("label", { class: "a4-field-row", hidden: true }, el("span", { class: "a4-step-label" }, tx("お願いしておくこと")), instructions); + const more = button(tx("+お願いしておくこと"), () => { more.hidden = true; instructionsField.hidden = false; instructions.focus(); }); + more.classList.add("a4-chip"); + const error = el("p", { class: "a4-save-error", role: "alert", hidden: true }); + const field = (label: HTMLElement | string, control: HTMLElement, ...after: (HTMLElement | null)[]) => el("label", { class: "a4-field-row" }, typeof label === "string" ? el("span", { class: "a4-step-label" }, label) : label, control, ...after); + const form = el("form", { class: "a4-sheet-form a4-agent-form", id: "a4-agent-form", novalidate: true }, + field(tx("名前"), name, nameError), + idLine, idField, + field(el("span", { class: "a4-step-label" }, tx("何をするAgent?"), el("small", {}, tx("任意"))), role, el("small", { class: "a4-muted" }, tx("担当を選ぶときに表示されます。"))), + el("fieldset", { class: "a4-field a4-powers" }, el("legend", { class: "a4-step-label" }, tx("やってよいこと")), power("read", tx("見るだけ"), tx("タスクを読むだけで、変えません"), false), power("write", tx("タスクを進める"), tx("タスクを読み、状態や中身を書き換えます"), true)), - el("label", { class: "a4-option" }, review, tx("終わったら、あなたの確認を待つ")), - field(tx("お願いしておくこと(任意)"), instructions), - error); - const close = el("button", { type: "button", class: "a4-button" }, tx("閉じる")); - const dialog = el("dialog", { class: "a4-sheet", "aria-labelledby": "a4-agent-title" }, - el("div", { class: "a4-sheet-head" }, el("h2", { id: "a4-agent-title", class: "a4-eyebrow" }, tx("Agent を登録")), close), + reviewRow, more, instructionsField, error); + form.addEventListener("change", () => { reviewRow.hidden = form.querySelector('input[name="a4-agent-power"]:checked')?.value !== "write"; }); + const close = el("button", { type: "button", class: "a4-icon-button", "aria-label": tx("閉じる") }, glyph(X)); + const cancel = el("button", { type: "button", class: "a4-link" }, tx("キャンセル")); + const dialog = el("dialog", { class: "a4-sheet a4-agent-sheet", "aria-labelledby": "a4-agent-title" }, + el("div", { class: "a4-sheet-head" }, el("h2", { id: "a4-agent-title", class: "a4-subtitle" }, tx("Agentを登録")), close), form, - el("div", { class: "a4-actions a4-sheet-foot" }, el("button", { type: "submit", class: "a4-button a4-primary", form: "a4-agent-form" }, tx("登録する")))); + el("div", { class: "a4-actions a4-sheet-foot" }, cancel, el("button", { type: "submit", class: "a4-button a4-primary", form: "a4-agent-form" }, tx("登録する")))); close.addEventListener("click", () => dialog.close()); - const fail = (message: string, focus: HTMLElement) => { error.textContent = message; error.hidden = false; focus.focus(); }; + cancel.addEventListener("click", () => dialog.close()); + // Each problem is said under its own field. + const fail = (target: HTMLElement, message: string, focus: HTMLElement) => { target.textContent = message; target.hidden = false; focus.setAttribute("aria-invalid", "true"); focus.focus(); }; form.addEventListener("submit", event => { event.preventDefault(); + for (const node of [nameError, idError, error]) node.hidden = true; + for (const node of [name, id]) node.removeAttribute("aria-invalid"); + paintId(); const agentId = id.value.trim().toLowerCase(); - if (!name.value.trim()) return fail(tx("名前を入れてください。"), name); - if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(agentId)) return fail(tx("Agent ID は英小文字・数字・ハイフンで入れてください。"), id); - if (port.agents().some(agent => agent.id === agentId)) return fail(tx("この Agent ID はもう使われています。"), id); + const showId = () => { idLine.hidden = true; idField.hidden = false; }; + if (!name.value.trim()) return fail(nameError, tx("名前を入れてください。"), name); + if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(agentId)) { showId(); return fail(idError, tx("Agent ID は英小文字・数字・ハイフンで入れてください。"), id); } + if (port.agents().some(agent => agent.id === agentId)) { showId(); return fail(idError, tx("この Agent ID はもう使われています。"), id); } const write = form.querySelector('input[name="a4-agent-power"]:checked')?.value === "write"; - error.hidden = true; void run(async () => { - const agent = await port.registerAgent({ - agentId, displayName: name.value.trim(), role: role.value.trim(), instructions: instructions.value.trim(), - allowedScopes: write ? ["quests:read", "quests:write"] : ["quests:read"], reviewRequired: review.checked, - }); - dialog.close(); - say(tx("{name} を登録しました。担当のリレーで選べます。", { name: agent.name })); + try { + const agent = await port.registerAgent({ + agentId, displayName: name.value.trim(), role: role.value.trim(), instructions: instructions.value.trim(), + allowedScopes: write ? ["quests:read", "quests:write"] : ["quests:read"], reviewRequired: write && review.checked, + }); + dialog.close(); + say(tx("{name}を登録しました。担当に選べます。", { name: agent.name })); + } catch { + error.textContent = tx("登録できませんでした。もう一度試してください。"); error.hidden = false; + } }, tx("登録できませんでした。もう一度試してください。")); }); dialog.addEventListener("close", () => { dialog.remove(); if (sheet === dialog) sheet = null; }); sheet = dialog; + paintId(); document.body.append(dialog); dialog.showModal(); name.focus(); @@ -1777,11 +2276,33 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { .finally(() => { notifyLoading = false; if (!disposed) render(); }); } - function notifySection(): HTMLElement { + /** One settings line: what it is, a short hint, then a control, a value or a way in. */ + function setRow(label: string, hint: string, tail: HTMLElement | string | null, open?: (() => void) | string): HTMLElement { + const copy = el("span", { class: "a4-set-copy" }, el("strong", {}, label), hint ? el("small", {}, hint) : null); + if (typeof open === "string") return el("a", { class: "a4-set-row", href: open }, copy, glyph(ChevronRight)); + if (open) { + const node = el("button", { type: "button", class: "a4-set-row" }, copy, tail ? el("span", { class: "a4-set-value" }, tail) : null, glyph(ChevronRight)); + node.addEventListener("click", open); + return node; + } + return el(tail instanceof HTMLInputElement ? "label" : "div", { class: "a4-set-row" }, copy, tail); + } + function toggle(checked: boolean, change: (on: boolean) => void, label = ""): HTMLInputElement { + const input = el("input", { type: "checkbox", class: "a4-switch", role: "switch", checked }); + if (label) input.setAttribute("aria-label", label); + input.addEventListener("change", () => change(input.checked)); + return input; + } + function setPage(title: string, lead: string, ...body: (HTMLElement | null)[]): HTMLElement { + return el("section", { class: "a4-view a4-wide a4-set-page" }, button(tx("← 設定"), () => { settingsPage = ""; render(); }, "link"), + el("h1", { class: "a4-title", tabindex: -1 }, title), lead ? el("p", { class: "a4-lead" }, lead) : null, ...body); + } + + function notifyPage(): HTMLElement { if (notify === null && !notifyLoading && !notifyError) loadNotify(); - const head = el("h2", { class: "a4-subtitle" }, tx("通知")); + const lead = tx("アプリを閉じていても、この端末に届きます。変更はすぐ保存されます。"); if (notify === null) { - return el("section", { class: "a4-group" }, head, notifyError + return setPage(tx("通知"), lead, notifyError ? el("p", { class: "a4-muted" }, tx("通知の設定を読み込めませんでした。"), button(tx("もう一度"), loadNotify, "link")) : el("p", { class: "a4-muted" }, tx("通知の設定を読み込んでいます…"))); } @@ -1801,33 +2322,27 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { }, tx("通知をオンにできませんでした。もう一度試してください。")); }; const device = support === "unsupported" - ? el("p", { class: "a4-muted" }, tx("このブラウザは通知に対応していません。iPhone と iPad では、共有メニューの「ホーム画面に追加」から開くと使えます。")) + ? el("p", { class: "a4-set-banner", "data-off": "true" }, tx("このブラウザは通知に対応していません。iPhone と iPad では、共有メニューの「ホーム画面に追加」から開くと使えます。")) : support === "denied" - ? el("p", { class: "a4-muted" }, tx("このサイトの通知がブラウザでブロックされています。サイトの設定で通知を許可してください。")) + ? el("p", { class: "a4-set-banner", "data-off": "true" }, tx("このサイトの通知がブラウザでブロックされています。サイトの設定で通知を許可してください。")) : state.device - ? el("div", { class: "a4-actions" }, el("span", { class: "a4-meta" }, tx("この端末で受け取っています")), + ? el("div", { class: "a4-set-banner" }, el("strong", {}, tx("この端末で受け取っています")), button(tx("テスト通知を送る"), () => { void run(async () => { const sent = await port.notifications.test(); say(sent > 0 ? tx("テスト通知を送りました") : tx("届けられる端末がありませんでした。もう一度オンにしてください。")); }, tx("テスト通知を送れませんでした。")); }, "link"), button(tx("この端末では止める"), () => { void run(async () => { await port.notifications.disable(); state.device = false; say(tx("この端末の通知を止めました")); }, tx("通知を止められませんでした。")); }, "link")) - : el("div", { class: "a4-actions" }, button(tx("この端末で通知を受け取る"), () => { void enable(); }, "primary")); - const check = (key: "atStart" | "review" | "proposal" | "report", text: string) => { - const input = el("input", { type: "checkbox", checked: state.settings[key] }); - input.addEventListener("change", () => save({ [key]: input.checked })); - return el("label", { class: "a4-option" }, input, text); - }; + : el("div", { class: "a4-set-banner", "data-off": "true" }, button(tx("この端末で通知を受け取る"), () => { void enable(); }, "primary")); + const check = (key: "atStart" | "review" | "proposal" | "report", text: string) => setRow(text, "", toggle(state.settings[key], on => save({ [key]: on }))); const before = el("select", { class: "a4-input", "aria-label": tx("始まる前のリマインド") }, ...[0, 5, 10, 15, 30, 60].map(minutes => el("option", { value: String(minutes), selected: state.settings.before === minutes }, minutes ? tx("{n}分前", { n: minutes }) : tx("知らせない")))); before.addEventListener("change", () => save({ before: Number(before.value) })); - const summaryOn = el("input", { type: "checkbox", checked: state.settings.summary !== "" }); const summaryAt = el("input", { type: "time", class: "a4-input", value: state.settings.summary || "08:00", disabled: state.settings.summary === "", "aria-label": tx("朝のまとめの時刻") }); - summaryOn.addEventListener("change", () => { summaryAt.disabled = !summaryOn.checked; save({ summary: summaryOn.checked ? summaryAt.value || "08:00" : "" }); }); + const summaryOn = toggle(state.settings.summary !== "", on => { summaryAt.disabled = !on; save({ summary: on ? summaryAt.value || "08:00" : "" }); }, tx("朝のまとめ")); summaryAt.addEventListener("change", () => { if (summaryAt.value) save({ summary: summaryAt.value }); }); - return el("section", { class: "a4-group" }, head, - el("p", { class: "a4-muted" }, tx("アプリを閉じていても、この端末に届きます。")), device, - el("h3", { class: "a4-step-label" }, tx("タスク")), - el("label", { class: "a4-field-row" }, el("span", { class: "a4-meta" }, tx("始まる前")), before), - check("atStart", tx("始まる時刻ちょうどに知らせる")), - el("div", { class: "a4-field-row" }, el("label", { class: "a4-option" }, summaryOn, tx("朝のまとめ")), summaryAt), - el("h3", { class: "a4-step-label" }, tx("Agent")), + return setPage(tx("通知"), lead, device, + el("h2", { class: "a4-set-head" }, tx("タスク")), + setRow(tx("始まる前"), "", before), + check("atStart", tx("始まる時刻ちょうど")), + setRow(tx("朝のまとめ"), "", el("span", { class: "a4-set-pair" }, summaryAt, summaryOn)), + el("h2", { class: "a4-set-head" }, tx("Agent")), check("review", tx("確認を頼まれたとき")), check("proposal", tx("時間を提案されたとき")), check("report", tx("作業を終えた・止まったとき"))); @@ -1842,7 +2357,7 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { .finally(() => { connectionsLoading = false; if (!disposed) render(); }); } - function mcpSection(): HTMLElement { + function mcpPage(): HTMLElement { if (connections === null && !connectionsLoading && !connectionsError) loadConnections(); const url = el("input", { class: "a4-input a4-mono", readonly: true, value: port.mcp.url, "aria-label": tx("MCP の接続URL") }); url.addEventListener("focus", () => url.select()); @@ -1853,15 +2368,13 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { const nameOf = (agentId: string) => port.agents().find(agent => agent.id === agentId)?.name ?? agentId; const card = (connection: AllForConnection) => { const editing = linkDrafts.get(connection.clientId); - const head = el("div", { class: "a4-row-main" }, + const head = el("div", { class: "a4-conn-main" }, el("span", { class: "a4-avatar", "data-kind": "agent", "aria-hidden": "true" }, connection.clientName.slice(0, 1)), - el("div", { class: "a4-row-text" }, el("strong", {}, connection.clientName), - el("span", { class: "a4-meta" }, [connection.authorized ? tx("接続中") : tx("接続が切れています"), connection.lastUsedAt ? tx("最後に使ったのは {time}", { time: timeLabel(connection.lastUsedAt) }) : ""].filter(Boolean).join(" · "))), - editing ? null : button(connection.agentIds.length ? tx("変える") : tx("Agent を紐付ける"), () => { linkDrafts.set(connection.clientId, [...connection.agentIds]); render(); })); - const linked = el("p", { class: "a4-muted a4-conn-line" }, connection.agentIds.length - ? tx("この接続は {names} として動きます。", { names: connection.agentIds.map(nameOf).join(tx("・")) }) - : tx("まだ Agent に紐付いていません。紐付けると、この接続での作業がその Agent の担当として記録されます。")); - if (!editing) return el("li", { class: "a4-row" }, head, linked); + el("div", { class: "a4-conn-text" }, el("strong", {}, connection.clientName), + el("span", {}, [connection.authorized ? "" : tx("接続が切れています"), connection.lastUsedAt ? tx("最後に使ったのは {time}", { time: timeLabel(connection.lastUsedAt) }) : ""].filter(Boolean).join(" · ")), + el("span", {}, connection.agentIds.length ? tx("{names} として動きます", { names: connection.agentIds.map(nameOf).join(tx("・")) }) : tx("まだAgentに紐付いていません"))), + editing ? null : button(connection.agentIds.length ? tx("変える") : tx("Agentを紐付ける"), () => { linkDrafts.set(connection.clientId, [...connection.agentIds]); render(); }, "link")); + if (!editing) return el("li", { class: "a4-conn" }, head); const chips = el("div", { class: "a4-chips", role: "group", "aria-label": tx("紐付ける Agent") }, ...(agents.length === 0 ? [el("span", { class: "a4-muted" }, tx("先にギルドで Agent を登録してください。"))] : agents.map(agent => { @@ -1872,7 +2385,7 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { }); return chip; }))); - return el("li", { class: "a4-row" }, head, + return el("li", { class: "a4-conn" }, head, el("div", { class: "a4-conn-edit" }, el("p", { class: "a4-muted" }, tx("この接続で動かす Agent を選んでください。複数選ぶと、最初の Agent が既定になります。")), chips, @@ -1902,81 +2415,150 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { button(tx("やめる"), () => { revokingClient = null; render(); }, "link"))) : button(tx("この接続を切る…"), () => { revokingClient = connection.clientId; render(); }, "link"))); }; - return el("section", { class: "a4-group" }, - el("h2", { class: "a4-subtitle" }, tx("MCP 接続")), - el("p", { class: "a4-muted" }, tx("Claude や Codex などの MCP クライアントにこの URL を追加してサインインすると、下に接続が表示されます。")), + // Connections left alone for a month fold away, so the ones in use stay easy to find. + const stale = (connection: AllForConnection) => connection.lastUsedAt !== "" && Date.now() - Date.parse(connection.lastUsedAt) > 30 * 86_400_000 && !linkDrafts.has(connection.clientId); + const all = connections ?? []; + const live = all.filter(connection => !stale(connection)); + const old = all.filter(stale); + const fold = old.length === 0 ? null : el("details", { class: "a4-guild-fold", open: openFolds.has("mcp:old") }, + el("summary", {}, tx("30日以上使っていない接続"), el("span", {}, String(old.length))), el("ul", { class: "a4-conns" }, ...old.map(card))); + fold?.addEventListener("toggle", () => { if (fold.open) openFolds.add("mcp:old"); else openFolds.delete("mcp:old"); }); + return setPage(tx("MCP接続"), tx("ClaudeやCodexなどにこのURLを追加してサインインすると、下に接続が出ます。"), el("div", { class: "a4-step-form" }, url, copy), connectionsLoading && connections === null ? el("p", { class: "a4-muted" }, tx("接続を読み込んでいます…")) : connectionsError ? el("p", { class: "a4-muted" }, tx("接続を読み込めませんでした。"), button(tx("もう一度"), loadConnections, "link")) - : (connections ?? []).length === 0 ? el("p", { class: "a4-muted" }, tx("まだ接続はありません。")) - : el("ul", { class: "a4-list" }, ...(connections ?? []).map(card))); + : all.length === 0 ? el("p", { class: "a4-muted" }, tx("まだ接続はありません。")) + : el("h2", { class: "a4-set-head" }, tx("接続中"), el("span", {}, String(live.length))), + live.length ? el("ul", { class: "a4-conns" }, ...live.map(card)) : null, fold); } function guildView(): HTMLElement { - const list = quests().filter(needsDecision).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + const today = port.today(); + const ai = agentSummary(quests(), today); + const list = [...quests().filter(needsDecision).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)), ...ai.blocked]; const index = Math.max(0, list.findIndex(q => q.id === decisionId)); const current = list[index] ?? null; const agentWork = quests().filter(q => isOpen(q) && q.assignee.type === "agent"); - const byAgent = (id: string) => (["review_required", ...AGENT_STATE_ORDER] as const).flatMap(state => agentWork.filter(q => q.assignee.id === id && q.assignee.handoffState === state)); + const byAgent = (id: string) => (["working", "ready", "accepted", ...AGENT_STATE_ORDER, "review_required"] as const).flatMap(state => agentWork.filter(q => q.assignee.id === id && q.assignee.handoffState === state)) + .filter((q, i, all) => all.indexOf(q) === i); if (activity === null && !activityLoading && !activityError) loadActivity(); + if (connections === null && !connectionsLoading && !connectionsError) loadConnections(); const later = quests().filter(q => q.humanRequest?.status === "deferred" && isOpen(q)); - const agents = port.agents(); + const agents = [...port.agents()].sort((a, b) => Number(byAgent(b.id)[0]?.assignee.handoffState === "working") - Number(byAgent(a.id)[0]?.assignee.handoffState === "working")); const others = port.members().filter(member => member.id !== port.self().id); - const loads = { - self: quests().filter(q => isOpen(q) && q.assignee.type === "self"), - human: (id: string) => quests().filter(q => isOpen(q) && q.assignee.type === "human" && q.assignee.id === id), - agent: (id: string) => agentWork.filter(q => q.assignee.id === id), - }; - const scale = Math.max(1, loads.self.length, ...others.map(m => loads.human(m.id).length), ...agents.map(a => loads.agent(a.id).length)); - return el("section", { class: "a4-view" }, - el("h1", { class: "a4-title", tabindex: -1 }, tx("ギルド")), - el("p", { class: "a4-lead" }, tx("Agentや仲間と一緒に進めるための場所です。あなたが決めることだけ、ここに集まります。")), - el("section", { class: "a4-group" }, - el("h2", { class: "a4-subtitle" }, tx("あなたの判断待ち({count})", { count: list.length })), - current ? decisionCard(current, index, list.length, () => { reviseFor = null; go("guild", list[(index + 1) % list.length]!.id); }) - : el("p", { class: "a4-muted" }, tx("いま、あなたの判断を待っているものはありません。"))), - later.length === 0 ? null : el("section", { class: "a4-group" }, - el("h2", { class: "a4-subtitle" }, tx("あとで見る({count})", { count: later.length })), - el("ul", { class: "a4-list" }, ...later.map(q => el("li", { class: "a4-row" }, el("div", { class: "a4-row-main" }, rowText(q), button(tx("判断待ちに戻す"), () => answer(q, "resume"), "link")))))), - el("section", { class: "a4-group" }, - el("div", { class: "a4-group-head" }, el("h2", { class: "a4-subtitle" }, tx("人と Agent")), button(tx("+ Agent を登録"), openAgentForm)), - el("ul", { class: "a4-list" }, - memberRow("self", tx("{name}(あなた)", { name: port.self().label }), "", quests().filter(q => q.assignee.type === "self" && isWorking(q)), loadBar(loads.self, scale)), - ...others.map(member => memberRow("human", member.name, member.owner ? tx("オーナー") : tx("メンバー"), loads.human(member.id), loadBar(loads.human(member.id), scale))), - ...agents.map(agent => memberRow("agent", agent.name, agent.role ? `Agent · ${agent.role}` : "Agent", byAgent(agent.id), loadBar(loads.agent(agent.id), scale), !agent.active, agent.id)))), - el("section", { class: "a4-group" }, - el("h2", { class: "a4-subtitle" }, tx("最近の活動")), - activity === null ? el("p", { class: "a4-muted" }, activityError ? tx("活動を読み込めませんでした。") : tx("読み込んでいます…"), activityError ? button(tx("もう一度"), loadActivity, "link") : null) - : activity.length === 0 ? el("p", { class: "a4-muted" }, tx("まだ活動はありません。")) - : el("ul", { class: "a4-activity-list" }, ...activity.slice(0, 10).map(activityLine))), - extraLinks(EXTRAS.filter(item => ["network", "battle", "skills"].includes(item.id)))); - } - - /** Same fields and limits as Relay Forge's profile form; the server re-validates. */ + const mine = quests().filter(q => isOpen(q) && q.assignee.type === "self" && q.kind !== "reward" && !q.parentQuestId); + const focus = currentFocus(); + const human = (id: string) => quests().filter(q => isOpen(q) && q.assignee.type === "human" && q.assignee.id === id); + const nowOf = (quest: Quest | undefined): readonly [string, string, string] | null => quest + ? [AGENT_STATE[quest.assignee.handoffState] ?? quest.assignee.handoffState, quest.title, quest.assignee.handoffState] : null; + const activityBody = () => activity === null ? el("p", { class: "a4-muted" }, activityError ? tx("活動を読み込めませんでした。") : tx("読み込んでいます…"), activityError ? button(tx("もう一度"), loadActivity, "link") : null) + : activity.length === 0 ? el("p", { class: "a4-muted" }, tx("まだ活動はありません。")) + : el("ul", { class: "a4-activity-list" }, ...activity.slice(0, 10).map(activityLine)); + const places = () => extraLinks((game ? ["battle", "skills", "network"] : ["skills", "network"]).map(id => EXTRAS.find(item => item.id === id)!)); + const fold = el("details", { class: "a4-guild-fold", open: openFolds.has("guild:activity") }, + el("summary", {}, tx("最近の活動"), activity?.length ? el("span", {}, String(Math.min(10, activity.length))) : null), activityBody()); + fold.addEventListener("toggle", () => { if (fold.open) openFolds.add("guild:activity"); else openFolds.delete("guild:activity"); }); + return el("section", { class: "a4-view a4-wide a4-guild" }, + el("h1", { class: "a4-sr", tabindex: -1 }, tx("ギルド")), + el("div", { class: "a4-guild-main" }, + el("section", { class: "a4-guild-part" }, + el("h2", { class: "a4-guild-head" }, tx("あなたの判断"), list.length ? el("span", { class: "a4-guild-count" }, tx("{count}件", { count: list.length })) : null, + list.length > 1 ? el("span", { class: "a4-guild-at" }, `${index + 1} / ${list.length}`) : null), + current ? decisionCard(current, index, list.length, () => { reviseFor = null; go("guild", list[(index + 1) % list.length]!.id); }) + : el("p", { class: "a4-muted" }, tx("いま、判断を待っているものはありません。"))), + later.length === 0 ? null : el("details", { class: "a4-guild-fold" }, + el("summary", {}, tx("あとで見る"), el("span", {}, String(later.length))), + el("ul", { class: "a4-list" }, ...later.map(q => el("li", { class: "a4-row" }, el("div", { class: "a4-row-main" }, rowText(q), button(tx("判断待ちに戻す"), () => answer(q, "resume"), "link")))))), + el("section", { class: "a4-guild-part" }, + el("div", { class: "a4-guild-head" }, el("h2", {}, tx("人とAgent")), agents.length ? button(tx("+ Agentを登録"), openAgentForm, "link") : null), + el("ul", { class: "a4-members" }, + ...agents.map(agent => memberRow("agent", "agent:" + agent.id, agent.name, nowOf(byAgent(agent.id)[0]), byAgent(agent.id), !agent.active, agent.id)), + ...others.map(member => memberRow("human", "human:" + member.id, member.name, human(member.id)[0] ? [member.owner ? tx("オーナー") : tx("メンバー"), human(member.id)[0]!.title, "none"] : null, human(member.id))), + memberRow("self", "self", port.self().label, focus ? [isWorking(focus) ? tx("いまやっている") : tx("今の1件"), focus.title, "working"] : null, mine)), + // Without an Agent the guild has little to show; say what one is for. + agents.length ? null : el("div", { class: "a4-guild-none" }, el("p", { class: "a4-muted" }, tx("まだAgentはいません。登録すると、タスクを任せて、結果をここで確認できます。")), + (() => { const add = button(tx("Agentを登録"), openAgentForm); add.prepend(glyph(Plus)); return add; })())), + activity?.length ? fold : el("section", { class: "a4-guild-part a4-guild-quiet" }, el("h2", { class: "a4-guild-head" }, tx("最近の活動")), activityBody()), places()), + el("aside", { class: "a4-guild-side", "aria-label": tx("最近の活動") }, + el("h2", { class: "a4-guild-head" }, tx("最近の活動")), activityBody(), places())); + } + function profileView(back: HTMLElement): HTMLElement { const current = profile ?? runtime!.profile ?? {}; - const name = el("input", { class: "a4-input", value: current.displayName ?? "", maxlength: 60, autocomplete: "nickname" }); - const handle = el("input", { class: "a4-input", value: current.handle ?? "", maxlength: 21, autocomplete: "username", spellcheck: "false" }); - const bio = el("textarea", { class: "a4-input a4-textarea", maxlength: 160 }, current.bio ?? ""); - const form = el("form", { class: "a4-form" }, - el("label", { class: "a4-field-row" }, el("span", { class: "a4-meta" }, tx("表示名")), name), - el("label", { class: "a4-field-row" }, el("span", { class: "a4-meta" }, tx("ユーザー名")), handle, - el("span", { class: "a4-muted" }, tx("英小文字・数字・_ で 3〜20 文字。ほかの人があなたを探すときに使います。"))), - el("label", { class: "a4-field-row" }, el("span", { class: "a4-meta" }, tx("ひとこと")), bio), - el("div", { class: "a4-actions" }, el("button", { type: "submit", class: "a4-button a4-primary" }, tx("保存")))); + const initial = { displayName: current.displayName ?? "", handle: current.handle ?? "", bio: current.bio ?? "" }; + const name = el("input", { class: "a4-input", value: initial.displayName, maxlength: 60, autocomplete: "nickname", "aria-label": tx("表示名") }); + const handle = el("input", { class: "a4-input", value: initial.handle, maxlength: 21, autocomplete: "username", spellcheck: "false", "aria-label": tx("ユーザー名") }); + const bio = el("textarea", { class: "a4-input a4-textarea", maxlength: 160, rows: 3, "aria-label": tx("ひとこと") }, initial.bio); + const count = el("small", { class: "a4-meta a4-count" }); + const errorOf = () => el("small", { class: "a4-field-error", role: "alert", hidden: true }); + const nameError = errorOf(), handleError = errorOf(); + const save = el("button", { type: "submit", class: "a4-button a4-primary", disabled: true }, tx("保存")); + const read = () => ({ displayName: name.value.trim(), handle: handle.value.trim().replace(/^@+/, "").toLowerCase(), bio: bio.value.trim() }); + // Saving is offered only when something changed. + const paint = () => { count.textContent = `${bio.value.length} / 160`; save.disabled = JSON.stringify(read()) === JSON.stringify(initial); }; + for (const field of [name, handle, bio]) field.addEventListener("input", paint); + paint(); + + // The picture comes first, as in Relay Forge: change it, or take it off. + const version = current.hasCustomAvatar ? current.avatarVersion ?? 0 : 0; + if (version && avatarFor !== version) { + avatarFor = version; + void runtime!.profileAvatarPort.fetchProfileAvatar(version).then(blob => { avatarSrc = URL.createObjectURL(blob); render(); }, () => { avatarFor = 0; }); + } + const label = (initial.displayName || port.self().label).slice(0, 2); + const face = el("span", { class: "a4-profile-face", "aria-hidden": "true" }, version && avatarSrc ? el("img", { src: avatarSrc, alt: "" }) : label); + const fileInput = el("input", { type: "file", accept: "image/png,image/jpeg,image/webp", hidden: true, "aria-label": tx("画像を変更") }); + const avatarError = errorOf(); + const settle = (response: Record) => { const next = normalizeProfileRecord(response.profile); if (next) profile = next; avatarFor = 0; avatarSrc = ""; }; + fileInput.addEventListener("change", () => { + const file = fileInput.files?.[0]; + if (!file) return; + avatarError.hidden = true; + void run(async () => { + try { + const resized = await resizeAvatarImage(file); + settle(await runtime!.profileAvatarPort.uploadProfileAvatar(resized.blob)); + say(tx("画像を変えました")); + } catch (error) { + avatarError.textContent = error instanceof Error && error.message ? error.message : tx("画像を保存できませんでした。もう一度試してください。"); + avatarError.hidden = false; + } + }, tx("画像を保存できませんでした。もう一度試してください。")); + }); + const change = button(tx("画像を変更"), () => fileInput.click()); + const remove = version ? button(tx("外す"), () => { + void run(async () => { settle(await runtime!.profileAvatarPort.deleteProfileAvatar()); say(tx("画像を外しました")); }, tx("画像を外せませんでした。もう一度試してください。")); + }, "link") : null; + + const form = el("form", { class: "a4-form a4-profile-form", novalidate: true }, + el("div", { class: "a4-profile-avatar" }, face, change, remove, fileInput), avatarError, + el("label", { class: "a4-field-row" }, el("span", { class: "a4-step-label" }, tx("表示名")), name, nameError), + el("label", { class: "a4-field-row" }, el("span", { class: "a4-step-label" }, tx("ユーザー名")), + el("span", { class: "a4-at-field" }, el("span", { "aria-hidden": "true" }, "@"), handle), + el("small", { class: "a4-muted" }, tx("英小文字・数字・_ で 3〜20 文字。ほかの人があなたを探すときに使います。")), handleError), + el("label", { class: "a4-field-row" }, el("span", { class: "a4-step-label" }, tx("ひとこと")), bio, count), + el("div", { class: "a4-actions" }, save)); + const fail = (target: HTMLElement, field: HTMLElement, message: string) => { target.textContent = message; target.hidden = false; field.setAttribute("aria-invalid", "true"); field.focus(); }; form.addEventListener("submit", event => { event.preventDefault(); - const input = { displayName: name.value.trim(), handle: handle.value.trim().replace(/^@+/, "").toLowerCase(), bio: bio.value.trim() }; - if (input.displayName.length < 1) { say(tx("表示名を入れてください。")); name.focus(); return; } - if (!/^[a-z0-9_]{3,20}$/.test(input.handle)) { say(tx("ユーザー名は英小文字・数字・_ で 3〜20 文字にしてください。")); handle.focus(); return; } + for (const node of [nameError, handleError]) node.hidden = true; + for (const node of [name, handle]) node.removeAttribute("aria-invalid"); + const input = read(); + if (input.displayName.length < 1) return fail(nameError, name, tx("表示名を入れてください。")); + if (!/^[a-z0-9_]{3,20}$/.test(input.handle)) return fail(handleError, handle, tx("ユーザー名は英小文字・数字・_ で 3〜20 文字にしてください。")); void run(async () => { - const response = await runtime!.profilePort.updateProfile(input) as { profile?: typeof profile }; - profile = response.profile ?? { ...current, ...input }; - say(tx("プロフィールを保存しました")); + try { + const response = await runtime!.profilePort.updateProfile(input) as { profile?: typeof profile }; + profile = response.profile ?? { ...current, ...input }; + say(tx("プロフィールを保存しました")); + } catch { + // A failed save stays next to the field it is most likely about. + fail(handleError, handle, tx("保存できませんでした。ユーザー名がほかの人と重なっていないか確かめてください。")); + } }, tx("保存できませんでした。ユーザー名がほかの人と重なっていないか確かめてください。")); }); - return el("section", { class: "a4-view a4-extra" }, back, - el("h1", { class: "a4-title", tabindex: -1 }, tx("プロフィール")), form); + return el("section", { class: "a4-view a4-wide a4-set-page a4-profile" }, back, + el("h1", { class: "a4-title", tabindex: -1 }, tx("プロフィール")), el("p", { class: "a4-lead" }, tx("ほかの人に見える情報です。")), form); } /** Relay Forge modules keep their own heading; this adds only the way back. */ @@ -1988,69 +2570,88 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { el("p", { class: "a4-lead" }, tx("サインインすると使えます。デモでは表示できません。"))); } if (id === "profile") return profileView(back); - if (id === "billing") { - return el("section", { class: "a4-view a4-extra" }, back, - el("h1", { class: "a4-title", tabindex: -1 }, tx("プラン")), billingUi.render(), analyticsUi.render()); - } - const screen = forge!.render(id as ForgeScreen); - // Relay Forge titles are English destination names; AFA names the screen in the reader's language. - screen.querySelector(".rf-screen-title")?.replaceChildren(EXTRAS.find(item => item.id === id)!.label); - return el("section", { class: "a4-view a4-extra" }, back, screen); + if (id === "billing") { billingUi.enter(); return billingPage.render(back, billingUi.retry); } + // Every way in loads the screen's data, a reload included; both calls only load once. + if (FORGE.includes(id)) forge!.enter(id as ForgeScreen); + const leads: Partial> = { + network: tx("止まっているタスクと、その理由が分かります。"), battle: tx("タスクを終えるとMPがたまり、コマンドで戦えます。"), + connections: tx("外のサービスとの同期を管理します。"), skills: tx("Agentがタスクを進めるときに使える機能です。"), + }; + const content = forge!.render(id as ForgeScreen); + // The same frame as a settings child page: back, title, one line; Relay Forge's own question and counters stay out. + // A child page (a root on a phone) brings its own back link and title. + return el("section", { class: "a4-view a4-wide a4-set-page a4-extra a4-extra-page", "data-extra": id }, + ...(content.dataset.ownFrame ? [] : [back, el("h1", { class: "a4-title", tabindex: -1 }, EXTRAS.find(item => item.id === id)!.label), + leads[id] ? el("p", { class: "a4-lead" }, leads[id]) : null]), + content); } function extraLinks(items: readonly (typeof EXTRAS)[number][]): HTMLElement { return el("nav", { class: "a4-group" }, el("ul", { class: "a4-list a4-extras" }, ...items.map(item => { const open = el("a", { class: "a4-row-title", href: "#" + item.id }, item.label); open.addEventListener("click", event => { event.preventDefault(); go(item.id); }); - return el("li", { class: "a4-row" }, el("div", { class: "a4-row-main" }, el("div", { class: "a4-row-text" }, open, el("span", { class: "a4-meta" }, item.hint)))); + return el("li", { class: "a4-row" }, el("div", { class: "a4-row-main" }, el("div", { class: "a4-row-text" }, open, el("span", { class: "a4-meta" }, item.hint)), glyph(ChevronRight))); }))); } function settingsView(): HTMLElement { - const cheerToggle = el("input", { type: "checkbox", checked: cheer }); - cheerToggle.addEventListener("change", () => { cheer = cheerToggle.checked; store.set("a4-cheer", cheer ? null : "off"); }); - const gameToggle = el("input", { type: "checkbox", checked: game }); - gameToggle.addEventListener("change", () => { game = gameToggle.checked; store.set("a4-game", game ? null : "off"); say(game ? tx("ゲーム要素を表示します") : tx("ゲーム要素を隠しました")); }); + if (settingsPage === "notify") return notifyPage(); + if (settingsPage === "mcp") return mcpPage(); + if (settingsPage === "time" && runtime) return setPage(tx("時間の記録"), "", embed(workUi.settings())); + if (settingsPage === "analytics" && runtime) return setPage(tx("任意の利用計測"), "", el("div", { class: "a4-extra" }, analyticsUi.render())); + if (notify === null && !notifyLoading && !notifyError) loadNotify(); + if (connections === null && !connectionsLoading && !connectionsError) loadConnections(); + const open = (page: typeof settingsPage) => () => { settingsPage = page; if (page === "analytics") analyticsUi.enter(); render(); main.querySelector("h1")?.focus(); }; const theme = document.documentElement.getAttribute("data-theme") ?? "system"; - const themes = el("fieldset", { class: "a4-field" }, el("legend", {}, tx("表示テーマ")), - ...[["system", tx("端末に合わせる")], ["light", tx("ライト")], ["dark", tx("ダーク")]].map(([value, label]) => { - const input = el("input", { type: "radio", name: "a4-theme", value, checked: theme === value }); - input.addEventListener("change", () => { - if (value === "system") document.documentElement.removeAttribute("data-theme"); else document.documentElement.setAttribute("data-theme", value!); - store.set("qf-relay-forge-theme", value === "system" ? null : value!); - paintThemeColor(); - }); - return el("label", { class: "a4-option" }, input, label!); - })); + const themes = modeSwitch([["system", tx("端末")], ["light", tx("ライト")], ["dark", tx("ダーク")]] as const, theme as "system" | "light" | "dark", value => { + if (value === "system") document.documentElement.removeAttribute("data-theme"); else document.documentElement.setAttribute("data-theme", value); + store.set("qf-relay-forge-theme", value === "system" ? null : value); + paintThemeColor(); + }); + themes.setAttribute("aria-label", tx("テーマ")); // Copy is resolved once at load, so a language change reloads the page. const language = el("select", { class: "a4-input", "aria-label": tx("言語") }, ...SUPPORTED_LOCALES.map(code => el("option", { value: code, selected: code === LOCALE, lang: code }, LOCALE_METADATA[code].label))); language.addEventListener("change", () => { setLocale(language.value); location.reload(); }); - return el("section", { class: "a4-view" }, - el("h1", { class: "a4-title", tabindex: -1 }, tx("設定")), - el("label", { class: "a4-field-row" }, el("span", { class: "a4-meta" }, tx("言語")), language), - el("label", { class: "a4-option" }, cheerToggle, tx("完了したら紙吹雪でお祝いする")), - el("label", { class: "a4-option" }, gameToggle, tx("ゲーム要素(レベル・XP)を表示する")), - themes, - el("p", { class: "a4-muted" }, tx("キーボード:N か / で書き出し、J と K で行を移動、X で完了、S で今のタスクを始める")), - notifySection(), - extraLinks(EXTRAS.filter(item => item.id === "connections")), - mcpSection(), - runtime ? el("section", { class: "a4-group" }, el("h2", { class: "a4-subtitle" }, tx("時間の記録")), embed(workUi.settings())) : null, - extraLinks(EXTRAS.filter(item => item.id === "profile" || item.id === "billing")), - el("p", {}, el("a", { class: "a4-link", href: RELAY_FORGE_URL }, tx("Relay Forge(詳しい画面)を開く"))), - port.signOut ? button(tx("サインアウト"), () => { void port.signOut?.(); }, "link") : null); - } - - /** The running task follows the person to every other screen. */ + const group = (title: string, ...rows: (HTMLElement | null)[]) => el("section", { class: "a4-set-group" }, el("h2", { class: "a4-set-head" }, title), ...rows); + const signOut = port.signOut ? el("button", { type: "button", class: "a4-set-row a4-set-out" }, el("span", { class: "a4-set-copy" }, el("strong", {}, tx("サインアウト")))) : null; + signOut?.addEventListener("click", () => { void port.signOut?.(); }); + return el("section", { class: "a4-view a4-wide a4-settings" }, + el("h1", { class: "a4-sr", tabindex: -1 }, tx("設定")), + el("div", { class: "a4-set-col" }, + group(tx("表示"), + setRow(tx("テーマ"), "", themes), + setRow(tx("言語"), "", language), + setRow(tx("完了のお祝い"), tx("完了したら紙吹雪を出します"), toggle(cheer, on => { cheer = on; store.set("a4-cheer", on ? null : "off"); })), + setRow(tx("レベル・XP"), tx("ゲーム要素を表示します"), toggle(game, on => { game = on; store.set("a4-game", on ? null : "off"); say(on ? tx("ゲーム要素を表示します") : tx("ゲーム要素を隠しました")); }))), + group(tx("通知と連携"), + setRow(tx("通知"), tx("リマインド・朝のまとめ・Agentからの知らせ"), notify ? notify.device ? tx("オン") : tx("オフ") : null, open("notify")), + setRow(tx("外部サービス"), tx("カレンダーや時間の記録をつなぐ"), null, () => go("connections")), + setRow(tx("MCP接続"), tx("Claude・CodexなどのAIクライアント"), connections ? tx("{count}件", { count: connections.length }) : null, open("mcp")))), + el("div", { class: "a4-set-col" }, + group(tx("アカウントとデータ"), + runtime ? setRow(tx("時間の記録"), tx("記録元・タイムゾーン・書き出し"), null, open("time")) : null, + setRow(tx("プロフィール"), tx("表示名・ユーザー名・ひとこと"), port.self().label, () => go("profile")), + setRow(tx("利用プラン"), tx("プランの確認と管理"), null, () => go("billing")), + runtime ? setRow(tx("任意の利用計測"), tx("同意の確認と撤回"), null, open("analytics")) : null), + group(tx("その他"), + setRow(tx("キーボード操作"), tx("N か / で書き出し、J と K で行を移動、X で完了、S で今のタスクを始める"), null), + setRow("Guilduo / Relay Forge", tx("詳しい画面を開く"), null, RELAY_FORGE_URL), + signOut))); + } + function dock(): HTMLElement | null { const running = view === "now" ? undefined : quests().find(isWorking); if (!running) return null; const open = el("button", { type: "button", class: "a4-row-title" }, running.title); open.addEventListener("click", () => { focusId = running.id; go("now"); }); - return el("aside", { class: "a4-dock", "aria-label": tx("作業中") }, timer(running, true), - el("div", { class: "a4-row-text" }, el("span", { class: "a4-meta" }, tx("作業中")), open), - button(tx("一時停止"), () => pause(running)), button(tx("完了"), () => complete(running))); + const stop = el("button", { type: "button", class: "a4-icon-button", "aria-label": tx("一時停止") }, glyph(Pause)); + stop.addEventListener("click", () => pause(running)); + const node = runTimer(running); + if (running.estimatedMinutes > 0) node.querySelector("small")!.textContent = `/ ${minutesLabel(running.estimatedMinutes)}`; + return el("aside", { class: "a4-dock", "aria-label": tx("作業中") }, node, + el("div", { class: "a4-row-text" }, open, el("span", { class: "a4-meta" }, tx("作業中"))), + stop, (() => { const done = button(tx("完了"), () => complete(running)); done.prepend(glyph(Check)); return done; })()); } /* ------------------------------ shell ------------------------------ */ @@ -2063,9 +2664,9 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { } function leave(next: View, questId?: string | null): void { - if (questId && !quests().some(q => q.id === questId && needsDecision(q))) { say(tx("見つかりませんでした。")); return; } + if (questId && !quests().some(q => q.id === questId && (needsDecision(q) || q.assignee.handoffState === "blocked"))) { say(tx("見つかりませんでした。")); return; } if (isExtra(next) && next !== view) { - if (next === "billing") { billingUi.enter(); analyticsUi.enter(); } + if (next === "billing") { billingPage.reset(); billingUi.enter(); } else if (FORGE.includes(next)) forge?.enter(next as ForgeScreen); void workUi.load(); } @@ -2077,6 +2678,7 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { if (questId) url.searchParams.set("quest", questId); else url.searchParams.delete("quest"); } view = next; + settingsPage = ""; url.hash = next; if (url.href !== location.href) history.pushState(null, "", url); viewUrl = url.href; @@ -2098,7 +2700,7 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { const focusTaskId = active instanceof HTMLElement ? active.dataset.questId : undefined; const keepFocus = active instanceof HTMLElement && main.contains(active) && active.matches("input") ? null : active instanceof HTMLElement && main.contains(active) ? active.textContent : null; replaceChildren(nav, ...VIEWS.map(item => { - const waiting = item.id === "guild" ? quests().filter(needsDecision).length : 0; + const waiting = item.id === "guild" ? quests().filter(needsDecision).length + agentSummary(quests(), port.today()).blocked.length : 0; const tab = el("a", { class: "a4-tab", href: `#${item.id}`, "aria-current": view === item.id || (item.id === (["network", "battle", "skills"].includes(view) ? "guild" : "settings") && isExtra(view)) ? "page" : null, "data-view": item.id }, glyph(({ now: Sun, plan: CalendarDays, all: ListTodo, guild: Users, settings: Settings } as Record)[item.id]!), el("span", {}, item.label), waiting > 0 ? el("span", { class: "a4-badge", "aria-label": tx("判断待ち {count} 件", { count: waiting }) }, String(waiting)) : null); tab.addEventListener("click", event => { event.preventDefault(); go(item.id); }); @@ -2107,7 +2709,10 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { root.querySelector(".a4-app")?.setAttribute("data-busy", String(busy)); main.setAttribute("data-view", view); document.title = `${[...VIEWS, ...EXTRAS].find(item => item.id === view)?.label} — Guilduo`; - breadcrumb.textContent = [...VIEWS, ...EXTRAS].find(item => item.id === view)?.label ?? ""; + replaceChildren(breadcrumb, el("span", {}, tx("マイワークスペース")), glyph(ChevronRight), ...(view === "settings" && settingsPage + ? [el("span", {}, tx("設定")), " › ", el("strong", {}, { notify: tx("通知"), mcp: tx("MCP接続"), time: tx("時間の記録"), analytics: tx("任意の利用計測") }[settingsPage])] + : isExtra(view) ? [el("span", {}, ["network", "battle", "skills"].includes(view) ? tx("ギルド") : tx("設定")), " › ", el("strong", {}, EXTRAS.find(item => item.id === view)!.label)] + : [el("strong", {}, [...VIEWS, ...EXTRAS].find(item => item.id === view)?.label ?? "")])); paintThemeColor(); workUi.clearCalendar(); replaceChildren(main, view === "now" ? nowView() : view === "plan" ? planView() : view === "all" ? allView() : view === "guild" ? guildView() : isExtra(view) ? extraView(view) : settingsView()); @@ -2127,7 +2732,7 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { } } - const tick = window.setInterval(() => { for (const node of root.querySelectorAll void }>(".a4-ring")) node.a4Paint?.(); paintProgress(); }, 1000); + const tick = window.setInterval(() => { for (const node of root.querySelectorAll void }>(".a4-ring, .a4-run")) node.a4Paint?.(); paintProgress(); }, 1000); const onFocus = () => { if (port.refresh && !busy && pending.size === 0 && !sheet && !captureDialog && !document.querySelector(".a4-image-viewer[open]")) void port.refresh().then(() => { clearDecisionGalleries(); refreshError = false; render(); }, () => { refreshError = true; say(tx("再取得できませんでした。表示中の内容を残しています。")); render(); }); }; const onHide = () => { if (document.visibilityState === "hidden") flushPending(); else onFocus(); }; // Agents move work while the page sits open; pick that up unless someone is typing. @@ -2137,7 +2742,7 @@ export function mountAllFor(root: HTMLElement, port: AllForPort): () => void { const onHash = () => { const next = viewFromHash(); const requested = next === "guild" ? new URL(location.href).searchParams.get("quest") : undefined; - const questId = requested === null || (requested && quests().some(q => q.id === requested && needsDecision(q))) ? requested : undefined; + const questId = requested === null || (requested && quests().some(q => q.id === requested && (needsDecision(q) || q.assignee.handoffState === "blocked"))) ? requested : undefined; if (next !== view || (questId !== undefined && questId !== decisionId)) { if (sheet || workUi.dirty()) history.replaceState(null, "", viewUrl); go(next, questId); diff --git a/interaction-lab/all-for-adhd/battle-view.ts b/interaction-lab/all-for-adhd/battle-view.ts new file mode 100644 index 0000000..8d82dd2 --- /dev/null +++ b/interaction-lab/all-for-adhd/battle-view.ts @@ -0,0 +1,115 @@ +/** + * Battle in All for ADHD's layout (Penpot page 17): one card for the fight, + * equal command buttons, a preview before anything is written, and the MP + * sources and the record folded after three and one. Rules and requests stay + * Relay Forge's own (preview, then execute; a stale turn needs a refresh). + */ + +import { createElement, ChevronDown } from "lucide"; +import { el } from "../relay-forge/primitives/dom.ts"; +import { + battleBusy, cancelBattlePreview, executeCommand, previewCommand, refreshBattle, + type BattleCallbacks, type BattleModel, type BattleState, +} from "../relay-forge/screens/battle-model.ts"; +import { tx } from "./copy.ts"; + +export interface BattleViewHost { + readonly rerender: () => void; + readonly announce: (message: string) => void; + readonly openQuest: (id: string) => void; +} + +const chevron = () => createElement(ChevronDown, { width: 16, height: 16, "stroke-width": 1.75, "aria-hidden": "true" }); +const WEAK: Record string> = { todo: () => tx("1回きり"), daily: () => tx("毎日"), habit: () => tx("習慣") }; + +function bar(label: string, value: number, max: number, tone: string): HTMLElement { + const percent = max > 0 ? Math.max(0, Math.min(100, value / max * 100)) : 0; + return el("div", { class: "a4-bt-bar", "data-tone": tone }, + el("span", { class: "a4-bt-bar-head" }, el("span", {}, label), el("span", {}, `${value} / ${max}`)), + el("span", { class: "a4-bt-track", role: "meter", "aria-label": label, "aria-valuemin": "0", "aria-valuemax": String(max), "aria-valuenow": String(value) }, + el("span", { class: "a4-bt-fill", style: `width:${percent}%` }))); +} + +function fold(summary: string, items: readonly HTMLElement[]): HTMLElement | null { + if (!items.length) return null; + return el("details", { class: "a4-bill-fold a4-bt-fold" }, el("summary", {}, summary, chevron()), el("ul", { class: "a4-bt-rows" }, ...items)); +} + +export function battleView(model: BattleModel, state: BattleState, callbacks: BattleCallbacks, host: BattleViewHost, empty: boolean): HTMLElement { + const busy = battleBusy(state); + const refresh = el("button", { type: "button", class: "a4-link", disabled: busy }, tx("更新")); + refresh.addEventListener("click", () => { void refreshBattle(state, callbacks, host.rerender); }); + if (empty) { + return el("div", { class: "a4-bt" }, el("div", { class: "a4-bill-card a4-bt-card" }, + el("div", { class: "a4-bt-head" }, el("p", { class: "a4-meta" }, state.phase === "refreshing" ? tx("読み込んでいます") : tx("戦闘の状態を読み込めませんでした。")), refresh))); + } + // Boss names come in the reader's language; server log lines follow suit. + const boss = model.bossLabel || model.bossName; + const words = (text: string) => text.replace(/(\d+)\s*damage/gi, (_m, n: string) => tx("{count}ダメージ", { count: n })).split(model.bossName).join(boss); + + const card = el("section", { class: "a4-bill-card a4-bt-card", "aria-label": boss }, + el("div", { class: "a4-bt-head" }, + el("strong", { class: "a4-bt-boss" }, boss), el("span", { class: "a4-bill-chip" }, tx("ターン {turn}", { turn: model.turn })), refresh), + model.weakKind ? el("p", { class: "a4-meta" }, tx("弱点は{kind}のタスク", { kind: WEAK[model.weakKind]?.() ?? model.weakKind })) : null, + bar(tx("ボスのHP"), model.bossHp, model.bossMaxHp, "boss"), + bar(tx("自分のHP"), model.playerHp, model.playerMaxHp, "self"), + bar("MP", model.mp, model.maxMp, "mp"), + model.statuses.length ? el("div", { class: "a4-bt-statuses" }, ...model.statuses.map(status => + el("span", { class: "a4-net-chip", "data-tone": status.tone === "review" || status.tone === "blocked" ? "review" : "done" }, `${status.label} ${status.value}`))) : null, + model.ended ? el("p", { class: "a4-meta" }, tx("この戦闘は終わりました。")) : null); + + // Equal buttons; a command short of MP says how much is missing. + const hand = el("div", { class: "a4-bt-hand" }, ...model.commands.map(command => { + const short = command.mpCost - model.mp; + const node = el("button", { type: "button", class: "a4-bt-cmd", disabled: busy || !command.enabled || state.needsRefresh, "aria-pressed": String(state.pendingCommand === command.id), "data-short": String(short > 0) }, + el("strong", {}, command.label), + el("span", { class: "a4-meta" }, `MP ${command.mpCost}`, short > 0 ? el("span", { class: "a4-bt-short" }, tx("あと {count}", { count: short })) : null)); + if (!command.enabled && command.blockedReason) node.title = command.blockedReason; + node.addEventListener("click", () => { void previewCommand(model, state, callbacks, command.id, host.rerender); }); + return node; + })); + + let preview: HTMLElement | null = null; + if (state.pendingCommand) { + const label = model.commands.find(command => command.id === state.pendingCommand)?.label ?? state.pendingCommand; + const before = state.preview?.before; + const after = state.preview?.after; + const change = (name: string, from: number, to: number, signed = true) => el("div", { class: "a4-bt-change" }, el("span", {}, name), + el("strong", {}, from === to ? String(from) : `${from} → ${to}`), + el("span", { class: "a4-meta" }, from === to ? tx("変化なし") : signed ? `${to - from > 0 ? "+" : "−"}${Math.abs(to - from)}` : "")); + const again = el("button", { type: "button", class: "a4-button", disabled: busy }, tx("選び直す")); + again.addEventListener("click", () => { if (cancelBattlePreview(state)) host.rerender(); }); + const run = el("button", { type: "button", class: "a4-button a4-primary", disabled: busy || state.phase !== "previewed" }, tx("この手を実行")); + run.addEventListener("click", () => { void executeCommand(model, state, callbacks, host.rerender, host.announce); }); + preview = el("section", { class: "a4-bill-card a4-bt-preview", "aria-live": "polite" }, + el("h3", { class: "a4-bt-sub" }, tx("{command} を実行すると", { command: label })), + before && after ? el("div", {}, change(tx("ボスのHP"), before.bossHp, after.bossHp), change(tx("自分のHP"), before.playerHp, after.playerHp), + change("MP", before.mp, after.mp), change(tx("ターン"), before.turn, after.turn, false)) + : el("p", { class: "a4-meta" }, state.phase === "previewing" ? tx("結果を計算しています") : ""), + el("p", { class: "a4-meta" }, state.phase === "submitting" ? tx("実行しています") : tx("まだ実行していません。")), + el("div", { class: "a4-actions" }, again, run)); + } + const failure = state.failure ? el("div", { class: "a4-bill-alert", role: "alert" }, el("span", {}, state.failure.code === "battle_mp_insufficient" ? tx("MPが足りません。タスクを終えてMPをためてください。") : state.failure.message), + state.needsRefresh ? (() => { const again = el("button", { type: "button", class: "a4-link", disabled: busy }, tx("最新にする")); again.addEventListener("click", () => { void refreshBattle(state, callbacks, host.rerender); }); return again; })() : null) : null; + + const source = (item: BattleModel["mpSources"][number]) => { + const open = el("button", { type: "button", class: "a4-bt-row" }, el("span", {}, item.title), el("span", { class: "a4-bt-mp" }, `MP +${item.mpGain}`)); + open.addEventListener("click", () => host.openQuest(item.id)); + return el("li", {}, open); + }; + const record = (event: BattleModel["timeline"][number]) => { + const at = Date.parse(event.at); + return el("li", { class: "a4-bt-row" }, el("span", {}, words(event.text)), + el("span", { class: "a4-meta" }, Number.isFinite(at) ? new Date(at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : "")); + }; + const sources = model.mpSources; + const timeline = model.timeline; + return el("div", { class: "a4-bt" }, card, + el("h2", { class: "a4-bt-sub" }, tx("次の一手")), hand, failure, preview, + sources.length ? el("h2", { class: "a4-bt-sub" }, tx("タスクを終えてMPをためる")) : null, + sources.length ? el("ul", { class: "a4-bt-rows" }, ...sources.slice(0, 3).map(source)) : null, + fold(tx("ほかのタスク {count}件", { count: sources.length - 3 }), sources.slice(3).map(source)), + timeline.length ? el("h2", { class: "a4-bt-sub" }, tx("これまでの記録")) : null, + timeline.length ? el("ul", { class: "a4-bt-rows" }, record(timeline[0]!)) : null, + fold(tx("記録をすべて見る {count}件", { count: timeline.length - 1 }), timeline.slice(1).map(record))); +} diff --git a/interaction-lab/all-for-adhd/billing-view.ts b/interaction-lab/all-for-adhd/billing-view.ts new file mode 100644 index 0000000..87f0a84 --- /dev/null +++ b/interaction-lab/all-for-adhd/billing-view.ts @@ -0,0 +1,251 @@ +/** + * Plan and Credits in All for ADHD's own layout (Penpot page 17): the plan, + * the Agents Free uses, going Pro, and Credits, with Jev and the history as + * child pages. State and every guarded request stay in Relay Forge's billing UI. + */ + +import { createElement, ChevronRight, ChevronDown, type IconNode } from "lucide"; +import { getLocale } from "../../i18n.ts"; +import { el } from "../relay-forge/primitives/dom.ts"; +import { billingText as copy } from "../relay-forge/billing-copy.ts"; +import { formatBillingMoney, type BillingPrice } from "../relay-forge/screens/settings-model.ts"; +import type { createBillingUi } from "../relay-forge/screens/billing.ts"; +import { tx } from "./copy.ts"; + +type BillingApi = ReturnType["api"]; +type Page = "plan" | "jev" | "history"; + +const glyph = (icon: IconNode) => createElement(icon, { width: 18, height: 18, "stroke-width": 1.75, "aria-hidden": "true" }); +const number = (value: number) => value.toLocaleString(getLocale()); +const day = (value: string | null | undefined) => { + const parsed = Date.parse(value ?? ""); + return Number.isFinite(parsed) ? new Date(parsed).toLocaleDateString(getLocale(), { dateStyle: "long" }) : "—"; +}; +const price = (value: BillingPrice) => formatBillingMoney(value.amount, value.currency, getLocale()); + +function action(label: string, key: string, run: () => void, disabled: boolean, kind: "primary" | "secondary" | "link" = "secondary"): HTMLButtonElement { + const node = el("button", { type: "button", class: kind === "primary" ? "a4-button a4-primary" : kind === "link" ? "a4-link" : "a4-button", disabled, "data-billing-action": key, "data-billing-focus": key }, label); + node.addEventListener("click", run); + return node; +} +function check(label: string, checked: boolean, disabled: boolean, focus: string, change: (on: boolean) => void, muted = false): HTMLElement { + const input = el("input", { type: "checkbox", checked, disabled, "data-billing-focus": focus }); + input.addEventListener("change", () => change(input.checked)); + return el("label", { class: "a4-bill-check", "data-muted": String(muted) }, input, el("span", {}, label)); +} +function fold(summary: string, focus: string, ...body: (HTMLElement | null)[]): HTMLElement { + return el("details", { class: "a4-bill-fold" }, el("summary", { "data-billing-focus": focus }, summary, glyph(ChevronDown)), ...body); +} +function row(label: string, hint: string, open: () => void): HTMLElement { + const node = el("button", { type: "button", class: "a4-set-row" }, el("span", { class: "a4-set-copy" }, el("strong", {}, label), hint ? el("small", {}, hint) : null), glyph(ChevronRight)); + node.addEventListener("click", open); + return node; +} + +export function billingView(api: BillingApi, rerender: () => void, say: (message: string) => void) { + let page: Page = "plan"; + let interval: "month" | "year" = "year"; + const go = (next: Page) => { + page = next; + if (next === "history" && api.ledger() === null && !api.ledgerError()) void api.loadLedger(); + rerender(); + document.querySelector(".a4-bill-page h1")?.focus(); + }; + + function notices(): HTMLElement[] { + const out: HTMLElement[] = []; + const busy = api.busy(); + if (busy === "load" && !api.status()) out.push(el("p", { class: "a4-meta", role: "status" }, tx("読み込んでいます"))); + const error = api.error(); + if (error) { + out.push(el("div", { class: "a4-bill-alert", role: "alert" }, el("span", {}, copy(error)), + // Retry is offered only after a failure. + page === "plan" ? action(tx("もう一度"), "retry", () => retry(), busy !== "", "link") : null)); + } + const message = api.message(); + if (message) out.push(el("p", { class: "a4-meta", role: "status" }, copy(message))); + return out; + } + let retry = () => {}; + + + function planCard(): HTMLElement { + const status = api.status()!; + const pro = status.plan === "pro"; + const credits = status.credits; + const card = el("section", { class: "a4-bill-card" }, + el("div", { class: "a4-bill-plan" }, el("strong", {}, pro ? "Pro" : "Free"), + el("span", { class: "a4-bill-chip" }, status.agentLimit === null ? tx("Agent 無制限") : tx("使えるAgent {count}体まで", { count: status.agentLimit }))), + el("p", { class: "a4-bill-credits" }, tx("残りのCredits"), el("strong", {}, credits.available === null ? "—" : number(credits.available))), + // Reserved and pending adjustment only appear when they hold something. + credits.reserved ? el("p", { class: "a4-meta" }, `${tx("予約中")} ${number(credits.reserved)}`) : null, + credits.debt ? el("p", { class: "a4-meta" }, `${tx("調整待ち")} ${number(credits.debt)}`) : null); + if (pro) { + card.append(el("p", { class: "a4-meta" }, status.cancelAtPeriodEnd ? copy("cancelScheduled") : `${tx("次回の更新")} ${day(status.nextRenewal ?? status.periodEnd)}`)); + if (status.enabled && status.configured) card.append(action(copy("portal"), "portal", () => { void api.portal(); }, !api.canWrite())); + } else card.append(el("p", { class: "a4-meta" }, tx("通常のMCP・タスク・受け渡しの操作は0 Credits。自動で追加請求はされません。"))); + return card; + } + + function freeAgents(): HTMLElement | null { + const status = api.status()!; + const agents = api.agents(); + if (!status.enabled || status.plan !== "free" || agents.length <= 2) return null; + const ids = api.selectedIds(); + const saved = status.selectedAgentIds; + const dirty = ids.length !== saved.length || ids.some(id => !saved.includes(id)); + return el("section", { class: "a4-bill-section" }, + el("h2", { class: "a4-subtitle a4-bill-head" }, tx("Freeで使うAgent"), el("span", { class: "a4-meta" }, `${ids.length} / 2`)), + el("p", { class: "a4-meta" }, tx("登録している{count}体のうち2体を選びます。選ばなかったAgentも消えません。", { count: agents.length })), + el("div", { class: "a4-bill-list" }, ...agents.map((agent, index) => { + const on = ids.includes(agent.agentId); + const full = !on && ids.length >= 2; + return check(agent.displayName, on, api.busy() !== "" || full, `free-${index}`, () => api.toggleFree(agent.agentId), full); + })), + dirty ? action(tx("保存"), "saveAgents", () => { void api.saveAgents(); }, !api.canWrite() || !api.validFree(), "primary") : null); + } + + function terms(): HTMLElement { + const pricing = api.status()!.pricing; + const discounts = [["launchDiscount", pricing.launchDiscount], ["referralDiscount", pricing.referralDiscount]] as const; + return fold(tx("返金・紹介を含む販売条件をすべて見る"), "terms", + el("ul", { class: "a4-bill-terms" }, + ...[copy("creditsCadence"), `${copy("creditExpiryFromGrant")}: ${number(pricing.creditLifetimeDays!)} ${copy("days")}`, + `${copy("jev")}: ${number(pricing.decisionCredits!)} Credits`, copy("refundPolicy"), + `${copy("referralReward")}: ${number(pricing.referralCredits!)}`, copy("referralRules"), + pricing.automaticTax ? copy("autoTax") : "", + ...discounts.flatMap(([key, discount]) => discount ? [`${copy(key)}: ${discount.percentOff !== null ? `${number(discount.percentOff)}%` : formatBillingMoney(discount.amountOff!, discount.currency, getLocale())} · ${copy("redeemUntil")}: ${discount.redeemBy ? day(discount.redeemBy) : copy("noEnd")}`] : []), + pricing.launchDiscount || pricing.referralDiscount ? copy("discountPriority") : copy("noDiscount"), + ].filter(Boolean).map(line => el("li", {}, line)))); + } + + function goPro(): HTMLElement | null { + const status = api.status()!; + if (status.plan === "pro") return null; + const pricing = status.pricing; + const section = el("section", { class: "a4-bill-section", "data-billing-pricing": "true" }, el("h2", { class: "a4-subtitle" }, tx("Proにする"))); + if (!pricing.complete) { section.append(el("p", { class: "a4-meta" }, copy("salesPending"))); return section; } + const attempt = api.purchaseAttempt(); + if (attempt) interval = attempt.interval; + const choices = el("div", { class: "a4-bill-choices", role: "radiogroup", "aria-label": tx("支払いの周期") }); + for (const option of [pricing.month!, pricing.year!]) { + const key = option.interval === "month" ? "month" : "year"; + const card = el("button", { type: "button", class: "a4-bill-choice", role: "radio", "aria-checked": String(interval === key), disabled: !!attempt, "data-billing-focus": `interval-${key}` }, + el("span", { class: "a4-meta" }, copy(key === "month" ? "monthly" : "yearly")), el("strong", {}, price(option)), + el("span", { class: "a4-meta" }, tx("毎月 {count} Credits", { count: number(option.creditsPerMonth) }))); + card.addEventListener("click", () => { interval = key; api.setPurchaseConsent(false); }); + choices.append(card); + } + section.append(choices, + el("ul", { class: "a4-bill-points" }, ...[copy("autoRenew"), copy("cancelPolicy"), + tx("年払いも毎月の付与で、各付与日から{days}日有効です。", { days: number(pricing.creditLifetimeDays!) }), + pricing.taxBehavior === "inclusive" ? tx("税込です。") : tx("税別です。")].map(line => el("li", {}, line))), + terms()); + if (status.mode === "test") section.append(el("p", { class: "a4-meta" }, copy("testMode"))); + if (!api.purchasable()) { section.append(el("p", { class: "a4-meta" }, tx("今は購入できません。"))); return section; } + const referral = el("input", { class: "a4-input", type: "text", value: api.referralCode(), maxlength: 200, autocomplete: "off", "aria-label": copy("referralInput"), "data-billing-focus": "referral", disabled: api.busy() !== "" || !!attempt || status.referral.attributed }); + const consent = check(copy("checkoutConsent"), api.purchaseConsent(), api.busy() !== "", "purchase-consent", on => api.setPurchaseConsent(on)); + const buy = attempt + ? action(copy("checkoutRetry"), "checkoutRetry", () => { void api.checkout(attempt.interval); }, !api.canWrite() || !api.purchaseConsent(), "primary") + : action(interval === "month" ? tx("月払いでProにする") : tx("年払いでProにする"), interval === "month" ? "monthly" : "yearly", () => { void api.checkout(interval); }, !api.canWrite() || !api.purchaseConsent(), "primary"); + referral.addEventListener("input", () => { + api.setReferral(referral.value); + // A changed code clears the consent on screen at once. + consent.querySelector("input")!.checked = false; buy.disabled = true; + }); + section.append(...[ + status.referral.attributed ? el("p", { class: "a4-meta" }, copy("referralAttributed")) : fold(tx("紹介コードを入力"), "referral-fold", referral), + attempt ? el("p", { class: "a4-meta", role: "status" }, copy("checkoutUncertain")) : null, + consent, buy, el("p", { class: "a4-meta" }, tx("このあとStripeの支払い画面に進みます。"))].filter(node => node !== null)); + return section; + } + + function creditsSection(): HTMLElement { + const status = api.status()!; + const code = status.referral.code; + let codeRow: HTMLElement | null = null; + if (code) { + const copyButton = action(tx("コピー"), "copy-referral", () => { + void navigator.clipboard?.writeText(code).then(() => say(tx("コピーしました")), () => say(tx("コピーできませんでした"))); + }, false, "link"); + codeRow = el("div", { class: "a4-set-row" }, el("span", { class: "a4-set-copy" }, el("strong", {}, tx("自分の紹介コード")), el("small", {}, code)), copyButton); + } + const credits = status.pricing.decisionCredits; + return el("section", { class: "a4-bill-section" }, el("h2", { class: "a4-subtitle" }, "Credits"), + el("div", { class: "a4-set-group" }, + row(tx("Creditsの履歴"), "", () => go("history")), + row(copy("jev"), credits === null ? tx("担当Agentと次の行動を提案します。") : tx("担当Agentと次の行動を提案します。{count} Credits", { count: number(credits) }), () => go("jev")), + codeRow, + status.referral.rewarded ? el("p", { class: "a4-meta" }, copy("referralRewarded")) : null)); + } + + function jevPage(): (HTMLElement | null)[] { + const locked = api.busy() !== "" || !!api.decisionId(); + const quests = api.quests(); + const candidates = api.candidates(); + const select = el("select", { class: "a4-input", disabled: locked, "data-billing-focus": "quest", "aria-label": tx("対象のタスク") }, el("option", { value: "" }, tx("選んでください"))); + for (const quest of quests) select.append(el("option", { value: quest.id, selected: quest.id === api.questId() }, quest.title)); + select.addEventListener("change", () => api.setQuest(select.value)); + const quest = quests.find(item => item.id === api.questId()); + const chosen = candidates.filter(agent => api.candidateIds().includes(agent.agentId)); + const credits = api.status()?.pricing.decisionCredits ?? null; + const out: (HTMLElement | null)[] = [ + el("label", { class: "a4-field-row" }, el("span", { class: "a4-step-label" }, tx("対象のタスク")), select), + quests.length ? null : el("p", { class: "a4-meta" }, tx("提案を受けられるタスクがありません。")), + el("div", { class: "a4-bill-section" }, el("span", { class: "a4-step-label" }, tx("候補のAgent")), + candidates.length ? el("div", { class: "a4-bill-list" }, ...candidates.map((agent, index) => + check(agent.displayName, api.candidateIds().includes(agent.agentId), locked, `jev-${index}`, () => api.toggleCandidate(agent.agentId)))) + : el("p", { class: "a4-meta" }, copy("noAgents"))), + quest ? fold(tx("送る内容を見る"), "payload", el("div", { class: "a4-bill-payload" }, + ...[api.contextText(quest.title, 500), api.contextText(quest.completionCriteria, 1500), api.contextText(quest.nextAction, 1500)].filter(Boolean).map(text => el("p", {}, text)), + chosen.length ? el("ul", {}, ...chosen.map(agent => el("li", {}, `${agent.displayName} · ${api.contextText(agent.role, 120)}`))) : null)) : null, + check(tx("表示したタスクの最小テキストと候補Agent情報をTypeSafeへ送信することに同意します。"), api.consent(), locked, "consent", on => api.setConsent(on)), + ]; + if (!api.decisionId()) { + // One button: the estimate is checked first, then the request goes out on the same consent. + out.push(action(credits === null ? tx("提案を受ける") : tx("{count} Creditsで提案を受ける", { count: number(credits) }), "request", () => { + void (async () => { if (!api.quoteCurrent()) await api.estimate(); if (api.quoteCurrent()) await api.execute(); })(); + }, !api.canWrite() || !quest || !api.consent() || !api.allowedCandidates(), "primary")); + return out; + } + const state = api.decisionState(); + const result = api.suggestion(); + const actions: Record = { continue: copy("action_continue"), ask_human: copy("action_ask_human"), review: copy("action_review"), wait: copy("action_wait") }; + out.push(el("div", { class: "a4-bill-card", role: "status" }, + el("p", {}, copy(result ? "result" : ["pending", "running", "reserved", "uncertain", "reconcile_required"].includes(state) || !api.hasDecision() ? "pending" : "manual")), + result ? el("p", { class: "a4-bill-result" }, el("strong", {}, result.agent.displayName), ` · ${copy("action")}: ${actions[result.action]} · ${copy("confidence")}: ${Math.round(result.confidence * 100)}%`) : null, + el("div", { class: "a4-actions" }, + result ? action(copy("apply"), "apply", () => { void api.apply(); }, !api.canWrite() || !quest || quest.updatedAt !== api.decisionVersion() || api.applied(), "primary") : null, + action(copy("refresh"), "refresh", () => { void api.checkResult(); }, !api.canWrite()), + api.hasDecision() && ["suggestion", "manual", "error"].includes(state) || api.applied() ? action(tx("やり直す"), "cancel", api.clearDecision, !api.canWrite(), "link") : null))); + return out; + } + + function historyPage(): HTMLElement[] { + const ledger = api.ledger(); + const out: HTMLElement[] = []; + if (api.ledgerError()) out.push(el("div", { class: "a4-bill-alert", role: "alert" }, el("span", {}, copy("failed")), action(tx("もう一度"), "retry", () => { void api.loadLedger(); }, api.busy() !== "", "link"))); + if (ledger === null) { if (!api.ledgerError()) out.push(el("p", { class: "a4-meta", role: "status" }, tx("読み込んでいます"))); return out; } + if (!ledger.length) out.push(el("p", { class: "a4-meta" }, tx("まだ履歴はありません。"))); + else out.push(el("div", { class: "a4-set-group" }, ...ledger.map(entry => el("div", { class: "a4-set-row" }, + el("span", { class: "a4-set-copy" }, el("strong", {}, api.entryLabel(entry.kind)), el("small", {}, day(typeof entry.createdAt === "string" ? entry.createdAt : ""))), + el("span", { class: "a4-bill-amount" }, typeof entry.amount === "number" && Number.isSafeInteger(entry.amount) ? `${entry.amount > 0 ? "+" : ""}${number(entry.amount)}` : "—"))))); + if (api.ledgerMore()) out.push(action(copy("loadMore"), "loadMore", () => { void api.loadLedger(); }, api.busy() !== "", "link")); + return out; + } + + /** The whole screen, child pages included; `backToSettings` is the parent's back link. */ + function render(backToSettings: HTMLElement, reload: () => void): HTMLElement { + retry = reload; + const status = api.status(); + const titles: Record = { plan: tx("利用プラン"), jev: copy("jev"), history: tx("Creditsの履歴") }; + const back = page === "plan" ? backToSettings : action(tx("← 利用プラン"), "back", () => go("plan"), false, "link"); + const lead = page === "jev" ? el("p", { class: "a4-lead" }, tx("担当Agentと次の行動の提案です。自動で担当を変更したり、タスクを完了したりしません。")) : null; + const body: (HTMLElement | null)[] = page === "jev" ? jevPage() : page === "history" ? historyPage() + : status ? [planCard(), freeAgents(), goPro(), creditsSection()] : []; + return el("section", { class: "a4-view a4-wide a4-set-page a4-bill-page", "data-billing": "true", "aria-busy": String(api.busy() !== "") }, back, + el("h1", { class: "a4-title", tabindex: -1 }, titles[page]), lead, ...notices(), ...body); + } + + return { render, reset: () => { page = "plan"; } }; +} diff --git a/interaction-lab/all-for-adhd/connections-view.ts b/interaction-lab/all-for-adhd/connections-view.ts new file mode 100644 index 0000000..ebe4ead --- /dev/null +++ b/interaction-lab/all-for-adhd/connections-view.ts @@ -0,0 +1,175 @@ +/** + * Connections in All for ADHD's layout (Penpot page 17): what needs attention + * first, a state chip instead of a colour line, the action on the row, and the + * authentication details folded. Requests and the disconnect confirmation + * content stay Relay Forge's own. + */ + +import { createElement, ChevronDown, ChevronRight } from "lucide"; +import { t } from "../../i18n.ts"; +import { el } from "../relay-forge/primitives/dom.ts"; +import { relayText } from "../relay-forge/relay-copy.ts"; +import { connectionSyncDirection, needsAttention, type ConnectionActionResult, type ConnectionsModel, type ConnectionView } from "../relay-forge/screens/connections-model.ts"; +import type { ConnectionsState } from "../relay-forge/screens/connections.ts"; +import { tx } from "./copy.ts"; + +export interface ConnectionsViewHost { + readonly mobile: boolean; + readonly rerender: () => void; + readonly announce: (message: string) => void; + readonly openQuest: (id: string) => void; + readonly agentName: (id: string) => string; + readonly port: { + previewSync(id: string): Promise; + runSync(id: string): Promise; + reconnect(id: string): Promise; + disconnect(id: string): Promise; + }; +} + +const icon = (node: typeof ChevronDown) => createElement(node, { width: 16, height: 16, "stroke-width": 1.75, "aria-hidden": "true" }); +const when = (value: string) => { + const at = Date.parse(value); + return Number.isFinite(at) ? new Date(at).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : ""; +}; +function chip(connection: ConnectionView): HTMLElement { + const map: Record = { + expired: [tx("再接続が必要"), "review"], permission_required: [tx("許可が必要"), "review"], degraded: [tx("同期に問題"), "review"], + connected: [tx("接続済み"), "done"], not_connected: [tx("未接続"), "neutral"], unavailable: [tx("近日対応"), "neutral"], + }; + const [label, tone] = map[connection.health]; + return el("span", { class: "a4-net-chip", "data-tone": tone }, label); +} +function rowLine(connection: ConnectionView): string { + if (connection.health === "unavailable") return tx("次の開発段階で使えるようになります"); + if (connection.health === "not_connected") return connection.summary; + if (needsAttention(connection) && connection.affectedQuests.length && connection.health !== "degraded") return tx("タスク{count}件の同期が止まっています", { count: connection.affectedQuests.length }); + if (connection.health === "degraded" && connection.lastSyncedAt) return tx("{time} の同期で問題がありました", { time: when(connection.lastSyncedAt) }); + return connection.lastSyncedAt ? tx("{time} に同期", { time: when(connection.lastSyncedAt) }) : tx("まだ同期していません"); +} + +export function connectionsView(model: ConnectionsModel, state: ConnectionsState, host: ConnectionsViewHost): HTMLElement { + const busy = state.busyId !== null; + const select = (id: string, detail: boolean) => { + state.selectedId = id; state.result = null; state.resultId = null; state.confirmingDisconnect = false; + if (!busy) state.phase = "idle"; + state.mobileDetailOpen = detail; + host.rerender(); + }; + async function run(connection: ConnectionView, phase: ConnectionsState["phase"], work: () => Promise): Promise { + if (state.busyId !== null || model.writeHeld) return; + state.busyId = connection.id; state.phase = phase; state.result = null; state.resultId = null; + host.rerender(); + let result: ConnectionActionResult; + try { result = await work(); } catch { result = { ok: false, code: "failed", message: relayText("connectionFailed") }; } + if (phase === "previewing" && result.ok && result.preview === undefined) result = { ok: false, code: "invalid_response", message: relayText("connectionInvalidResponse") }; + state.busyId = null; + if (state.selectedId === connection.id) { state.result = result; state.resultId = connection.id; state.phase = result.ok ? (phase === "previewing" ? "previewed" : "done") : "failed"; } + else state.phase = "idle"; + host.rerender(); + host.announce(`${connection.name}: ${result.message}`); + } + const reconnect = (connection: ConnectionView) => { void run(connection, "running", () => host.port.reconnect(connection.id)); }; + + // The row carries its one action: reconnect, check, or set up. + function row(connection: ConnectionView): HTMLElement { + const selected = !host.mobile && connection.id === state.selectedId; + const open = el("button", { type: "button", class: "a4-cn-open", "aria-current": String(selected) }, + el("span", { class: "a4-cn-name" }, connection.name, chip(connection)), el("span", { class: "a4-meta" }, rowLine(connection))); + open.addEventListener("click", () => select(connection.id, host.mobile)); + let action: HTMLElement | null = null; + if (connection.health === "degraded") { action = el("button", { type: "button", class: "a4-button", disabled: busy }, tx("確認")); action.addEventListener("click", () => select(connection.id, host.mobile)); } + else if (connection.canReconnect && connection.health !== "connected") { + action = el("button", { type: "button", class: "a4-button", disabled: busy || model.writeHeld }, connection.health === "not_connected" ? tx("設定する") : tx("再接続")); + action.addEventListener("click", () => reconnect(connection)); + } + return el("li", { class: "a4-cn-row", "data-selected": String(selected), "data-muted": String(connection.health === "unavailable") }, open, action); + } + + function detail(connection: ConnectionView): HTMLElement { + if (state.confirmingDisconnect && !busy) { + // The confirmation says the same as before; stopping is the main choice. + const keep = el("button", { type: "button", class: "a4-button a4-primary" }, tx("やめる")); + keep.addEventListener("click", () => { state.confirmingDisconnect = false; host.rerender(); }); + const go = el("button", { type: "button", class: "a4-button", disabled: model.writeHeld }, tx("接続を解除")); + go.addEventListener("click", () => { state.confirmingDisconnect = false; void run(connection, "running", () => host.port.disconnect(connection.id)); }); + requestAnimationFrame(() => keep.focus()); + return el("div", { class: "a4-bill-card a4-cn-confirm", role: "alertdialog", "aria-label": tx("接続を解除すると次のことが起きます") }, + el("h3", { class: "a4-bt-sub" }, tx("接続を解除すると次のことが起きます")), + el("ul", { class: "a4-bill-points" }, ...[`${connection.name}: ${relayText("connectionRevokeHint")}`, + ...(connection.id === "google-calendar" || connection.id === "google-tasks" ? [relayText("connectionGoogleRevoke")] : []), + tx("紐づくタスクの同期が止まります。タスクは削除しません。")].map(line => el("li", {}, line))), + el("div", { class: "a4-actions" }, keep, go)); + } + const primary: HTMLElement[] = []; + if (connection.canReconnect && connection.health !== "connected") { + const button = el("button", { type: "button", class: "a4-button a4-primary", disabled: busy || model.writeHeld }, connection.health === "not_connected" ? tx("設定する") : tx("再接続する")); + button.addEventListener("click", () => reconnect(connection)); + primary.push(button); + } + if (connection.canSync) { + const preview = el("button", { type: "button", class: "a4-button", disabled: busy || model.writeHeld }, t("integration.previewLive")); + preview.addEventListener("click", () => { void run(connection, "previewing", () => host.port.previewSync(connection.id)); }); + const ready = state.phase === "previewed" && state.resultId === connection.id && state.result?.preview !== undefined; + const sync = el("button", { type: "button", class: "a4-button a4-primary", disabled: busy || model.writeHeld || !ready }, t("integration.confirmSync")); + sync.addEventListener("click", () => { if (ready) void run(connection, "running", () => host.port.runSync(connection.id)); }); + primary.push(el("div", { class: "a4-actions" }, preview, sync)); + } + let outcome: HTMLElement | null = null; + if (state.busyId === connection.id) outcome = el("p", { class: "a4-meta", role: "status" }, state.phase === "previewing" ? relayText("connectionPreviewing") : tx("実行しています")); + else if (state.result && state.resultId === connection.id) { + const preview = state.result.preview; + outcome = !state.result.ok ? el("div", { class: "a4-bill-alert", role: "alert" }, state.result.message) + : state.phase === "previewed" && preview ? el("div", { class: "a4-cn-preview", role: "status" }, el("strong", {}, t("integration.preview")), + el("ul", { class: "a4-bill-points" }, ...[[relayText("connectionCreated"), preview.imported], [relayText("connectionUpdated"), preview.updated], [relayText("connectionSkipped"), preview.skipped], [relayText("connectionConflicts"), preview.conflicts ?? 0]].map(([label, count]) => el("li", {}, `${label}: ${count}`))), + el("p", { class: "a4-meta" }, relayText("connectionPreviewOnly"))) + : el("p", { class: "a4-meta", role: "status" }, state.result.message); + } + const quests = connection.affectedQuests.map(quest => { + const open = el("button", { type: "button", class: "a4-set-row" }, el("span", { class: "a4-set-copy" }, el("strong", {}, quest.title)), icon(ChevronRight)); + open.addEventListener("click", () => host.openQuest(quest.id)); + return open; + }); + const fact = (label: string, value: string) => value ? el("div", { class: "a4-cn-fact" }, el("span", { class: "a4-meta" }, label), el("span", {}, value)) : null; + const disconnect = connection.canDisconnect ? el("button", { type: "button", class: "a4-link a4-cn-disconnect", disabled: busy || model.writeHeld }, tx("接続を解除")) : null; + disconnect?.addEventListener("click", () => { state.confirmingDisconnect = true; host.rerender(); }); + return el("div", { class: "a4-cn-detail" }, + host.mobile ? el("div", { class: "a4-cn-name" }, chip(connection)) : el("div", { class: "a4-cn-name a4-cn-title" }, el("h2", { tabindex: -1 }, connection.name), chip(connection)), + el("p", { class: "a4-cn-summary" }, connection.summary), + connection.lastError ? el("div", { class: "a4-cn-error" }, connection.lastError) : null, + ...primary, outcome, + quests.length ? el("section", {}, el("h3", { class: "a4-net-group-head" }, tx("止まっているタスク"), el("span", { class: "a4-meta" }, String(quests.length))), el("div", { class: "a4-set-group" }, ...quests)) : null, + el("div", { class: "a4-cn-facts" }, + fact(tx("接続先"), connection.accountLabel), fact(tx("最後の同期"), when(connection.lastSyncedAt)), + fact(tx("操作できるAgent"), connection.affectedAgents.map(host.agentName).join(", "))), + el("details", { class: "a4-bill-fold" }, el("summary", {}, tx("認証方式と権限を見る"), icon(ChevronDown)), + el("div", { class: "a4-cn-auth" }, fact(tx("認証方式"), connection.auth), + connection.requiredScopes.length ? fact(tx("必要な権限"), connection.requiredScopes.join(", ")) : null, + connection.canSync ? fact(tx("同期の向き"), t(`integration.direction.${connectionSyncDirection(connection.id)}`)) : null)), + disconnect); + } + + const counts = { all: model.connections.length, attention: model.connections.filter(needsAttention).length, connected: model.connections.filter(entry => entry.health === "connected").length }; + const current = model.connections.find(entry => entry.id === state.selectedId) ?? null; + // A phone shows one connection as its own page. + if (host.mobile && state.mobileDetailOpen && current) { + const back = el("button", { type: "button", class: "a4-link" }, tx("← 連携")); + back.addEventListener("click", () => { state.mobileDetailOpen = false; state.confirmingDisconnect = false; host.rerender(); }); + return el("div", { class: "a4-cn", "data-own-frame": "true" }, back, el("h1", { class: "a4-title", tabindex: -1 }, current.name), detail(current)); + } + const filters = el("div", { class: "a4-net-switch", role: "group", "aria-label": tx("表示") }, ...([["all", tx("すべて")], ["attention", tx("要対応")], ["connected", tx("接続中")]] as const).map(([id, label]) => { + const pill = el("button", { type: "button", class: "a4-net-pill", "aria-pressed": String(state.filter === id) }, `${label} ${counts[id]}`); + pill.addEventListener("click", () => { state.filter = id; host.rerender(); }); + return pill; + })); + const shown = model.connections.filter(entry => state.filter === "all" || (state.filter === "attention" ? needsAttention(entry) : entry.health === "connected")); + if (!host.mobile && !current && shown[0]) state.selectedId = shown[0].id; + const groups = ([[tx("要対応"), shown.filter(needsAttention)], [tx("接続中"), shown.filter(entry => entry.health === "connected")], + [tx("未接続"), shown.filter(entry => !needsAttention(entry) && entry.health !== "connected")]] as const) + .filter(([, entries]) => entries.length) + .map(([title, entries]) => el("section", { class: "a4-cn-group" }, el("h2", { class: "a4-bt-sub" }, `${title} ${entries.length}`), el("ul", { class: "a4-cn-rows" }, ...entries.map(row)))); + const list = el("div", { class: "a4-cn-list" }, ...(groups.length ? groups : [el("p", { class: "a4-net-empty" }, tx("表示できる連携はありません。"))])); + const selected = model.connections.find(entry => entry.id === state.selectedId) ?? null; + return el("div", { class: "a4-cn" }, filters, + host.mobile ? list : el("div", { class: "a4-cn-split" }, list, selected ? el("div", { class: "a4-bill-card a4-cn-side" }, detail(selected)) : null)); +} diff --git a/interaction-lab/all-for-adhd/copy.ts b/interaction-lab/all-for-adhd/copy.ts index ede2580..f22074d 100644 --- a/interaction-lab/all-for-adhd/copy.ts +++ b/interaction-lab/all-for-adhd/copy.ts @@ -44,16 +44,13 @@ export const COPY: Readonly> = { "未保存": ["Unsaved", "Sin guardar", "Não salvo", "Non enregistrée", "Ungespeichert", "저장 안 됨", "未保存", "Не сохранено"], "画像容量 {used} / {total} MB": ["Image storage {used} / {total} MB", "Imágenes {used} / {total} MB", "Imagens {used} / {total} MB", "Images {used} / {total} Mo", "Bildspeicher {used} / {total} MB", "이미지 용량 {used} / {total} MB", "图片容量 {used} / {total} MB", "Изображения: {used} / {total} МБ"], "画像を保存できませんでした。入力を残しています。再試行してください。": ["Could not save images. Your input is kept. Please retry.", "No se pudieron guardar las imágenes. Conservamos tus cambios. Reintenta.", "Não foi possível salvar as imagens. Suas alterações foram mantidas. Tente novamente.", "Impossible d’enregistrer les images. Vos modifications sont conservées. Réessayez.", "Bilder konnten nicht gespeichert werden. Eingaben bleiben erhalten. Erneut versuchen.", "이미지를 저장하지 못했어요. 입력은 남아 있어요. 다시 시도하세요.", "图片保存失败,输入已保留,请重试。", "Не удалось сохранить изображения. Ввод сохранён. Повторите попытку."], - "確認 {review}件 · 停止 {blocked}件": ["Review {review} · Blocked {blocked}", "Revisar {review} · Bloqueadas {blocked}", "Revisar {review} · Bloqueadas {blocked}", "À vérifier {review} · Bloquées {blocked}", "Prüfen {review} · Blockiert {blocked}", "확인 {review}개 · 중단 {blocked}개", "待确认 {review}项 · 阻塞 {blocked}项", "Проверить {review} · Заблокировано {blocked}"], "{day} {count}件、完了 {done}件、作業中 {working}件": ["{day}: {count} tasks, {done} done, {working} working", "{day}: {count} tareas, {done} hechas, {working} en curso", "{day}: {count} tarefas, {done} concluídas, {working} em andamento", "{day} : {count} tâches, {done} terminées, {working} en cours", "{day}: {count} Aufgaben, {done} erledigt, {working} in Arbeit", "{day} {count}개, 완료 {done}개, 작업 중 {working}개", "{day} {count}项,已完成{done}项,进行中{working}项", "{day}: {count} задач, {done} завершено, {working} в работе"], "編集を続ける": ["Keep editing", "Seguir editando", "Continuar editando", "Continuer à modifier", "Weiter bearbeiten", "계속 편집", "继续编辑", "Продолжить редактирование"], "破棄する": ["Discard", "Descartar", "Descartar", "Abandonner", "Verwerfen", "버리기", "放弃", "Отменить изменения"], - "AIの状況": ["AI activity", "Actividad de IA", "Atividade da IA", "Activité de l’IA", "KI-Aktivität", "AI 현황", "AI 状态", "Активность ИИ"], "← ギルド": ["← Guild", "← Gremio", "← Guilda", "← Guilde", "← Gilde", "← 길드", "← 公会", "← Гильдия"], "まずは、できるところから。": ["Start with what you can do.", "Empieza por lo que puedas.", "Comece pelo que puder.", "Commencez par ce que vous pouvez.", "Fang an, wo du kannst.", "할 수 있는 것부터 시작해요.", "先从能做的开始。", "Начните с того, что можете."], "カレンダー": ["Calendar", "Calendario", "Calendário", "Calendrier", "Kalender", "캘린더", "日历", "Календарь"], "ギルドを開く": ["Open Guild", "Abrir gremio", "Abrir guilda", "Ouvrir la guilde", "Gilde öffnen", "길드 열기", "打开公会", "Открыть гильдию"], - "タスクから今日やるものを選びましょう。": ["Choose a task to do today.", "Elige una tarea para hoy.", "Escolha uma tarefa para hoje.", "Choisissez une tâche pour aujourd’hui.", "Wähle eine Aufgabe für heute.", "오늘 할 작업을 선택하세요.", "选择今天要做的任务。", "Выберите задачу на сегодня."], "タスクを追加": ["Add task", "Añadir tarea", "Adicionar tarefa", "Ajouter une tâche", "Aufgabe hinzufügen", "작업 추가", "添加任务", "Добавить задачу"], "今の1件": ["One task now", "Una tarea ahora", "Uma tarefa agora", "Une tâche maintenant", "Eine Aufgabe jetzt", "지금 할 한 가지", "当前一件事", "Одна задача сейчас"], "今日の1件を、ここから。": ["Start your day with one task.", "Empieza el día con una tarea.", "Comece o dia com uma tarefa.", "Commencez la journée par une tâche.", "Starte den Tag mit einer Aufgabe.", "오늘의 한 가지를 여기서 시작해요.", "从一件任务开始今天。", "Начните день с одной задачи."], @@ -62,29 +59,19 @@ export const COPY: Readonly> = { "保存中…": ["Saving…", "Guardando…", "Salvando…", "Enregistrement…", "Wird gespeichert…", "저장 중…", "正在保存…", "Сохранение…"], "再取得できませんでした。表示中の内容を残しています。": ["Could not refresh. Your current view is preserved.", "No se pudo actualizar. Conservamos el contenido actual.", "Não foi possível atualizar. O conteúdo atual foi mantido.", "Actualisation impossible. Le contenu affiché est conservé.", "Aktualisierung fehlgeschlagen. Die aktuelle Ansicht bleibt erhalten.", "새로 불러오지 못했습니다. 현재 내용을 유지합니다.", "无法刷新。已保留当前显示内容。", "Не удалось обновить. Текущие данные сохранены на экране."], "別のタスク": ["Another task", "Otra tarea", "Outra tarefa", "Une autre tâche", "Andere Aufgabe", "다른 작업", "其他任务", "Другая задача"], - "受け取り待ち {count}件": ["Awaiting pickup: {count}", "Por recoger: {count}", "Aguardando recebimento: {count}", "En attente de prise en charge : {count}", "Warten auf Übernahme: {count}", "수신 대기 {count}개", "待接收 {count} 项", "Ожидают принятия: {count}"], "次の予定": ["Up next", "A continuación", "A seguir", "À suivre", "Als Nächstes", "다음 일정", "下一个安排", "Далее"], "確認待ち": ["Awaiting review", "Pendiente de revisión", "Aguardando revisão", "À vérifier", "Prüfung ausstehend", "확인 대기", "待确认", "Ожидает проверки"], "やること": ["Tasks", "Tareas", "Tarefas", "Tâches", "Aufgaben", "할 일", "待办", "Задачи"], "ギルド": ["Guild", "Gremio", "Guilda", "Guilde", "Gilde", "길드", "公会", "Гильдия"], "設定": ["Settings", "Ajustes", "Configurações", "Réglages", "Einstellungen", "설정", "设置", "Настройки"], "画面": ["Screens", "Pantallas", "Telas", "Écrans", "Bildschirme", "화면", "页面", "Экраны"], - "思いついたら書く": ["Write it down", "Anótalo", "Anote aqui", "Notez-le", "Notier es", "떠오르면 적기", "想到就写下", "Запишите мысль"], "やることを書き出す": ["Write down a task", "Anota una tarea", "Anote uma tarefa", "Noter une tâche", "Aufgabe notieren", "할 일 적기", "写下一项待办", "Записать задачу"], "今日やる": ["Today", "Hoy", "Hoje", "Aujourd’hui", "Heute", "오늘", "今天做", "Сегодня"], - "追加": ["Add", "Añadir", "Adicionar", "Ajouter", "Hinzufügen", "추가", "添加", "Добавить"], - "デモ(保存されません) ": ["Demo (nothing is saved) ", "Demo (no se guarda nada) ", "Demo (nada é salvo) ", "Démo (rien n’est enregistré) ", "Demo (nichts wird gespeichert) ", "데모 (저장되지 않음) ", "演示(不会保存) ", "Демо (ничего не сохраняется) "], - "サインイン": ["Sign in", "Iniciar sesión", "Entrar", "Se connecter", "Anmelden", "로그인", "登录", "Войти"], - "今日のやることに追加しました": ["Added to today", "Añadida a hoy", "Adicionada a hoje", "Ajoutée à aujourd’hui", "Zu heute hinzugefügt", "오늘 할 일에 추가했어요", "已添加到今天", "Добавлено на сегодня"], - "受け箱に追加しました": ["Added to your inbox", "Añadida a la bandeja", "Adicionada à caixa de entrada", "Ajoutée à la boîte de réception", "Zum Eingang hinzugefügt", "받은 함에 추가했어요", "已添加到收件箱", "Добавлено во входящие"], "追加できませんでした。入力は残しています。": ["Couldn’t add it. Your text is still there.", "No se pudo añadir. Tu texto sigue ahí.", "Não foi possível adicionar. Seu texto continua lá.", "Impossible d’ajouter. Votre texte est conservé.", "Konnte nicht hinzugefügt werden. Dein Text bleibt erhalten.", "추가하지 못했어요. 입력한 내용은 남아 있어요.", "未能添加。输入内容已保留。", "Не удалось добавить. Текст сохранён."], "元に戻す": ["Undo", "Deshacer", "Desfazer", "Annuler", "Rückgängig", "되돌리기", "撤销", "Отменить"], "元に戻せませんでした": ["Couldn’t undo", "No se pudo deshacer", "Não foi possível desfazer", "Impossible d’annuler", "Rückgängig machen fehlgeschlagen", "되돌리지 못했어요", "无法撤销", "Не удалось отменить"], "保存できませんでした。もう一度試してください。": ["Couldn’t save. Please try again.", "No se pudo guardar. Inténtalo de nuevo.", "Não foi possível salvar. Tente de novo.", "Enregistrement impossible. Réessayez.", "Speichern fehlgeschlagen. Bitte erneut versuchen.", "저장하지 못했어요. 다시 시도해 주세요.", "未能保存,请重试。", "Не удалось сохранить. Попробуйте ещё раз."], - "完了! Lv {level} · XP {xp}/{next}": ["Done! Lv {level} · XP {xp}/{next}", "¡Hecho! Nv {level} · XP {xp}/{next}", "Feito! Nv {level} · XP {xp}/{next}", "Terminé ! Nv {level} · XP {xp}/{next}", "Erledigt! Lv {level} · XP {xp}/{next}", "완료! Lv {level} · XP {xp}/{next}", "完成!Lv {level} · XP {xp}/{next}", "Готово! Ур. {level} · XP {xp}/{next}"], - "完了を保存できませんでした。もう一度試してください。": ["Couldn’t save the completion. Please try again.", "No se pudo guardar el progreso. Inténtalo de nuevo.", "Não foi possível salvar a conclusão. Tente de novo.", "Impossible d’enregistrer la fin. Réessayez.", "Abschluss konnte nicht gespeichert werden. Bitte erneut versuchen.", "완료를 저장하지 못했어요. 다시 시도해 주세요.", "未能保存完成状态,请重试。", "Не удалось сохранить завершение. Попробуйте ещё раз."], "今日の分、ぜんぶ終わりました!": ["That’s everything for today!", "¡Terminaste todo lo de hoy!", "Tudo de hoje concluído!", "Tout est fait pour aujourd’hui !", "Für heute ist alles erledigt!", "오늘 할 일을 모두 끝냈어요!", "今天的都完成了!", "На сегодня всё сделано!"], - "完了! よくできました": ["Done! Nicely done", "¡Hecho! Bien hecho", "Feito! Muito bem", "Terminé ! Bien joué", "Erledigt! Gut gemacht", "완료! 잘했어요", "完成!做得好", "Готово! Отлично"], "完了しました": ["Done", "Hecho", "Concluída", "Terminé", "Erledigt", "완료했어요", "已完成", "Готово"], "始めました。まずは次の一歩だけ。": ["Started. Just the next step for now.", "Empezado. Solo el siguiente paso.", "Começou. Só o próximo passo por enquanto.", "C’est parti. Juste la prochaine étape.", "Gestartet. Erst mal nur der nächste Schritt.", "시작했어요. 일단 다음 한 걸음만.", "已开始。先只做下一步。", "Начато. Пока только следующий шаг."], "一時停止しました。続きはいつでもここから。": ["Paused. Pick it up here anytime.", "En pausa. Retómalo aquí cuando quieras.", "Pausado. Continue daqui quando quiser.", "En pause. Reprenez ici quand vous voulez.", "Pausiert. Mach hier jederzeit weiter.", "일시 정지했어요. 언제든 여기서 이어서 하세요.", "已暂停。随时从这里继续。", "Пауза. Продолжите отсюда в любой момент."], @@ -121,7 +108,6 @@ export const COPY: Readonly> = { "そのまま次へ": ["Straight on", "Sigue directo", "Segue direto", "Passe directement", "Direkt weiter", "바로 다음으로", "直接交给下一位", "Сразу дальше"], "確認を挟む": ["Check in between", "Revisar entre medias", "Revisar no meio", "Vérifier entre deux", "Dazwischen prüfen", "중간에 확인", "中间确认", "Проверить между"], "Agentを足すと、順番にバトンを渡します。最後はあなたが確認します。": ["Add Agents to pass the baton in order. You check at the end.", "Añade Agents para pasar el relevo en orden. Al final revisas tú.", "Adicione Agents para passar o bastão em ordem. No fim, você revisa.", "Ajoutez des Agents pour passer le relais dans l’ordre. Vous vérifiez à la fin.", "Füge Agents hinzu, die nacheinander übernehmen. Am Ende prüfst du.", "Agent를 추가하면 순서대로 바통을 넘겨요. 마지막에 내가 확인해요.", "添加 Agent 后会依次接力,最后由你确认。", "Добавьте Agent — они передадут эстафету по порядку. В конце проверяете вы."], - "Agentを足すと、そのAgentに渡します。終わったらあなたが確認します。": ["Add an Agent to hand this over. You check when it’s done.", "Añade un Agent para entregárselo. Revisas cuando termine.", "Adicione um Agent para entregar a ele. Você revisa ao final.", "Ajoutez un Agent pour lui confier la tâche. Vous vérifiez à la fin.", "Füge einen Agent hinzu, um es abzugeben. Du prüfst am Ende.", "Agent를 추가하면 그 Agent에게 넘겨요. 끝나면 내가 확인해요.", "添加 Agent 后会交给它,完成后由你确认。", "Добавьте Agent, чтобы передать задачу. Когда закончит, проверите вы."], "保存すると自分の担当に戻します。": ["Saving takes it back to you.", "Al guardar vuelve a ti.", "Ao salvar, volta para você.", "En enregistrant, la tâche vous revient.", "Beim Speichern geht es an dich zurück.", "저장하면 내 담당으로 돌아와요.", "保存后改回由你负责。", "При сохранении задача вернётся к вам."], "保存すると {first} に渡します。終わったら {rest} へ回り、最後にあなたが確認します。": ["Saving hands it to {first}. Then it goes to {rest}, and you check at the end.", "Al guardar pasa a {first}. Luego va a {rest} y al final revisas tú.", "Ao salvar, vai para {first}. Depois para {rest}, e no fim você revisa.", "En enregistrant, elle passe à {first}, puis à {rest} ; vous vérifiez à la fin.", "Beim Speichern geht es an {first}, dann an {rest}; am Ende prüfst du.", "저장하면 {first}에게 넘겨요. 끝나면 {rest}(으)로 가고, 마지막에 내가 확인해요.", "保存后交给 {first},之后交给 {rest},最后由你确认。", "При сохранении задача перейдёт к {first}, затем к {rest}, а в конце проверите вы."], "保存すると {first} に渡します。終わったら、最後にあなたが確認します。": ["Saving hands it to {first}. You check when it’s done.", "Al guardar pasa a {first}. Revisas cuando termine.", "Ao salvar, vai para {first}. Você revisa ao final.", "En enregistrant, elle passe à {first}. Vous vérifiez à la fin.", "Beim Speichern geht es an {first}. Am Ende prüfst du.", "저장하면 {first}에게 넘겨요. 끝나면 내가 확인해요.", "保存后交给 {first},完成后由你确认。", "При сохранении задача перейдёт к {first}. Когда закончит, проверите вы."], @@ -132,9 +118,7 @@ export const COPY: Readonly> = { "あなたが最後の確認": ["You check at the end", "Revisas al final", "Você revisa no fim", "Vous vérifiez à la fin", "Du prüfst am Ende", "마지막 확인은 나", "最后由你确认", "Вы проверяете в конце"], "あなたが進める": ["You do it", "Lo haces tú", "Você faz", "Vous vous en chargez", "Du machst es", "내가 진행", "由你来做", "Делаете вы"], "あなたの番": ["Your turn", "Tu turno", "Sua vez", "À vous", "Du bist dran", "내 차례", "轮到你", "Ваша очередь"], - "+ Agent を足す": ["+ Add an Agent", "+ Añadir Agent", "+ Adicionar Agent", "+ Ajouter un Agent", "+ Agent hinzufügen", "+ Agent 추가", "+ 添加 Agent", "+ Добавить Agent"], "足すAgent": ["Agents to add", "Agents para añadir", "Agents para adicionar", "Agents à ajouter", "Hinzuzufügende Agents", "추가할 Agent", "要添加的 Agent", "Добавляемые Agent"], - "使えるAgentがいません。登録は詳しい画面で行います。": ["No Agents available. Register one in the Guild.", "No hay Agents disponibles. Registra uno en el Gremio.", "Nenhum Agent disponível. Cadastre um na Guilda.", "Aucun Agent disponible. Inscrivez-en un dans la Guilde.", "Keine Agents verfügbar. Registriere einen in der Gilde.", "사용할 수 있는 Agent가 없어요. 길드에서 등록하세요.", "没有可用的 Agent。请在公会中注册。", "Нет доступных Agent. Зарегистрируйте в Гильдии."], "次の一歩:最初の1分でできること": ["Next step: something you can do in a minute", "Siguiente paso: algo que hagas en un minuto", "Próximo passo: algo para fazer em um minuto", "Prochaine étape : quelque chose de faisable en une minute", "Nächster Schritt: etwas für die erste Minute", "다음 한 걸음: 1분 안에 할 수 있는 일", "下一步:一分钟内能做的事", "Следующий шаг: то, что можно сделать за минуту"], "日付": ["Date", "Fecha", "Data", "Date", "Datum", "날짜", "日期", "Дата"], "時刻": ["Time", "Hora", "Horário", "Heure", "Uhrzeit", "시간", "时间", "Время"], @@ -178,47 +162,24 @@ export const COPY: Readonly> = { "変える": ["Change", "Cambiar", "Alterar", "Modifier", "Ändern", "바꾸기", "更改", "Изменить"], "{day} ほか{count}件を日表示で見る": ["See {count} more on {day} in day view", "Ver {count} más del {day} en vista diaria", "Ver mais {count} de {day} na visão diária", "Voir {count} de plus le {day} en vue jour", "{count} weitere am {day} in der Tagesansicht", "{day} 외 {count}건을 일 보기로 보기", "在日视图查看 {day} 的其余 {count} 项", "Ещё {count} на {day} в режиме дня"], "+{count}件": ["+{count}", "+{count}", "+{count}", "+{count}", "+{count}", "+{count}건", "+{count}项", "+{count}"], - "いつでも": ["Anytime", "Cuando sea", "A qualquer hora", "N’importe quand", "Jederzeit", "언제든", "随时", "В любое время"], "この週": ["This week", "Esta semana", "Esta semana", "Cette semaine", "Diese Woche", "이번 주", "本周", "Эта неделя"], "いま": ["Now", "Ahora", "Agora", "Maintenant", "Jetzt", "지금", "现在", "Сейчас"], "今日のタイムライン": ["Today’s timeline", "Línea de hoy", "Linha do tempo de hoje", "Fil de la journée", "Heutiger Ablauf", "오늘의 타임라인", "今天的时间线", "План на сегодня"], - "それより前({count})": ["Earlier ({count})", "Antes ({count})", "Antes ({count})", "Plus tôt ({count})", "Früher ({count})", "이전 ({count})", "更早({count})", "Ранее ({count})"], - "今日 {done} / {total} 完了 · 予定 {time}": ["Today {done} / {total} done · {time} planned", "Hoy {done} / {total} hechas · {time} previstas", "Hoje {done} / {total} feitas · {time} previstas", "Aujourd’hui {done} / {total} faites · {time} prévues", "Heute {done} / {total} erledigt · {time} geplant", "오늘 {done} / {total} 완료 · 예정 {time}", "今天 {done} / {total} 完成 · 计划 {time}", "Сегодня {done} / {total} готово · план {time}"], - "{time} 空いています": ["{time} free", "{time} libres", "{time} livres", "{time} de libre", "{time} frei", "{time} 비어 있어요", "空闲 {time}", "Свободно {time}"], - "休憩 {time}": ["Rest {time}", "Descanso {time}", "Pausa {time}", "Pause {time}", "Pause {time}", "휴식 {time}", "休息 {time}", "Отдых {time}"], - "提案を見る": ["See ideas", "Ver ideas", "Ver ideias", "Voir des idées", "Vorschläge ansehen", "제안 보기", "查看建议", "Показать идеи"], - "空き時間にすること": ["Something for this free time", "Algo para este rato libre", "Algo para este tempo livre", "Une idée pour ce temps libre", "Etwas für diese freie Zeit", "빈 시간에 할 일", "空闲时间做点什么", "Чем занять это время"], - "休む": ["Rest", "Descansar", "Descansar", "Se reposer", "Ausruhen", "쉬기", "休息", "Отдохнуть"], - "休憩にしました": ["Marked as rest", "Marcado como descanso", "Marcado como pausa", "Noté comme pause", "Als Pause markiert", "휴식으로 했어요", "已设为休息", "Отмечено как отдых"], "{time}に「{title}」を入れました": ["Put “{title}” at {time}", "«{title}» puesta a las {time}", "“{title}” marcada para {time}", "« {title} » placée à {time}", "„{title}“ auf {time} gelegt", "{time}에 「{title}」을(를) 넣었어요", "已把「{title}」放到 {time}", "«{title}» поставлено на {time}"], "時刻の決まった予定はありません。": ["Nothing with a set time today.", "Hoy no hay nada con hora fija.", "Nada com horário marcado hoje.", "Rien à heure fixe aujourd’hui.", "Heute nichts mit fester Uhrzeit.", "시간이 정해진 일정은 없어요.", "今天没有定好时间的安排。", "Сегодня нет дел с точным временем."], "{title} を{day}に入れる": ["Put {title} on {day}", "Poner {title} en {day}", "Colocar {title} em {day}", "Placer {title} le {day}", "{title} auf {day} legen", "{title}을(를) {day}에 넣기", "把 {title} 放到 {day}", "Поставить «{title}» на {day}"], - "+": ["+", "+", "+", "+", "+", "+", "+", "+"], "{day}に入れました": ["Put on {day}", "Puesta en {day}", "Colocada em {day}", "Placée le {day}", "Auf {day} gelegt", "{day}에 넣었어요", "已放到 {day}", "Поставлено на {day}"], - "今日 {day}": ["Today, {day}", "Hoy, {day}", "Hoje, {day}", "Aujourd’hui, {day}", "Heute, {day}", "오늘 {day}", "今天 {day}", "Сегодня, {day}"], "表示": ["View", "Vista", "Visualização", "Affichage", "Ansicht", "보기", "视图", "Вид"], "日": ["Day", "Día", "Dia", "Jour", "Tag", "일", "日", "День"], "週": ["Week", "Semana", "Semana", "Semaine", "Woche", "주", "周", "Неделя"], - "今日へ": ["Go to today", "Ir a hoy", "Ir para hoje", "Aller à aujourd’hui", "Zu heute", "오늘로", "回到今天", "К сегодня"], "前の週": ["Previous week", "Semana anterior", "Semana anterior", "Semaine précédente", "Vorige Woche", "이전 주", "上一周", "Предыдущая неделя"], "次の週": ["Next week", "Semana siguiente", "Próxima semana", "Semaine suivante", "Nächste Woche", "다음 주", "下一周", "Следующая неделя"], "前の月": ["Previous month", "Mes anterior", "Mês anterior", "Mois précédent", "Vorheriger Monat", "이전 달", "上个月", "Предыдущий месяц"], "次の月": ["Next month", "Mes siguiente", "Próximo mês", "Mois suivant", "Nächster Monat", "다음 달", "下个月", "Следующий месяц"], - "この日の予定はありません。受け箱から+で入れるか、上の欄に書き出しましょう。": ["Nothing planned for this day. Add from your inbox with + or write something above.", "Nada planeado este día. Añade desde la bandeja con + o escribe arriba.", "Nada planejado para este dia. Adicione da caixa de entrada com + ou escreva acima.", "Rien de prévu ce jour-là. Ajoutez depuis la boîte de réception avec + ou notez en haut.", "Für diesen Tag ist nichts geplant. Füge mit + aus dem Eingang hinzu oder schreib oben etwas.", "이날은 일정이 없어요. 받은 함에서 +로 넣거나 위 칸에 적어 보세요.", "这一天没有安排。用 + 从收件箱加入,或在上方写下。", "На этот день ничего нет. Добавьте из входящих через + или запишите выше."], - "この日の分はすべて終わりました。": ["Everything for this day is done.", "Todo lo de este día está hecho.", "Tudo deste dia foi concluído.", "Tout est fait pour ce jour.", "Für diesen Tag ist alles erledigt.", "이날 할 일을 모두 끝냈어요.", "这一天的都完成了。", "На этот день всё сделано."], - "残り {count} 件 · 目安 {time}": ["{count} left · about {time}", "Quedan {count} · aprox. {time}", "Faltam {count} · cerca de {time}", "Reste {count} · environ {time}", "Noch {count} · ca. {time}", "남은 {count}건 · 예상 {time}", "剩 {count} 项 · 预计 {time}", "Осталось {count} · ≈ {time}"], "日付のないもの": ["No date", "Sin fecha", "Sem data", "Sans date", "Ohne Datum", "날짜 없음", "无日期", "Без даты"], - "受け箱({count})": ["Inbox ({count})", "Bandeja ({count})", "Caixa de entrada ({count})", "Boîte de réception ({count})", "Eingang ({count})", "받은 함 ({count})", "收件箱({count})", "Входящие ({count})"], "受け箱は空です。": ["Your inbox is empty.", "La bandeja está vacía.", "A caixa de entrada está vazia.", "La boîte de réception est vide.", "Der Eingang ist leer.", "받은 함이 비어 있어요.", "收件箱是空的。", "Входящие пусты."], - "日付を見直す({count})": ["Reschedule ({count})", "Reprogramar ({count})", "Reagendar ({count})", "À replanifier ({count})", "Neu planen ({count})", "날짜 다시 보기 ({count})", "重新安排日期({count})", "Перенести ({count})"], - "予定日を過ぎたものです。+で今日に入れるか、タイトルから日付を変えます。": ["These are past their date. Use + to move one to today, or open it to change the date.", "Ya pasó su fecha. Usa + para pasarla a hoy o ábrela para cambiar la fecha.", "Já passaram da data. Use + para trazer para hoje ou abra para mudar a data.", "Leur date est passée. Utilisez + pour les mettre aujourd’hui, ou ouvrez-les pour changer la date.", "Ihr Datum ist vorbei. Mit + auf heute legen oder öffnen, um das Datum zu ändern.", "예정일이 지난 일이에요. +로 오늘에 넣거나 제목을 눌러 날짜를 바꾸세요.", "这些已过计划日期。用 + 放到今天,或打开后修改日期。", "Их дата прошла. Нажмите +, чтобы перенести на сегодня, или откройте, чтобы изменить дату."], - "{title}({count})": ["{title} ({count})", "{title} ({count})", "{title} ({count})", "{title} ({count})", "{title} ({count})", "{title} ({count})", "{title}({count})", "{title} ({count})"], - "やることはありません。": ["No tasks.", "No hay tareas.", "Nenhuma tarefa.", "Aucune tâche.", "Keine Aufgaben.", "할 일이 없어요.", "没有待办。", "Задач нет."], - "あなたの判断待ち": ["Waiting for your decision", "Pendientes de tu decisión", "Aguardando sua decisão", "En attente de votre décision", "Warten auf deine Entscheidung", "내 판단 대기", "等你决定", "Ждут вашего решения"], - "Agentや他の人からの確認依頼です。「確認する」から内容を見て判断します。": ["Review requests from Agents and others. Open “Review” to look and decide.", "Solicitudes de revisión de Agents y otras personas. Abre «Revisar» para ver y decidir.", "Pedidos de revisão de Agents e outras pessoas. Abra “Revisar” para ver e decidir.", "Demandes de vérification d’Agents et d’autres personnes. Ouvrez « Examiner » pour décider.", "Prüfanfragen von Agents und anderen. Öffne „Prüfen“, um zu entscheiden.", "Agent나 다른 사람의 확인 요청이에요. '확인하기'에서 내용을 보고 판단해요.", "来自 Agent 或他人的确认请求。点「查看」了解后再决定。", "Запросы на проверку от Agent и других. Откройте «Проверить», чтобы решить."], "日付を見直す": ["Reschedule", "Reprogramar", "Reagendar", "À replanifier", "Neu planen", "날짜 다시 보기", "重新安排日期", "Перенести"], "受け箱": ["Inbox", "Bandeja", "Caixa de entrada", "Boîte de réception", "Eingang", "받은 함", "收件箱", "Входящие"], - "日付のないものです。今日やるものだけ選べば十分です。": ["Tasks without a date. Just pick what you’ll do today.", "Tareas sin fecha. Basta con elegir lo de hoy.", "Tarefas sem data. Basta escolher o que fará hoje.", "Tâches sans date. Choisissez seulement celles d’aujourd’hui.", "Aufgaben ohne Datum. Wähle nur, was du heute machst.", "날짜가 없는 일이에요. 오늘 할 것만 고르면 충분해요.", "没有日期的事项。只选今天要做的就够了。", "Задачи без даты. Достаточно выбрать то, что сделаете сегодня."], "Agentが担当": ["With Agents", "Con Agents", "Com Agents", "Confiées aux Agents", "Bei Agents", "Agent 담당", "由 Agent 负责", "У Agent"], "OKを伝えました": ["Approved", "Aprobado", "Aprovado", "Approuvé", "Freigegeben", "OK를 전했어요", "已批准", "Одобрено"], "直してほしい点を伝えました": ["Changes requested", "Cambios solicitados", "Alterações solicitadas", "Modifications demandées", "Änderungen angefragt", "고칠 점을 전했어요", "已提出修改意见", "Запрошены правки"], @@ -237,10 +198,7 @@ export const COPY: Readonly> = { "受け取って {name} に回す": ["Accept and pass to {name}", "Aceptar y pasar a {name}", "Aceitar e passar para {name}", "Accepter et passer à {name}", "Annehmen und an {name} weitergeben", "받고 {name}에게 넘기기", "接收并转交 {name}", "Принять и передать {name}"], "受け取る": ["Accept", "Aceptar", "Aceitar", "Accepter", "Annehmen", "받기", "接收", "Принять"], "送る": ["Send", "Enviar", "Enviar", "Envoyer", "Senden", "보내기", "发送", "Отправить"], - "{name} からの確認依頼": ["Review request from {name}", "Solicitud de revisión de {name}", "Pedido de revisão de {name}", "Demande de vérification de {name}", "Prüfanfrage von {name}", "{name}의 확인 요청", "来自 {name} 的确认请求", "Запрос на проверку от {name}"], "{name} が「{ask}」を終えました": ["{name} finished “{ask}”", "{name} terminó «{ask}»", "{name} terminou “{ask}”", "{name} a terminé « {ask} »", "{name} hat „{ask}“ erledigt", "{name}이(가) 「{ask}」을(를) 끝냈어요", "{name} 完成了「{ask}」", "{name} закончил(а) «{ask}»"], - "{name} が「終わった」と報告しています": ["{name} reports it’s done", "{name} dice que terminó", "{name} diz que terminou", "{name} indique avoir terminé", "{name} meldet: fertig", "{name}이(가) 끝났다고 보고했어요", "{name} 报告已完成", "{name} сообщает, что готово"], - "元のタスクを見る": ["See the original task", "Ver la tarea original", "Ver a tarefa original", "Voir la tâche d’origine", "Ursprüngliche Aufgabe ansehen", "원래 작업 보기", "查看原任务", "Открыть исходную задачу"], "成果物を開く ↗": ["Open the result ↗", "Abrir el resultado ↗", "Abrir o resultado ↗", "Ouvrir le résultat ↗", "Ergebnis öffnen ↗", "결과물 열기 ↗", "打开成果 ↗", "Открыть результат ↗"], "成果物を開く": ["Open the result", "Abrir el resultado", "Abrir o resultado", "Ouvrir le résultat", "Ergebnis öffnen", "결과물 열기", "打开成果", "Открыть результат"], "中身を確認しました": ["I’ve checked it", "Lo he revisado", "Já conferi", "J’ai vérifié", "Ich habe es geprüft", "내용을 확인했어요", "我已确认内容", "Я проверил(а)"], @@ -259,40 +217,27 @@ export const COPY: Readonly> = { "{who} が「{title}」をしまいました": ["{who} archived “{title}”", "{who} archivó «{title}»", "{who} arquivou “{title}”", "{who} a archivé « {title} »", "{who} hat „{title}“ archiviert", "{who}이(가) 「{title}」을(를) 보관했어요", "{who} 归档了「{title}」", "{who}: в архиве «{title}»"], "{who} が「{title}」を更新しました": ["{who} updated “{title}”", "{who} actualizó «{title}»", "{who} atualizou “{title}”", "{who} a mis à jour « {title} »", "{who} hat „{title}“ aktualisiert", "{who}이(가) 「{title}」을(를) 업데이트했어요", "{who} 更新了「{title}」", "{who}: обновлено «{title}»"], "外す": ["Remove", "Quitar", "Remover", "Retirer", "Entfernen", "빼기", "移除", "Убрать"], - "{name} をギルドから外します。": ["{name} will leave the Guild. ", "{name} saldrá del Gremio. ", "{name} sairá da Guilda. ", "{name} quittera la Guilde. ", "{name} verlässt die Gilde. ", "{name}을(를) 길드에서 빼요. ", "将把 {name} 移出公会。", "{name} покинет Гильдию. "], - "担当中の {count} 件はそのまま残るので、必要なら担当を変えてください。": ["Its {count} tasks stay as they are, so reassign them if needed. ", "Sus {count} tareas se quedan igual; reasígnalas si hace falta. ", "As {count} tarefas continuam como estão; reatribua se precisar. ", "Ses {count} tâches restent telles quelles ; réattribuez-les si besoin. ", "Die {count} Aufgaben bleiben, wie sie sind; weise sie bei Bedarf neu zu. ", "담당 중인 {count}건은 그대로 남으니 필요하면 담당을 바꾸세요. ", "它负责的 {count} 项会保留,如有需要请更换负责人。", "Его задачи ({count}) останутся — при необходимости переназначьте их. "], - "外した Agent は担当に選べなくなり、MCP の紐付けも使えなくなります。": ["A removed Agent can’t be assigned, and its MCP links stop working.", "Un Agent quitado no se puede asignar y sus vínculos MCP dejan de funcionar.", "Um Agent removido não pode ser atribuído e seus vínculos MCP deixam de funcionar.", "Un Agent retiré ne peut plus être assigné et ses liens MCP cessent de fonctionner.", "Ein entfernter Agent kann nicht zugewiesen werden, und seine MCP-Verknüpfungen funktionieren nicht mehr.", "뺀 Agent는 담당으로 고를 수 없고 MCP 연결도 쓸 수 없게 돼요.", "移除后无法再指派该 Agent,其 MCP 关联也将失效。", "Убранного Agent нельзя назначить, и его связи MCP перестанут работать."], "{name} を外しました": ["Removed {name}", "Se quitó a {name}", "{name} removido", "{name} retiré", "{name} entfernt", "{name}을(를) 뺐어요", "已移除 {name}", "{name} убран(а)"], "外せませんでした。もう一度試してください。": ["Couldn’t remove. Please try again.", "No se pudo quitar. Inténtalo de nuevo.", "Não foi possível remover. Tente de novo.", "Retrait impossible. Réessayez.", "Entfernen fehlgeschlagen. Bitte erneut versuchen.", "빼지 못했어요. 다시 시도해 주세요.", "未能移除,请重试。", "Не удалось убрать. Попробуйте ещё раз."], "停止中": ["Paused", "En pausa", "Pausado", "En pause", "Pausiert", "중지됨", "已停用", "Остановлен"], - "前の作業待ち": ["Waiting on earlier work", "Esperando trabajo previo", "Aguardando trabalho anterior", "En attente d’un travail précédent", "Wartet auf vorherige Arbeit", "앞 작업 대기", "等待前置工作", "Ждёт предыдущей работы"], - "{count} 件を担当": ["{count} assigned", "{count} asignadas", "{count} atribuídas", "{count} attribuées", "{count} zugewiesen", "{count}건 담당", "负责 {count} 项", "Назначено: {count}"], "担当しているタスクはありません": ["No tasks assigned", "Sin tareas asignadas", "Nenhuma tarefa atribuída", "Aucune tâche assignée", "Keine Aufgaben zugewiesen", "담당 중인 작업이 없어요", "没有负责的任务", "Задач нет"], "いまは手が空いています": ["Free right now", "Libre ahora", "Livre agora", "Disponible pour l’instant", "Gerade frei", "지금은 한가해요", "目前空闲", "Сейчас свободен"], "いまやっている": ["Working on", "Haciendo", "Fazendo", "En cours", "In Arbeit", "진행 중", "正在做", "В работе"], "ほか {count} 件": ["+{count} more", "+{count} más", "+{count} mais", "+{count} de plus", "+{count} weitere", "외 {count}건", "另有 {count} 项", "ещё {count}"], - "Agent の名前(例:Scribe)": ["Agent name (e.g. Scribe)", "Nombre del Agent (p. ej., Scribe)", "Nome do Agent (ex.: Scribe)", "Nom de l’Agent (ex. : Scribe)", "Agent-Name (z. B. Scribe)", "Agent 이름 (예: Scribe)", "Agent 名称(例:Scribe)", "Имя Agent (напр., Scribe)"], - "Agent の名前": ["Agent name", "Nombre del Agent", "Nome do Agent", "Nom de l’Agent", "Agent-Name", "Agent 이름", "Agent 名称", "Имя Agent"], "例:scribe": ["e.g. scribe", "p. ej., scribe", "ex.: scribe", "ex. : scribe", "z. B. scribe", "예: scribe", "例:scribe", "напр., scribe"], "例:文書をまとめる": ["e.g. summarizes documents", "p. ej., resume documentos", "ex.: resume documentos", "ex. : résume des documents", "z. B. fasst Dokumente zusammen", "예: 문서를 정리해요", "例:整理文档", "напр., сводит документы"], - "何をする Agent か": ["What the Agent does", "Qué hace el Agent", "O que o Agent faz", "Ce que fait l’Agent", "Was der Agent macht", "Agent가 하는 일", "Agent 做什么", "Что делает Agent"], "例:下書きは箇条書きで。数字は必ず出典を書く。": ["e.g. drafts as bullet points; always cite numbers.", "p. ej., borradores en viñetas; cita siempre las cifras.", "ex.: rascunhos em tópicos; sempre cite os números.", "ex. : brouillons en puces ; toujours citer les chiffres.", "z. B. Entwürfe als Stichpunkte; Zahlen immer belegen.", "예: 초안은 글머리표로. 숫자에는 꼭 출처를.", "例:草稿用要点列表,数字务必注明出处。", "напр., черновики списком; у цифр всегда источник."], "お願いしておくこと": ["Standing instructions", "Instrucciones fijas", "Instruções fixas", "Consignes permanentes", "Dauerhafte Hinweise", "미리 부탁할 것", "常规说明", "Постоянные указания"], - "何をする Agent ?": ["What does this Agent do?", "¿Qué hace este Agent?", "O que este Agent faz?", "Que fait cet Agent ?", "Was macht dieser Agent?", "무슨 일을 하는 Agent인가요?", "这个 Agent 做什么?", "Что делает этот Agent?"], - "英小文字・数字・ハイフン。MCP で Agent を指すときに使います。あとから変えられません。": ["Lowercase letters, numbers and hyphens. Used to refer to the Agent over MCP. Can’t be changed later.", "Minúsculas, números y guiones. Se usa para nombrar al Agent en MCP. No se puede cambiar después.", "Letras minúsculas, números e hífens. Usado para indicar o Agent no MCP. Não pode ser alterado depois.", "Minuscules, chiffres et tirets. Sert à désigner l’Agent via MCP. Non modifiable ensuite.", "Kleinbuchstaben, Zahlen und Bindestriche. Bezeichnet den Agent über MCP. Später nicht änderbar.", "영문 소문자·숫자·하이픈. MCP에서 Agent를 가리킬 때 써요. 나중에 바꿀 수 없어요.", "小写字母、数字和连字符。用于在 MCP 中指代 Agent,之后无法更改。", "Строчные латинские буквы, цифры и дефисы. Используется для обращения к Agent через MCP. Позже не изменить."], "やってよいこと": ["Allowed to", "Puede", "Pode", "Autorisé à", "Darf", "할 수 있는 일", "允许", "Может"], "見るだけ": ["Read only", "Solo ver", "Só ver", "Lecture seule", "Nur lesen", "보기만", "只读", "Только смотреть"], "タスクを読むだけで、変えません": ["Reads tasks without changing them", "Lee tareas sin cambiarlas", "Lê tarefas sem alterá-las", "Lit les tâches sans les modifier", "Liest Aufgaben, ohne sie zu ändern", "작업을 읽기만 하고 바꾸지 않아요", "只读取任务,不做修改", "Читает задачи, не меняя их"], "タスクを進める": ["Move tasks forward", "Avanzar tareas", "Avançar tarefas", "Faire avancer les tâches", "Aufgaben voranbringen", "작업 진행하기", "推进任务", "Продвигать задачи"], "タスクを読み、状態や中身を書き換えます": ["Reads tasks and updates their status and content", "Lee tareas y actualiza su estado y contenido", "Lê tarefas e atualiza status e conteúdo", "Lit les tâches et met à jour leur état et contenu", "Liest Aufgaben und ändert Status und Inhalt", "작업을 읽고 상태와 내용을 바꿔요", "读取任务并修改状态和内容", "Читает задачи и меняет их статус и содержание"], "終わったら、あなたの確認を待つ": ["Wait for your check when done", "Esperar tu revisión al terminar", "Aguardar sua revisão ao terminar", "Attendre votre vérification à la fin", "Nach Abschluss auf deine Prüfung warten", "끝나면 내 확인을 기다리기", "完成后等待你确认", "После выполнения ждать вашей проверки"], - "お願いしておくこと(任意)": ["Standing instructions (optional)", "Instrucciones fijas (opcional)", "Instruções fixas (opcional)", "Consignes permanentes (facultatif)", "Dauerhafte Hinweise (optional)", "미리 부탁할 것 (선택)", "常规说明(可选)", "Постоянные указания (необязательно)"], - "Agent を登録": ["Register an Agent", "Registrar un Agent", "Cadastrar um Agent", "Inscrire un Agent", "Agent registrieren", "Agent 등록", "注册 Agent", "Зарегистрировать Agent"], "登録する": ["Register", "Registrar", "Cadastrar", "Inscrire", "Registrieren", "등록하기", "注册", "Зарегистрировать"], "名前を入れてください。": ["Please enter a name.", "Escribe un nombre.", "Digite um nome.", "Saisissez un nom.", "Bitte einen Namen eingeben.", "이름을 입력해 주세요.", "请输入名称。", "Введите имя."], "Agent ID は英小文字・数字・ハイフンで入れてください。": ["Use lowercase letters, numbers and hyphens for the Agent ID.", "Usa minúsculas, números y guiones en el ID del Agent.", "Use letras minúsculas, números e hífens no ID do Agent.", "Utilisez minuscules, chiffres et tirets pour l’ID de l’Agent.", "Verwende Kleinbuchstaben, Zahlen und Bindestriche für die Agent-ID.", "Agent ID는 영문 소문자·숫자·하이픈으로 입력해 주세요.", "Agent ID 请使用小写字母、数字和连字符。", "В ID Agent используйте строчные латинские буквы, цифры и дефисы."], "この Agent ID はもう使われています。": ["This Agent ID is already in use.", "Este ID de Agent ya está en uso.", "Este ID de Agent já está em uso.", "Cet ID d’Agent est déjà utilisé.", "Diese Agent-ID wird bereits verwendet.", "이 Agent ID는 이미 쓰이고 있어요.", "该 Agent ID 已被使用。", "Этот ID Agent уже используется."], - "{name} を登録しました。担当のリレーで選べます。": ["Registered {name}. You can now pick it as an owner.", "{name} registrado. Ya puedes elegirlo como responsable.", "{name} cadastrado. Já pode escolhê-lo como responsável.", "{name} inscrit. Vous pouvez le choisir comme responsable.", "{name} registriert. Du kannst ihn jetzt als Zuständigen wählen.", "{name}을(를) 등록했어요. 담당 릴레이에서 고를 수 있어요.", "已注册 {name},可在负责人接力中选择。", "{name} зарегистрирован(а). Теперь его можно выбрать исполнителем."], "登録できませんでした。もう一度試してください。": ["Couldn’t register. Please try again.", "No se pudo registrar. Inténtalo de nuevo.", "Não foi possível cadastrar. Tente de novo.", "Inscription impossible. Réessayez.", "Registrierung fehlgeschlagen. Bitte erneut versuchen.", "등록하지 못했어요. 다시 시도해 주세요.", "未能注册,请重试。", "Не удалось зарегистрировать. Попробуйте ещё раз."], "MCP の接続URL": ["MCP connection URL", "URL de conexión MCP", "URL de conexão MCP", "URL de connexion MCP", "MCP-Verbindungs-URL", "MCP 연결 URL", "MCP 连接 URL", "URL подключения MCP"], "コピー": ["Copy", "Copiar", "Copiar", "Copier", "Kopieren", "복사", "复制", "Копировать"], @@ -301,27 +246,17 @@ export const COPY: Readonly> = { "接続中": ["Connected", "Conectado", "Conectado", "Connecté", "Verbunden", "연결됨", "已连接", "Подключено"], "接続が切れています": ["Disconnected", "Desconectado", "Desconectado", "Déconnecté", "Getrennt", "연결이 끊겼어요", "已断开", "Отключено"], "最後に使ったのは {time}": ["Last used {time}", "Último uso {time}", "Último uso {time}", "Dernière utilisation {time}", "Zuletzt verwendet {time}", "마지막 사용 {time}", "上次使用 {time}", "Последнее использование {time}"], - "Agent を紐付ける": ["Link an Agent", "Vincular un Agent", "Vincular um Agent", "Lier un Agent", "Agent verknüpfen", "Agent 연결", "关联 Agent", "Привязать Agent"], - "この接続は {names} として動きます。": ["This connection acts as {names}.", "Esta conexión actúa como {names}.", "Esta conexão atua como {names}.", "Cette connexion agit en tant que {names}.", "Diese Verbindung handelt als {names}.", "이 연결은 {names}(으)로 동작해요.", "此连接以 {names} 的身份运行。", "Это подключение работает как {names}."], - "まだ Agent に紐付いていません。紐付けると、この接続での作業がその Agent の担当として記録されます。": ["Not linked to an Agent yet. Once linked, work done through this connection is recorded as that Agent’s.", "Aún no está vinculada a un Agent. Al vincularla, el trabajo hecho por esta conexión se registra como de ese Agent.", "Ainda não vinculada a um Agent. Depois de vincular, o trabalho feito por esta conexão é registrado como desse Agent.", "Pas encore liée à un Agent. Une fois liée, le travail fait via cette connexion est attribué à cet Agent.", "Noch mit keinem Agent verknüpft. Danach wird Arbeit über diese Verbindung diesem Agent zugeordnet.", "아직 Agent와 연결되지 않았어요. 연결하면 이 연결로 한 작업이 그 Agent 담당으로 기록돼요.", "尚未关联 Agent。关联后,通过此连接完成的工作将记在该 Agent 名下。", "Пока не привязано к Agent. После привязки работа через это подключение будет записываться на этого Agent."], "紐付ける Agent": ["Agents to link", "Agents a vincular", "Agents para vincular", "Agents à lier", "Zu verknüpfende Agents", "연결할 Agent", "要关联的 Agent", "Привязываемые Agent"], "先にギルドで Agent を登録してください。": ["Register an Agent in the Guild first.", "Primero registra un Agent en el Gremio.", "Primeiro cadastre um Agent na Guilda.", "Inscrivez d’abord un Agent dans la Guilde.", "Registriere zuerst einen Agent in der Gilde.", "먼저 길드에서 Agent를 등록해 주세요.", "请先在公会中注册 Agent。", "Сначала зарегистрируйте Agent в Гильдии."], "この接続で動かす Agent を選んでください。複数選ぶと、最初の Agent が既定になります。": ["Choose the Agents this connection runs as. With several, the first is the default.", "Elige los Agents de esta conexión. Si eliges varios, el primero es el predeterminado.", "Escolha os Agents desta conexão. Com vários, o primeiro é o padrão.", "Choisissez les Agents de cette connexion. S’il y en a plusieurs, le premier est celui par défaut.", "Wähle die Agents für diese Verbindung. Bei mehreren ist der erste der Standard.", "이 연결로 움직일 Agent를 고르세요. 여러 개를 고르면 첫 번째가 기본이 돼요.", "选择此连接要运行的 Agent。选择多个时,第一个为默认。", "Выберите Agent для этого подключения. Если несколько, первый — по умолчанию."], "{names} に紐付けました": ["Linked to {names}", "Vinculada a {names}", "Vinculada a {names}", "Liée à {names}", "Mit {names} verknüpft", "{names}에 연결했어요", "已关联 {names}", "Привязано к {names}"], "紐付けを外しました": ["Unlinked", "Desvinculada", "Desvinculada", "Lien retiré", "Verknüpfung entfernt", "연결을 해제했어요", "已取消关联", "Привязка снята"], - "MCP 接続": ["MCP connections", "Conexiones MCP", "Conexões MCP", "Connexions MCP", "MCP-Verbindungen", "MCP 연결", "MCP 连接", "Подключения MCP"], - "Claude や Codex などの MCP クライアントにこの URL を追加してサインインすると、下に接続が表示されます。": ["Add this URL to an MCP client such as Claude or Codex and sign in; the connection then appears below.", "Añade esta URL a un cliente MCP como Claude o Codex e inicia sesión; la conexión aparecerá abajo.", "Adicione esta URL a um cliente MCP como Claude ou Codex e entre; a conexão aparecerá abaixo.", "Ajoutez cette URL à un client MCP comme Claude ou Codex et connectez-vous ; la connexion apparaîtra ci-dessous.", "Füge diese URL in einem MCP-Client wie Claude oder Codex hinzu und melde dich an; die Verbindung erscheint dann unten.", "Claude나 Codex 같은 MCP 클라이언트에 이 URL을 추가하고 로그인하면 아래에 연결이 표시돼요.", "在 Claude、Codex 等 MCP 客户端中添加此 URL 并登录后,连接会显示在下方。", "Добавьте этот URL в MCP-клиент (например, Claude или Codex) и войдите — подключение появится ниже."], "接続を読み込んでいます…": ["Loading connections…", "Cargando conexiones…", "Carregando conexões…", "Chargement des connexions…", "Verbindungen werden geladen…", "연결을 불러오는 중…", "正在加载连接…", "Загрузка подключений…"], "接続を読み込めませんでした。": ["Couldn’t load connections.", "No se pudieron cargar las conexiones.", "Não foi possível carregar as conexões.", "Impossible de charger les connexions.", "Verbindungen konnten nicht geladen werden.", "연결을 불러오지 못했어요.", "未能加载连接。", "Не удалось загрузить подключения."], "もう一度": ["Try again", "Reintentar", "Tentar de novo", "Réessayer", "Erneut versuchen", "다시 시도", "重试", "Повторить"], "まだ接続はありません。": ["No connections yet.", "Aún no hay conexiones.", "Ainda não há conexões.", "Aucune connexion pour l’instant.", "Noch keine Verbindungen.", "아직 연결이 없어요.", "还没有连接。", "Подключений пока нет."], - "Agentや仲間と一緒に進めるための場所です。あなたが決めることだけ、ここに集まります。": ["Where you work with Agents and teammates. Only the things you need to decide gather here.", "Donde trabajas con Agents y compañeros. Aquí solo se reúne lo que debes decidir.", "Onde você trabalha com Agents e colegas. Aqui só chega o que você precisa decidir.", "L’endroit où vous avancez avec des Agents et des coéquipiers. Seules les décisions qui vous reviennent arrivent ici.", "Hier arbeitest du mit Agents und Mitstreitern. Nur deine Entscheidungen sammeln sich hier.", "Agent나 동료와 함께 진행하는 곳이에요. 내가 정할 일만 여기에 모여요.", "与 Agent 和伙伴一起推进的地方。只有需要你决定的事会汇集在这里。", "Здесь вы работаете с Agent и командой. Сюда попадает только то, что решать вам."], - "あなたの判断待ち({count})": ["Waiting for your decision ({count})", "Pendientes de tu decisión ({count})", "Aguardando sua decisão ({count})", "En attente de votre décision ({count})", "Warten auf deine Entscheidung ({count})", "내 판단 대기 ({count})", "等你决定({count})", "Ждут вашего решения ({count})"], "いま、あなたの判断を待っているものはありません。": ["Nothing is waiting for your decision right now.", "Ahora no hay nada pendiente de tu decisión.", "Nada aguarda sua decisão agora.", "Rien n’attend votre décision pour l’instant.", "Gerade wartet nichts auf deine Entscheidung.", "지금 내 판단을 기다리는 일은 없어요.", "目前没有等你决定的事。", "Сейчас ничего не ждёт вашего решения."], - "あとで見る({count})": ["Later ({count})", "Más tarde ({count})", "Depois ({count})", "Plus tard ({count})", "Später ({count})", "나중에 보기 ({count})", "稍后查看({count})", "Позже ({count})"], "判断待ちに戻す": ["Back to decisions", "Volver a decisiones", "Voltar às decisões", "Remettre dans les décisions", "Zurück zu Entscheidungen", "판단 대기로 되돌리기", "放回待决定", "Вернуть в решения"], - "人と Agent": ["People and Agents", "Personas y Agents", "Pessoas e Agents", "Personnes et Agents", "Menschen und Agents", "사람과 Agent", "成员与 Agent", "Люди и Agent"], - "+ Agent を登録": ["+ Register an Agent", "+ Registrar Agent", "+ Cadastrar Agent", "+ Inscrire un Agent", "+ Agent registrieren", "+ Agent 등록", "+ 注册 Agent", "+ Зарегистрировать Agent"], "オーナー": ["Owner", "Propietario", "Proprietário", "Propriétaire", "Inhaber", "소유자", "所有者", "Владелец"], "メンバー": ["Member", "Miembro", "Membro", "Membre", "Mitglied", "멤버", "成员", "Участник"], "最近の活動": ["Recent activity", "Actividad reciente", "Atividade recente", "Activité récente", "Letzte Aktivitäten", "최근 활동", "最近动态", "Недавние действия"], @@ -331,12 +266,8 @@ export const COPY: Readonly> = { "ゲーム要素を表示します": ["Showing game elements", "Mostrando elementos de juego", "Mostrando elementos de jogo", "Éléments de jeu affichés", "Spielelemente werden angezeigt", "게임 요소를 표시해요", "显示游戏元素", "Игровые элементы показаны"], "ゲーム要素を隠しました": ["Game elements hidden", "Elementos de juego ocultos", "Elementos de jogo ocultos", "Éléments de jeu masqués", "Spielelemente ausgeblendet", "게임 요소를 숨겼어요", "已隐藏游戏元素", "Игровые элементы скрыты"], "表示テーマ": ["Theme", "Tema", "Tema", "Thème", "Design", "테마", "主题", "Тема"], - "端末に合わせる": ["Match device", "Según el dispositivo", "Seguir o dispositivo", "Comme l’appareil", "Wie Gerät", "기기 설정 따르기", "跟随设备", "Как на устройстве"], "ライト": ["Light", "Claro", "Claro", "Clair", "Hell", "라이트", "浅色", "Светлая"], "ダーク": ["Dark", "Oscuro", "Escuro", "Sombre", "Dunkel", "다크", "深色", "Тёмная"], - "完了したら紙吹雪でお祝いする": ["Celebrate completions with confetti", "Celebrar con confeti al terminar", "Comemorar conclusões com confete", "Fêter les tâches finies avec des confettis", "Erledigtes mit Konfetti feiern", "완료하면 꽃가루로 축하하기", "完成时撒彩纸庆祝", "Праздновать выполнение конфетти"], - "ゲーム要素(レベル・XP)を表示する": ["Show game elements (level, XP)", "Mostrar elementos de juego (nivel, XP)", "Mostrar elementos de jogo (nível, XP)", "Afficher les éléments de jeu (niveau, XP)", "Spielelemente anzeigen (Level, XP)", "게임 요소 (레벨·XP) 표시", "显示游戏元素(等级、XP)", "Показывать игровые элементы (уровень, XP)"], - "Relay Forge(詳しい画面)を開く": ["Open Relay Forge (detailed view)", "Abrir Relay Forge (vista detallada)", "Abrir Relay Forge (visão detalhada)", "Ouvrir Relay Forge (vue détaillée)", "Relay Forge öffnen (Detailansicht)", "Relay Forge (자세한 화면) 열기", "打开 Relay Forge(详细视图)", "Открыть Relay Forge (подробный вид)"], "サインアウト": ["Sign out", "Cerrar sesión", "Sair", "Se déconnecter", "Abmelden", "로그아웃", "退出登录", "Выйти"], "作業中": ["Working", "En curso", "Em andamento", "En cours", "In Arbeit", "작업 중", "进行中", "В работе"], "判断待ち {count} 件": ["{count} decisions waiting", "{count} decisiones pendientes", "{count} decisões pendentes", "{count} décisions en attente", "{count} Entscheidungen offen", "판단 대기 {count}건", "{count} 项待决定", "Ждут решения: {count}"], @@ -360,22 +291,16 @@ export const COPY: Readonly> = { "{ask}が終わりました。確認をお願いします。": ["“{ask}” is done. Please take a look.", "«{ask}» está listo. Revísalo, por favor.", "“{ask}” está pronto. Dê uma olhada, por favor.", "« {ask} » est terminé. Merci de vérifier.", "„{ask}“ ist fertig. Bitte prüfen.", "「{ask}」이(가) 끝났어요. 확인 부탁해요.", "「{ask}」已完成,请确认。", "«{ask}» готово. Пожалуйста, проверьте."], "作業": ["The work", "El trabajo", "O trabalho", "Le travail", "Die Arbeit", "작업", "工作", "Работа"], "読み込んでいます": ["Loading", "Cargando", "Carregando", "Chargement", "Wird geladen", "불러오는 중", "加载中", "Загрузка"], - "やることを取り出しています。少しだけ待ってください。": ["Fetching your tasks. Just a moment.", "Trayendo tus tareas. Un momento.", "Buscando suas tarefas. Só um instante.", "Récupération de vos tâches. Un instant.", "Deine Aufgaben werden geholt. Einen Moment.", "할 일을 가져오고 있어요. 잠시만 기다려 주세요.", "正在取出你的待办,请稍候。", "Загружаем ваши задачи. Минутку."], "自分": ["Me", "Yo", "Eu", "Moi", "Ich", "나", "我", "Я"], "読み込めませんでした": ["Couldn’t load", "No se pudo cargar", "Não foi possível carregar", "Chargement impossible", "Laden fehlgeschlagen", "불러오지 못했어요", "未能加载", "Не удалось загрузить"], "通信を確認して、もう一度試してください。データは変更されていません。": ["Check your connection and try again. Nothing was changed.", "Revisa tu conexión e inténtalo de nuevo. No se cambió nada.", "Verifique a conexão e tente de novo. Nada foi alterado.", "Vérifiez votre connexion et réessayez. Rien n’a été modifié.", "Prüfe deine Verbindung und versuch es erneut. Nichts wurde geändert.", "연결을 확인하고 다시 시도해 주세요. 데이터는 바뀌지 않았어요.", "请检查网络后重试。数据没有改动。", "Проверьте подключение и попробуйте снова. Ничего не изменилось."], "再試行": ["Try again", "Reintentar", "Tentar de novo", "Réessayer", "Erneut versuchen", "다시 시도", "重试", "Повторить"], "デモを見る": ["Try the demo", "Ver la demo", "Ver a demo", "Voir la démo", "Demo ansehen", "데모 보기", "查看演示", "Посмотреть демо"], - "ようこそ": ["Welcome", "Te damos la bienvenida", "Boas-vindas", "Bienvenue", "Willkommen", "환영해요", "欢迎", "Добро пожаловать"], - "サインインの状態を確かめています。": ["Checking your sign-in.", "Comprobando tu sesión.", "Verificando seu login.", "Vérification de votre connexion.", "Anmeldung wird geprüft.", "로그인 상태를 확인하고 있어요.", "正在确认登录状态。", "Проверяем вход."], "ひとつずつ、迷わず進める。": ["One thing at a time, without getting lost.", "Una cosa a la vez, sin perderte.", "Uma coisa de cada vez, sem se perder.", "Une chose à la fois, sans se perdre.", "Eins nach dem anderen, ohne dich zu verlieren.", "하나씩, 헤매지 않고.", "一次一件,不再迷茫。", "По одному делу, не теряясь."], - "Googleでサインインすると、いつものやることがそのまま使えます。": ["Sign in with Google to pick up your usual tasks.", "Inicia sesión con Google para usar tus tareas de siempre.", "Entre com o Google para usar suas tarefas de sempre.", "Connectez-vous avec Google pour retrouver vos tâches.", "Melde dich mit Google an, um deine Aufgaben zu nutzen.", "Google로 로그인하면 평소 할 일을 그대로 쓸 수 있어요.", "用 Google 登录,即可沿用你的待办。", "Войдите через Google, чтобы продолжить с привычными задачами."], "Googleでサインイン": ["Sign in with Google", "Iniciar sesión con Google", "Entrar com o Google", "Se connecter avec Google", "Mit Google anmelden", "Google로 로그인", "使用 Google 登录", "Войти через Google"], "サインインできませんでした": ["Couldn’t sign in", "No se pudo iniciar sesión", "Não foi possível entrar", "Connexion impossible", "Anmeldung fehlgeschlagen", "로그인하지 못했어요", "未能登录", "Не удалось войти"], - "もう一度試してください。": ["Please try again.", "Inténtalo de nuevo.", "Tente de novo.", "Veuillez réessayer.", "Bitte erneut versuchen.", "다시 시도해 주세요.", "请重试。", "Попробуйте ещё раз."], "今やることは、いつも1つだけ": ["Always just one thing to do now", "Siempre una sola cosa para ahora", "Sempre só uma coisa para agora", "Toujours une seule chose à faire maintenant", "Immer nur eine Sache für jetzt", "지금 할 일은 언제나 하나만", "现在要做的永远只有一件", "Сейчас — всегда только одно дело"], "予定は時間の帯で見える": ["See your plan as bands of time", "Tu plan, en franjas de tiempo", "Veja seu plano em faixas de tempo", "Votre planning en bandes de temps", "Dein Plan als Zeitbänder", "일정은 시간 띠로 보여요", "日程以时间条呈现", "План — в виде полос времени"], - "Agent に任せて、あなたは判断だけ": ["Leave it to Agents; you just decide", "Delega en Agents; tú solo decides", "Deixe com Agents; você só decide", "Confiez aux Agents, vous ne faites que décider", "Überlass es Agents, du entscheidest nur", "Agent에게 맡기고, 나는 판단만", "交给 Agent,你只需做决定", "Поручите Agent — вам только решать"], "受け取り待ち": ["Ready to pick up", "Lista para tomar", "Pronta para pegar", "Prête à être prise", "Bereit zur Übernahme", "인수 대기", "待接手", "Ждёт принятия"], "止まっている": ["Blocked", "Bloqueada", "Bloqueada", "Bloquée", "Blockiert", "멈춤", "受阻", "Заблокировано"], "確認してほしい": ["Needs review", "Pide revisión", "Precisa de revisão", "À vérifier", "Prüfung nötig", "확인 요청", "待确认", "Нужна проверка"], @@ -386,7 +311,6 @@ export const COPY: Readonly> = { "{hours}時間{minutes}分": ["{hours} h {minutes} min", "{hours} h {minutes} min", "{hours} h {minutes} min", "{hours} h {minutes} min", "{hours} Std. {minutes} Min.", "{hours}시간 {minutes}분", "{hours} 小时 {minutes} 分钟", "{hours} ч {minutes} мин"], "言語": ["Language", "Idioma", "Idioma", "Langue", "Sprache", "언어", "语言", "Язык"], "本文へ移動": ["Skip to content", "Ir al contenido", "Ir para o conteúdo", "Aller au contenu", "Zum Inhalt springen", "본문으로 이동", "跳到正文", "К содержимому"], - "やることを探す": ["Search tasks", "Buscar tareas", "Buscar tarefas", "Rechercher des tâches", "Aufgaben suchen", "할 일 찾기", "搜索待办", "Поиск задач"], "見つかりませんでした。": ["Nothing found.", "No se encontró nada.", "Nada encontrado.", "Aucun résultat.", "Nichts gefunden.", "찾지 못했어요.", "没有找到。", "Ничего не найдено."], "しまったもの": ["Put away", "Archivada", "Arquivada", "Archivée", "Archiviert", "보관됨", "已归档", "В архиве"], "戻す": ["Restore", "Restaurar", "Restaurar", "Restaurer", "Wiederherstellen", "되돌리기", "恢复", "Вернуть"], @@ -394,19 +318,17 @@ export const COPY: Readonly> = { "しまう": ["Put away", "Archivar", "Arquivar", "Archiver", "Archivieren", "보관", "归档", "В архив"], "しまいました。「やること」の検索から戻せます。": ["Put away. You can bring it back from Tasks search.", "Archivada. Puedes recuperarla desde la búsqueda de Tareas.", "Arquivada. Dá para restaurar pela busca em Tarefas.", "Archivée. Vous pouvez la restaurer depuis la recherche des Tâches.", "Archiviert. Über die Suche in Aufgaben kannst du sie zurückholen.", "보관했어요. '할 일' 검색에서 되돌릴 수 있어요.", "已归档。可在「待办」搜索中恢复。", "В архиве. Вернуть можно через поиск в «Задачах»."], "時間の記録": ["Time tracking", "Registro de tiempo", "Registro de tempo", "Suivi du temps", "Zeiterfassung", "시간 기록", "时间记录", "Учёт времени"], - "プラン": ["Plan", "Plan", "Plano", "Forfait", "Tarif", "플랜", "套餐", "Тариф"], "利用プランと支払い": ["Your plan and billing", "Tu plan y pagos", "Seu plano e pagamentos", "Votre forfait et facturation", "Tarif und Abrechnung", "이용 플랜과 결제", "套餐与付款", "Тариф и оплата"], "← 設定": ["← Settings", "← Ajustes", "← Configurações", "← Réglages", "← Einstellungen", "← 설정", "← 设置", "← Настройки"], "サインインすると使えます。デモでは表示できません。": ["Available after you sign in. Not shown in the demo.", "Disponible al iniciar sesión. No se muestra en la demo.", "Disponível ao entrar. Não aparece na demo.", "Disponible après connexion. Non affiché dans la démo.", "Nach der Anmeldung verfügbar. In der Demo nicht sichtbar.", "로그인하면 쓸 수 있어요. 데모에서는 표시되지 않아요.", "登录后可用,演示中不显示。", "Доступно после входа. В демо не показывается."], "予定に入れました": ["Added to your plan", "Añadida al plan", "Adicionada à agenda", "Ajoutée au planning", "Eingeplant", "일정에 넣었어요", "已加入计划", "Добавлено в план"], "つながり": ["Network", "Red", "Rede", "Réseau", "Netzwerk", "연결도", "关系图", "Связи"], - "タスクと人と連携の関係を図で見る": ["See how tasks, people and services connect", "Ver cómo se conectan tareas, personas y servicios", "Ver como tarefas, pessoas e serviços se ligam", "Voir les liens entre tâches, personnes et services", "Sehen, wie Aufgaben, Menschen und Dienste zusammenhängen", "할 일·사람·연동의 관계를 그림으로 보기", "以图查看待办、成员与集成的关系", "Как связаны задачи, люди и сервисы"], "バトル": ["Battle", "Batalla", "Batalha", "Combat", "Kampf", "배틀", "战斗", "Битва"], "終えたタスクで戦うゲーム": ["A game powered by finished tasks", "Un juego impulsado por tareas terminadas", "Um jogo movido a tarefas concluídas", "Un jeu alimenté par vos tâches finies", "Ein Spiel mit erledigten Aufgaben", "끝낸 할 일로 싸우는 게임", "用完成的待办来战斗的游戏", "Игра на основе выполненных задач"], "連携": ["Connections", "Conexiones", "Conexões", "Connexions", "Verbindungen", "연동", "集成", "Интеграции"], "Google などの外部サービスと同期": ["Sync with Google and other services", "Sincronizar con Google y otros servicios", "Sincronizar com Google e outros serviços", "Synchroniser avec Google et d’autres services", "Mit Google und anderen Diensten synchronisieren", "Google 등 외부 서비스와 동기화", "与 Google 等外部服务同步", "Синхронизация с Google и другими сервисами"], "スキル": ["Skills", "Habilidades", "Habilidades", "Compétences", "Fähigkeiten", "스킬", "技能", "Навыки"], - "Agent が使える MCP ツールの一覧": ["MCP tools your Agents can use", "Herramientas MCP que pueden usar tus Agents", "Ferramentas MCP que seus Agents podem usar", "Outils MCP utilisables par vos Agents", "MCP-Werkzeuge für deine Agents", "Agent가 쓸 수 있는 MCP 도구 목록", "Agent 可用的 MCP 工具", "Инструменты MCP для ваших Agent"], + "Agentが使えるMCPツールの一覧": ["MCP tools your Agents can use", "Herramientas MCP que pueden usar tus Agents", "Ferramentas MCP que seus Agents podem usar", "Outils MCP utilisables par vos Agents", "MCP-Werkzeuge für deine Agents", "Agent가 쓸 수 있는 MCP 도구 목록", "Agent 可用的 MCP 工具", "Инструменты MCP для ваших Agent"], "プロフィール": ["Profile", "Perfil", "Perfil", "Profil", "Profil", "프로필", "个人资料", "Профиль"], "表示名・ユーザー名・ひとこと": ["Display name, username and bio", "Nombre, usuario y bio", "Nome, usuário e bio", "Nom, identifiant et bio", "Anzeigename, Benutzername und Bio", "표시 이름·사용자 이름·한마디", "显示名称、用户名与简介", "Имя, логин и о себе"], "表示名": ["Display name", "Nombre visible", "Nome de exibição", "Nom affiché", "Anzeigename", "표시 이름", "显示名称", "Отображаемое имя"], @@ -422,14 +344,11 @@ export const COPY: Readonly> = { "{name} の接続を切ります。もう一度使うには、クライアント側でサインインし直します。": ["{name} will be disconnected. To use it again, sign in from the client.", "{name} se desconectará. Para volver a usarlo, inicia sesión desde el cliente.", "{name} será desconectado. Para usar de novo, entre pelo cliente.", "{name} sera déconnecté. Pour le réutiliser, reconnectez-vous depuis le client.", "{name} wird getrennt. Zum erneuten Nutzen im Client wieder anmelden.", "{name}의 연결을 끊어요. 다시 쓰려면 클라이언트에서 다시 로그인해요.", "将断开 {name}。如需再次使用,请在客户端重新登录。", "{name} будет отключён. Чтобы снова пользоваться, войдите в клиенте."], "{name} の接続を切りました": ["Disconnected {name}", "{name} desconectado", "{name} desconectado", "{name} déconnecté", "{name} getrennt", "{name}의 연결을 끊었어요", "已断开 {name}", "{name} отключён"], "接続を切れませんでした。もう一度試してください。": ["Couldn’t disconnect. Please try again.", "No se pudo desconectar. Inténtalo de nuevo.", "Não foi possível desconectar. Tente de novo.", "Déconnexion impossible. Réessayez.", "Trennen fehlgeschlagen. Bitte erneut versuchen.", "연결을 끊지 못했어요. 다시 시도해 주세요.", "未能断开,请重试。", "Не удалось отключить. Попробуйте ещё раз."], - "まとめ": ["Overview", "Resumen", "Resumo", "Vue d’ensemble", "Übersicht", "모아 보기", "概览", "Обзор"], - "表・ボード・ガント": ["Table, board, Gantt", "Tabla, tablero, Gantt", "Tabela, quadro, Gantt", "Tableau, board, Gantt", "Tabelle, Board, Gantt", "표·보드·간트", "表格·看板·甘特图", "Таблица, доска, Гант"], "月": ["Month", "Mes", "Mês", "Mois", "Monat", "월", "月", "Месяц"], "この作業の記録:{time}": ["Time on this task: {time}", "Tiempo en esta tarea: {time}", "Tempo nesta tarefa: {time}", "Temps sur cette tâche : {time}", "Zeit für diese Aufgabe: {time}", "이 작업의 기록: {time}", "此任务的记录:{time}", "Время на задачу: {time}"], "記録の履歴": ["Time history", "Historial de tiempo", "Histórico de tempo", "Historique du temps", "Zeitverlauf", "기록 이력", "记录历史", "История времени"], "薬を飲む": ["Take medicine", "Tomar la medicina", "Tomar o remédio", "Prendre le médicament", "Medikament nehmen", "약 먹기", "吃药", "Принять лекарство"], "図書館は14時台が空いています": ["The library is quiet around 2 pm", "La biblioteca está tranquila sobre las 14:00", "A biblioteca fica vazia por volta das 14h", "La bibliothèque est calme vers 14 h", "Die Bibliothek ist gegen 14 Uhr ruhig", "도서관은 오후 2시대가 한산해요", "图书馆下午 2 点左右人少", "В библиотеке около 14:00 свободно"], - "{day}に追加しました": ["Added to {day}", "Añadido a {day}", "Adicionado a {day}", "Ajouté à {day}", "Zu {day} hinzugefügt", "{day}에 추가했어요", "已添加到{day}", "Добавлено на {day}"], "{name}にステップに分けてもらう": ["Ask {name} to split it into steps", "Pedir a {name} que lo divida en pasos", "Pedir a {name} para dividir em etapas", "Demander à {name} de découper en étapes", "{name} in Schritte aufteilen lassen", "{name}에게 단계로 나눠 달라고 하기", "请 {name} 拆成步骤", "Попросить {name} разбить на шаги"], "ステップに分けて、各ステップに目安と絵文字を付けてください。終わったら私に戻してください。": ["Please split this into steps, each with an estimate and an emoji, then hand it back to me.", "Divide esto en pasos, cada uno con una estimación y un emoji, y luego devuélvemelo.", "Divida isto em etapas, cada uma com estimativa e um emoji, e depois devolva para mim.", "Découpe ceci en étapes, chacune avec une estimation et un emoji, puis rends-la-moi.", "Bitte in Schritte aufteilen, jeder mit Schätzung und Emoji, dann an mich zurückgeben.", "단계로 나누고 단계마다 예상 시간과 이모지를 붙인 뒤 저에게 돌려주세요.", "请拆成步骤,每步写上预计时间和表情,完成后交还给我。", "Разбей на шаги, у каждого — оценка и эмодзи, потом верни мне."], "絵文字を変える": ["Change emoji", "Cambiar emoji", "Trocar emoji", "Changer l’emoji", "Emoji ändern", "이모지 바꾸기", "更换表情", "Сменить эмодзи"], @@ -438,14 +357,8 @@ export const COPY: Readonly> = { "この時間に入れる": ["Use this time", "Usar este horario", "Usar este horário", "Prendre ce créneau", "Diese Zeit nehmen", "이 시간에 넣기", "排在这个时间", "Поставить на это время"], "断る": ["Decline", "Rechazar", "Recusar", "Refuser", "Ablehnen", "거절", "拒绝", "Отклонить"], "提案を断りました": ["Suggestion declined", "Sugerencia rechazada", "Sugestão recusada", "Suggestion refusée", "Vorschlag abgelehnt", "제안을 거절했어요", "已拒绝建议", "Предложение отклонено"], - "今週のふりかえり": ["This week so far", "Esta semana hasta ahora", "Esta semana até agora", "Cette semaine jusqu’ici", "Diese Woche bisher", "이번 주 돌아보기", "本周回顾", "Эта неделя"], - "終えた": ["Finished", "Terminadas", "Concluídas", "Terminées", "Erledigt", "끝낸 일", "已完成", "Сделано"], - "{count}件": ["{count}", "{count}", "{count}", "{count}", "{count}", "{count}건", "{count} 项", "{count}"], - "予定した時間": ["Planned time", "Tiempo planificado", "Tempo planejado", "Temps prévu", "Geplante Zeit", "예정한 시간", "计划时间", "Запланировано"], - "習慣({count})": ["Habits ({count})", "Hábitos ({count})", "Hábitos ({count})", "Habitudes ({count})", "Gewohnheiten ({count})", "습관 ({count})", "习惯({count})", "Привычки ({count})"], "この2週間。埋まった点が、できた日です。": ["The last two weeks. A filled dot is a day it was done.", "Las dos últimas semanas. Un punto lleno es un día hecho.", "As últimas duas semanas. Um ponto cheio é um dia feito.", "Les deux dernières semaines. Un point plein = un jour fait.", "Die letzten zwei Wochen. Ein voller Punkt ist ein erledigter Tag.", "지난 2주. 채워진 점이 해낸 날이에요.", "最近两周。实心点是完成的日子。", "Последние две недели. Закрашенная точка — день, когда сделано."], "この2週間で {count} 日": ["{count} days in the last two weeks", "{count} días en las dos últimas semanas", "{count} dias nas últimas duas semanas", "{count} jours sur les deux dernières semaines", "{count} Tage in den letzten zwei Wochen", "지난 2주 중 {count}일", "最近两周 {count} 天", "{count} дн. за две недели"], - "キーボード:N か / で書き出し、J と K で行を移動、X で完了、S で今のタスクを始める": ["Keyboard: N or / to write something down, J and K to move between rows, X to finish, S to start the current task", "Teclado: N o / para anotar, J y K para moverte entre filas, X para terminar, S para empezar la tarea actual", "Teclado: N ou / para anotar, J e K para mover entre linhas, X para concluir, S para começar a tarefa atual", "Clavier : N ou / pour noter, J et K pour changer de ligne, X pour terminer, S pour lancer la tâche en cours", "Tastatur: N oder / zum Notieren, J und K zum Wechseln der Zeile, X zum Erledigen, S zum Starten der aktuellen Aufgabe", "키보드: N 또는 /로 적기, J와 K로 줄 이동, X로 완료, S로 지금 할 일 시작", "键盘:N 或 / 记下待办,J 和 K 在行间移动,X 完成,S 开始当前任务", "Клавиатура: N или / — записать, J и K — по строкам, X — готово, S — начать текущую задачу"], "いつもの通知設定": ["Usual notification settings", "Ajustes de notificación habituales", "Configuração de notificações padrão", "Réglages de notification habituels", "Übliche Benachrichtigungseinstellungen", "평소 알림 설정", "常用通知设置", "Обычные настройки уведомлений"], "知らせない": ["Don't remind", "No avisar", "Não avisar", "Ne pas prévenir", "Nicht erinnern", "알리지 않기", "不提醒", "Не напоминать"], "始まる時刻": ["At the start", "Al empezar", "No início", "Au début", "Zum Start", "시작 시각", "开始时", "В момент начала"], @@ -471,9 +384,7 @@ export const COPY: Readonly> = { "この端末で通知を受け取る": ["Get notifications on this device", "Recibir notificaciones en este dispositivo", "Receber notificações neste dispositivo", "Recevoir les notifications sur cet appareil", "Benachrichtigungen auf diesem Gerät erhalten", "이 기기에서 알림 받기", "在此设备上接收通知", "Получать уведомления на этом устройстве"], "始まる前のリマインド": ["Reminder before the start", "Recordatorio antes de empezar", "Lembrete antes do início", "Rappel avant le début", "Erinnerung vor dem Start", "시작 전 리마인드", "开始前提醒", "Напоминание до начала"], "朝のまとめの時刻": ["Morning summary time", "Hora del resumen matinal", "Horário do resumo da manhã", "Heure du résumé du matin", "Uhrzeit der Morgenübersicht", "아침 요약 시각", "早间汇总时间", "Время утренней сводки"], - "アプリを閉じていても、この端末に届きます。": ["They reach this device even when the app is closed.", "Llegan a este dispositivo aunque la app esté cerrada.", "Chegam a este dispositivo mesmo com o app fechado.", "Elles arrivent sur cet appareil même quand l'app est fermée.", "Sie kommen auch an, wenn die App geschlossen ist.", "앱을 닫아도 이 기기로 와요.", "即使关闭应用,也会送到此设备。", "Приходят на это устройство, даже когда приложение закрыто."], "始まる前": ["Before the start", "Antes de empezar", "Antes do início", "Avant le début", "Vor dem Start", "시작 전", "开始前", "До начала"], - "始まる時刻ちょうどに知らせる": ["Notify right at the start time", "Avisar justo a la hora de inicio", "Avisar bem na hora do início", "Prévenir à l'heure du début", "Genau zur Startzeit benachrichtigen", "시작 시각에 알리기", "在开始时间提醒", "Уведомлять в момент начала"], "朝のまとめ": ["Morning summary", "Resumen matinal", "Resumo da manhã", "Résumé du matin", "Morgenübersicht", "아침 요약", "早间汇总", "Утренняя сводка"], "Agent": ["Agent", "Agent", "Agent", "Agent", "Agent", "Agent", "Agent", "Agent"], "確認を頼まれたとき": ["When I'm asked to check something", "Cuando me piden revisar algo", "Quando me pedem para revisar", "Quand on me demande de vérifier", "Wenn ich etwas prüfen soll", "확인을 부탁받았을 때", "被请求确认时", "Когда просят проверить"], @@ -481,4 +392,309 @@ export const COPY: Readonly> = { "作業を終えた・止まったとき": ["When work is finished or stuck", "Cuando el trabajo termina o se bloquea", "Quando o trabalho termina ou trava", "Quand le travail est fini ou bloqué", "Wenn Arbeit fertig ist oder hängt", "작업을 끝냈거나 멈췄을 때", "工作完成或卡住时", "Когда работа закончена или застряла"], "通知はこう届きます": ["This is how notifications look", "Así se ven las notificaciones", "É assim que as notificações aparecem", "Voici à quoi ressemblent les notifications", "So sehen Benachrichtigungen aus", "알림은 이렇게 도착해요", "通知会这样显示", "Так выглядят уведомления"], "Guilduo からのテスト通知です": ["A test notification from Guilduo", "Una notificación de prueba de Guilduo", "Uma notificação de teste do Guilduo", "Une notification de test de Guilduo", "Eine Testbenachrichtigung von Guilduo", "Guilduo 테스트 알림입니다", "来自 Guilduo 的测试通知", "Тестовое уведомление от Guilduo"], + "個人ワークスペース": ["Personal workspace", "Espacio personal", "Espaço pessoal", "Espace personnel", "Persönlicher Bereich", "개인 워크스페이스", "个人工作区", "Личное пространство"], + "マイワークスペース": ["My workspace", "Mi espacio", "Meu espaço", "Mon espace", "Mein Bereich", "내 워크스페이스", "我的工作区", "Моё пространство"], + "残り {time}": ["{time} left", "Quedan {time}", "Restam {time}", "Reste {time}", "Noch {time}", "남은 시간 {time}", "剩余 {time}", "Осталось {time}"], + "あと{time}": ["{time} to go", "Faltan {time}", "Faltam {time}", "Encore {time}", "Noch {time}", "{time} 남음", "还有{time}", "Ещё {time}"], + "{time}の余白": ["{time} free", "{time} libres", "{time} livres", "{time} libres", "{time} frei", "{time} 여유", "{time}空档", "{time} свободно"], + "予定を入れる": ["Add a plan", "Añadir un plan", "Adicionar um plano", "Ajouter un créneau", "Termin eintragen", "일정 넣기", "安排事项", "Добавить дело"], + "受け箱から": ["From inbox", "Del buzón", "Da caixa de entrada", "De la boîte", "Aus dem Eingang", "받은함에서", "来自收件箱", "Из входящих"], + "ここに入れる": ["Put it here", "Ponerlo aquí", "Colocar aqui", "Placer ici", "Hier einplanen", "여기에 넣기", "放在这里", "Поставить сюда"], + "時刻未定": ["No set time", "Sin hora", "Sem horário", "Sans horaire", "Ohne Uhrzeit", "시간 미정", "未定时间", "Без времени"], + "止まっています": ["Stopped", "Detenida", "Parada", "À l'arrêt", "Angehalten", "멈춰 있습니다", "已停住", "Остановлено"], + "内容を確認": ["Review", "Revisar", "Revisar", "Vérifier", "Prüfen", "내용 확인", "查看内容", "Проверить"], + "判断する": ["Decide", "Decidir", "Decidir", "Décider", "Entscheiden", "판단하기", "去判断", "Решить"], + "あなたの判断 {count}件": ["{count} need you", "{count} te esperan", "{count} aguardam você", "{count} vous attendent", "{count} warten auf dich", "내 판단 {count}건", "待你判断 {count} 项", "Ждут вас: {count}"], + "作業中 {working}件 · 受け取り待ち {ready}件": ["{working} in progress · {ready} waiting to start", "{working} en curso · {ready} por empezar", "{working} em andamento · {ready} aguardando início", "{working} en cours · {ready} en attente", "{working} in Arbeit · {ready} wartend", "작업 중 {working}건 · 수령 대기 {ready}건", "进行中 {working} 项 · 待接收 {ready} 项", "В работе: {working} · ждут начала: {ready}"], + "それより前": ["Earlier", "Antes", "Antes", "Plus tôt", "Früher", "이전", "更早", "Ранее"], + "確認 {review} · 止まっている {blocked}": ["{review} to review · {blocked} stopped", "{review} por revisar · {blocked} detenidas", "{review} para revisar · {blocked} paradas", "{review} à vérifier · {blocked} à l'arrêt", "{review} zu prüfen · {blocked} angehalten", "확인 {review} · 멈춤 {blocked}", "待确认 {review} · 停住 {blocked}", "Проверить: {review} · стоит: {blocked}"], + "いま取り組んでいます": ["Working on this now", "En esto ahora", "Fazendo isto agora", "En cours maintenant", "Gerade in Arbeit", "지금 하는 중", "正在进行", "Сейчас в работе"], + "完了する": ["Complete", "Completar", "Concluir", "Terminer", "Abschließen", "완료하기", "完成", "Завершить"], + "頭にあることを書き出すところから。": ["Start by writing down what is on your mind.", "Empieza por escribir lo que tienes en mente.", "Comece escrevendo o que está na sua cabeça.", "Commencez par noter ce que vous avez en tête.", "Schreib zuerst auf, was dir im Kopf ist.", "머릿속에 있는 것을 적는 것부터.", "先把脑子里的事写下来。", "Начните с того, что запишете всё, что в голове."], + "予定なし": ["Nothing planned", "Sin planes", "Nada planejado", "Rien de prévu", "Nichts geplant", "일정 없음", "无安排", "Нет планов"], + "{day}日に入れる": ["Add to the {day}", "Añadir al día {day}", "Adicionar ao dia {day}", "Ajouter au {day}", "Am {day}. einplanen", "{day}일에 넣기", "放到{day}日", "Добавить на {day}-е"], + "{count}件 · {time} · {done}件完了": ["{count} tasks · {time} · {done} done", "{count} tareas · {time} · {done} hechas", "{count} tarefas · {time} · {done} concluídas", "{count} tâches · {time} · {done} terminées", "{count} Aufgaben · {time} · {done} erledigt", "{count}건 · {time} · {done}건 완료", "{count} 项 · {time} · 已完成 {done} 项", "Задач: {count} · {time} · готово: {done}"], + "予定日を過ぎたものです。今日に入れるか、タイトルから日付を変えます。": ["These are past their planned day. Add them to today, or open one to change its date.", "Ya pasó su día previsto. Añádelas a hoy o abre una para cambiar la fecha.", "Já passaram do dia planejado. Adicione a hoje ou abra uma para mudar a data.", "Leur jour prévu est passé. Ajoutez-les à aujourd'hui ou ouvrez-en une pour changer la date.", "Ihr geplanter Tag ist vorbei. Heute einplanen oder öffnen, um das Datum zu ändern.", "예정일이 지난 항목입니다. 오늘에 넣거나 제목을 눌러 날짜를 바꾸세요.", "这些已过计划日期。可放到今天,或点标题更改日期。", "Их запланированный день прошёл. Добавьте на сегодня или откройте, чтобы сменить дату."], + "未定": ["No time", "Sin hora", "Sem horário", "Sans heure", "Ohne Zeit", "미정", "未定", "Без времени"], + "日付を選ぶ": ["Choose a date", "Elegir fecha", "Escolher data", "Choisir une date", "Datum wählen", "날짜 선택", "选择日期", "Выбрать дату"], + "完了 {count}": ["{count} done", "{count} hechas", "{count} concluídas", "{count} terminées", "{count} erledigt", "완료 {count}", "已完成 {count}", "Готово: {count}"], + "{count}日": ["{count} days", "{count} días", "{count} dias", "{count} jours", "{count} Tage", "{count}일", "{count} 天", "{count} дн."], + "予定日 {date}": ["Planned {date}", "Previsto {date}", "Previsto {date}", "Prévu {date}", "Geplant {date}", "예정일 {date}", "计划日 {date}", "План: {date}"], + "{day}日に移す": ["Move to the {day}", "Mover al día {day}", "Mover para o dia {day}", "Déplacer au {day}", "Auf den {day}. verschieben", "{day}일로 옮기기", "移到{day}日", "Перенести на {day}-е"], + "この日を開く": ["Open this day", "Abrir este día", "Abrir este dia", "Ouvrir ce jour", "Diesen Tag öffnen", "이 날 열기", "打开这一天", "Открыть этот день"], + "今日に入れる": ["Add to today", "Añadir a hoy", "Adicionar a hoje", "Ajouter à aujourd'hui", "Heute einplanen", "오늘에 넣기", "放到今天", "Добавить на сегодня"], + "タスクを探す": ["Search tasks", "Buscar tareas", "Buscar tarefas", "Rechercher des tâches", "Aufgaben suchen", "작업 찾기", "搜索任务", "Поиск задач"], + "一覧": ["List", "Lista", "Lista", "Liste", "Liste", "목록", "列表", "Список"], + "ボード": ["Board", "Tablero", "Quadro", "Tableau", "Board", "보드", "看板", "Доска"], + "ガント": ["Gantt", "Gantt", "Gantt", "Gantt", "Gantt", "간트", "甘特图", "Гант"], + "メモはサインイン後に使えます。": ["Sign in to use notes.", "Inicia sesión para usar las notas.", "Entre para usar as notas.", "Connectez-vous pour utiliser les notes.", "Zum Verwenden von Notizen anmelden.", "메모는 로그인 후 사용할 수 있습니다.", "登录后可使用备注。", "Войдите, чтобы пользоваться заметками."], + "ほか {count}件を見る": ["Show {count} more", "Ver {count} más", "Ver mais {count}", "Voir {count} de plus", "{count} weitere zeigen", "{count}건 더 보기", "再看 {count} 项", "Показать ещё {count}"], + "習慣": ["Habits", "Hábitos", "Hábitos", "Habitudes", "Gewohnheiten", "습관", "习惯", "Привычки"], + "{name}が止まっています": ["{name} is stuck", "{name} está detenido", "{name} está parado", "{name} est bloqué", "{name} kommt nicht weiter", "{name}이(가) 멈춰 있습니다", "{name} 停住了", "{name} остановился"], + "元のタスク": ["Original task", "Tarea original", "Tarefa original", "Tâche d'origine", "Ursprüngliche Aufgabe", "원래 작업", "原任务", "Исходная задача"], + "{name}からの確認依頼": ["Review request from {name}", "Solicitud de revisión de {name}", "Pedido de revisão de {name}", "Demande de vérification de {name}", "Prüfanfrage von {name}", "{name}의 확인 요청", "来自 {name} 的确认请求", "Запрос на проверку от {name}"], + "{name}が「終わった」と報告しています": ["{name} reports it is done", "{name} informa que terminó", "{name} informa que terminou", "{name} indique avoir terminé", "{name} meldet: fertig", "{name}이(가) 끝났다고 보고했습니다", "{name} 报告已完成", "{name} сообщает, что закончил"], + "説明": ["Description", "Descripción", "Descrição", "Description", "Beschreibung", "설명", "说明", "Описание"], + "{count}件": ["{count}", "{count}", "{count}", "{count}", "{count}", "{count}건", "{count} 项", "{count}"], + "あなたの判断": ["Your decisions", "Tus decisiones", "Suas decisões", "Vos décisions", "Deine Entscheidungen", "내 판단", "需要你判断", "Ваши решения"], + "あとで見る": ["Later", "Más tarde", "Mais tarde", "Plus tard", "Später", "나중에 보기", "稍后查看", "Позже"], + "人とAgent": ["People and agents", "Personas y agentes", "Pessoas e agentes", "Personnes et agents", "Personen und Agenten", "사람과 Agent", "人与 Agent", "Люди и агенты"], + "+ Agentを登録": ["+ Register agent", "+ Registrar agente", "+ Registrar agente", "+ Enregistrer un agent", "+ Agent registrieren", "+ Agent 등록", "+ 注册 Agent", "+ Добавить агента"], + "+ 回答に画像を添付": ["+ Attach images to your reply", "+ Adjuntar imágenes a la respuesta", "+ Anexar imagens à resposta", "+ Joindre des images à la réponse", "+ Bilder an die Antwort anhängen", "+ 답변에 이미지 첨부", "+ 在回复中添加图片", "+ Прикрепить изображения к ответу"], + "{count}枚": ["{count}", "{count}", "{count}", "{count}", "{count}", "{count}장", "{count} 张", "{count}"], + "アプリを閉じていても、この端末に届きます。変更はすぐ保存されます。": ["Arrives on this device even when the app is closed. Changes save right away.", "Llega a este dispositivo aunque la app esté cerrada. Los cambios se guardan al instante.", "Chega a este dispositivo mesmo com o app fechado. As alterações são salvas na hora.", "Arrive sur cet appareil même si l'app est fermée. Les changements sont enregistrés aussitôt.", "Kommt auf diesem Gerät an, auch wenn die App geschlossen ist. Änderungen werden sofort gespeichert.", "앱을 닫아도 이 기기로 도착합니다. 변경은 바로 저장됩니다.", "即使关闭应用也会送达此设备。更改会立即保存。", "Приходит на это устройство, даже если приложение закрыто. Изменения сохраняются сразу."], + "始まる時刻ちょうど": ["At the start time", "A la hora de inicio", "Na hora de início", "À l'heure de début", "Zur Startzeit", "시작 시각 정각", "开始时刻", "Точно в момент начала"], + "{names} として動きます": ["Works as {names}", "Funciona como {names}", "Funciona como {names}", "Agit en tant que {names}", "Arbeitet als {names}", "{names}(으)로 동작합니다", "以 {names} 的身份运行", "Работает как {names}"], + "まだAgentに紐付いていません": ["Not linked to an agent yet", "Aún sin vincular a un agente", "Ainda não vinculado a um agente", "Pas encore lié à un agent", "Noch mit keinem Agenten verknüpft", "아직 Agent에 연결되지 않았습니다", "尚未关联 Agent", "Пока не связано с агентом"], + "Agentを紐付ける": ["Link an agent", "Vincular agente", "Vincular agente", "Lier un agent", "Agent verknüpfen", "Agent 연결", "关联 Agent", "Связать с агентом"], + "30日以上使っていない接続": ["Connections unused for 30+ days", "Conexiones sin usar más de 30 días", "Conexões sem uso há mais de 30 dias", "Connexions inutilisées depuis plus de 30 jours", "Seit über 30 Tagen ungenutzte Verbindungen", "30일 넘게 쓰지 않은 연결", "超过 30 天未使用的连接", "Подключения без использования более 30 дней"], + "MCP接続": ["MCP connections", "Conexiones MCP", "Conexões MCP", "Connexions MCP", "MCP-Verbindungen", "MCP 연결", "MCP 连接", "Подключения MCP"], + "ClaudeやCodexなどにこのURLを追加してサインインすると、下に接続が出ます。": ["Add this URL to Claude, Codex or another client and sign in; the connection then appears below.", "Añade esta URL a Claude, Codex u otro cliente e inicia sesión; la conexión aparecerá abajo.", "Adicione esta URL ao Claude, Codex ou outro cliente e entre; a conexão aparece abaixo.", "Ajoutez cette URL à Claude, Codex ou un autre client et connectez-vous ; la connexion apparaît ci-dessous.", "Füge diese URL in Claude, Codex oder einem anderen Client hinzu und melde dich an; die Verbindung erscheint dann unten.", "Claude나 Codex 등에 이 URL을 추가하고 로그인하면 아래에 연결이 표시됩니다.", "在 Claude、Codex 等客户端添加此 URL 并登录后,连接会显示在下方。", "Добавьте этот URL в Claude, Codex или другой клиент и войдите — подключение появится ниже."], + "任意の利用計測": ["Optional usage analytics", "Análisis de uso opcional", "Análise de uso opcional", "Mesure d'usage facultative", "Optionale Nutzungsanalyse", "선택적 사용 측정", "可选的使用统计", "Необязательная аналитика"], + "端末": ["Device", "Dispositivo", "Dispositivo", "Appareil", "Gerät", "기기", "设备", "Устройство"], + "テーマ": ["Theme", "Tema", "Tema", "Thème", "Design", "테마", "主题", "Тема"], + "完了のお祝い": ["Celebrate completion", "Celebrar al completar", "Comemorar ao concluir", "Fêter la fin", "Abschluss feiern", "완료 축하", "完成庆祝", "Празднование завершения"], + "完了したら紙吹雪を出します": ["Shows confetti when you finish", "Muestra confeti al terminar", "Mostra confete ao concluir", "Affiche des confettis à la fin", "Zeigt Konfetti beim Abschluss", "완료하면 종이 꽃가루가 나옵니다", "完成时显示彩纸", "Показывает конфетти при завершении"], + "レベル・XP": ["Level and XP", "Nivel y XP", "Nível e XP", "Niveau et XP", "Level und XP", "레벨·XP", "等级与 XP", "Уровень и XP"], + "通知と連携": ["Notifications and connections", "Notificaciones y conexiones", "Notificações e conexões", "Notifications et connexions", "Benachrichtigungen und Verbindungen", "알림과 연동", "通知与连接", "Уведомления и подключения"], + "リマインド・朝のまとめ・Agentからの知らせ": ["Reminders, morning summary, news from agents", "Recordatorios, resumen matutino, avisos de agentes", "Lembretes, resumo da manhã, avisos de agentes", "Rappels, résumé du matin, nouvelles des agents", "Erinnerungen, Morgenübersicht, Nachrichten von Agenten", "리마인드·아침 요약·Agent 소식", "提醒、早间摘要、Agent 通知", "Напоминания, утренняя сводка, сообщения агентов"], + "オン": ["On", "Activado", "Ativado", "Activé", "An", "켜짐", "开", "Вкл."], + "オフ": ["Off", "Desactivado", "Desativado", "Désactivé", "Aus", "꺼짐", "关", "Выкл."], + "外部サービス": ["External services", "Servicios externos", "Serviços externos", "Services externes", "Externe Dienste", "외부 서비스", "外部服务", "Внешние сервисы"], + "カレンダーや時間の記録をつなぐ": ["Connect calendars and time tracking", "Conecta calendarios y registro de tiempo", "Conecte calendários e registro de tempo", "Connecter agendas et suivi du temps", "Kalender und Zeiterfassung verbinden", "캘린더와 시간 기록 연결", "连接日历和时间记录", "Подключить календари и учёт времени"], + "Claude・CodexなどのAIクライアント": ["AI clients such as Claude and Codex", "Clientes de IA como Claude y Codex", "Clientes de IA como Claude e Codex", "Clients IA comme Claude et Codex", "KI-Clients wie Claude und Codex", "Claude·Codex 등의 AI 클라이언트", "Claude、Codex 等 AI 客户端", "ИИ-клиенты, такие как Claude и Codex"], + "アカウントとデータ": ["Account and data", "Cuenta y datos", "Conta e dados", "Compte et données", "Konto und Daten", "계정과 데이터", "账户与数据", "Аккаунт и данные"], + "記録元・タイムゾーン・書き出し": ["Source, time zone, export", "Origen, zona horaria, exportación", "Origem, fuso horário, exportação", "Source, fuseau horaire, export", "Quelle, Zeitzone, Export", "기록 출처·시간대·내보내기", "来源、时区、导出", "Источник, часовой пояс, экспорт"], + "利用プラン": ["Plan", "Plan", "Plano", "Offre", "Tarif", "이용 플랜", "使用方案", "Тариф"], + "プランの確認と管理": ["View and manage your plan", "Ver y gestionar tu plan", "Ver e gerenciar seu plano", "Voir et gérer votre offre", "Tarif ansehen und verwalten", "플랜 확인 및 관리", "查看并管理方案", "Просмотр и управление тарифом"], + "同意の確認と撤回": ["Review or withdraw consent", "Revisar o retirar el consentimiento", "Rever ou retirar o consentimento", "Vérifier ou retirer le consentement", "Einwilligung prüfen oder widerrufen", "동의 확인 및 철회", "查看或撤回同意", "Проверить или отозвать согласие"], + "その他": ["Other", "Otros", "Outros", "Autres", "Sonstiges", "기타", "其他", "Другое"], + "キーボード操作": ["Keyboard shortcuts", "Atajos de teclado", "Atalhos de teclado", "Raccourcis clavier", "Tastaturkürzel", "키보드 조작", "键盘操作", "Горячие клавиши"], + "N か / で書き出し、J と K で行を移動、X で完了、S で今のタスクを始める": ["N or / to capture, J and K to move, X to complete, S to start the current task", "N o / para anotar, J y K para moverte, X para completar, S para empezar la tarea actual", "N ou / para anotar, J e K para mover, X para concluir, S para iniciar a tarefa atual", "N ou / pour noter, J et K pour se déplacer, X pour terminer, S pour démarrer la tâche en cours", "N oder / zum Notieren, J und K zum Bewegen, X zum Erledigen, S startet die aktuelle Aufgabe", "N 또는 /로 적기, J와 K로 이동, X로 완료, S로 지금 작업 시작", "按 N 或 / 记录,J 和 K 移动,X 完成,S 开始当前任务", "N или / — записать, J и K — перемещение, X — завершить, S — начать текущую задачу"], + "詳しい画面を開く": ["Open the detailed view", "Abrir la vista detallada", "Abrir a visão detalhada", "Ouvrir la vue détaillée", "Detailansicht öffnen", "자세한 화면 열기", "打开详细界面", "Открыть подробный вид"], + "「{title}」をしまう": ["Put away \"{title}\"", "Guardar «{title}»", "Guardar “{title}”", "Ranger « {title} »", "„{title}“ weglegen", "\"{title}\" 치우기", "收起“{title}”", "Убрать «{title}»"], + "ほかの操作": ["More actions", "Más acciones", "Mais ações", "Autres actions", "Weitere Aktionen", "다른 작업", "其他操作", "Другие действия"], + "タスクの詳細": ["Task details", "Detalles de la tarea", "Detalhes da tarefa", "Détails de la tâche", "Aufgabendetails", "작업 상세", "任务详情", "Детали задачи"], + "キャンセル": ["Cancel", "Cancelar", "Cancelar", "Annuler", "Abbrechen", "취소", "取消", "Отмена"], + "変更を保存": ["Save changes", "Guardar cambios", "Salvar alterações", "Enregistrer", "Änderungen speichern", "변경 저장", "保存更改", "Сохранить изменения"], + "例:明日15時 30分 画面案を見比べる": ["e.g. tomorrow 3pm 30 min compare mockups", "p. ej.: mañana 15:00 30 min comparar bocetos", "ex.: amanhã 15h 30 min comparar telas", "ex. : demain 15h 30 min comparer les maquettes", "z. B. morgen 15 Uhr 30 Min Entwürfe vergleichen", "예: 내일 15시 30분 화면안 비교하기", "例:明天15点 30分钟 比较界面方案", "напр.: завтра 15:00 30 мин сравнить макеты"], + "追加する": ["Add", "Añadir", "Adicionar", "Ajouter", "Hinzufügen", "추가", "添加", "Добавить"], + "空のままEnterで閉じます。": ["Press Enter on an empty line to close.", "Pulsa Intro con la línea vacía para cerrar.", "Pressione Enter com a linha vazia para fechar.", "Appuyez sur Entrée sur une ligne vide pour fermer.", "Enter bei leerer Zeile schließt.", "빈 상태에서 Enter를 누르면 닫힙니다.", "留空按 Enter 即可关闭。", "Нажмите Enter в пустой строке, чтобы закрыть."], + "追加しました": ["Added", "Añadido", "Adicionado", "Ajouté", "Hinzugefügt", "추가했습니다", "已添加", "Добавлено"], + "レベルが上がりました · Lv {level}": ["Level up · Lv {level}", "Subiste de nivel · Nv {level}", "Subiu de nível · Nv {level}", "Niveau supérieur · Nv {level}", "Level aufgestiegen · Lv {level}", "레벨이 올랐습니다 · Lv {level}", "等级提升 · Lv {level}", "Новый уровень · Ур. {level}"], + "完了を保存できませんでした": ["Couldn't save the completion", "No se pudo guardar como hecha", "Não foi possível salvar a conclusão", "Impossible d'enregistrer la fin", "Abschluss konnte nicht gespeichert werden", "완료를 저장하지 못했습니다", "无法保存完成状态", "Не удалось сохранить выполнение"], + "{count}件 完了しました": ["{count} done", "{count} hechas", "{count} concluídas", "{count} terminées", "{count} erledigt", "{count}개 완료했습니다", "已完成 {count} 项", "Выполнено: {count}"], + "おつかれさま! ひとつ前へ。": ["Nice work! One step forward.", "¡Buen trabajo! Un paso más.", "Bom trabalho! Um passo à frente.", "Bien joué ! Un pas de plus.", "Gut gemacht! Ein Schritt weiter.", "수고했어요! 한 걸음 앞으로.", "辛苦了!又前进一步。", "Отлично! Ещё шаг вперёд."], + "ほかのタスク {count}件": ["{count} other tasks", "{count} tareas más", "{count} outras tarefas", "{count} autres tâches", "{count} weitere Aufgaben", "다른 작업 {count}개", "其他任务 {count} 项", "Другие задачи: {count}"], + "今やるタスクを選ぶ": ["Choose what to do now", "Elige qué hacer ahora", "Escolha o que fazer agora", "Choisir quoi faire maintenant", "Wähle, was du jetzt machst", "지금 할 작업 고르기", "选择现在要做的任务", "Выберите, чем заняться"], + "次の予定 {time} まで、あと{left}": ["{left} until the next plan at {time}", "Faltan {left} para el siguiente plan a las {time}", "Faltam {left} até o próximo plano às {time}", "{left} avant le prochain plan à {time}", "Noch {left} bis zum nächsten Termin um {time}", "다음 일정 {time}까지 {left} 남음", "距下一个安排 {time} 还有{left}", "До следующего плана в {time}: {left}"], + "今日の候補はありません。": ["Nothing left for today.", "No queda nada para hoy.", "Nada restante para hoje.", "Rien de prévu pour aujourd'hui.", "Für heute steht nichts mehr an.", "오늘 남은 후보가 없습니다.", "今天没有可选的任务。", "На сегодня ничего не осталось."], + "タスクを見る": ["View tasks", "Ver tareas", "Ver tarefas", "Voir les tâches", "Aufgaben ansehen", "작업 보기", "查看任务", "Открыть задачи"], + "この余白をどう使う?": ["How will you use this gap?", "¿Cómo usarás este hueco?", "Como usar este intervalo?", "Que faire de ce créneau ?", "Wie nutzt du diese Lücke?", "이 빈 시간을 어떻게 쓸까요?", "这段空档怎么用?", "Как использовать этот промежуток?"], + "新しく書く": ["Write a new one", "Escribir una nueva", "Escrever uma nova", "En écrire une nouvelle", "Neu schreiben", "새로 쓰기", "新写一项", "Написать новую"], + "この時間に入れるタスクを追加します": ["Add a task for this time", "Añade una tarea para este rato", "Adicione uma tarefa para este horário", "Ajouter une tâche pour ce créneau", "Eine Aufgabe für diese Zeit hinzufügen", "이 시간에 넣을 작업을 추가합니다", "添加要放进这段时间的任务", "Добавить задачу на это время"], + "ひと息つく": ["Take a breather", "Tomar un respiro", "Dar uma pausa", "Souffler un peu", "Durchatmen", "한숨 돌리기", "喘口气", "Передохнуть"], + "何も入れず、この時間を残します。": ["Keep this time free.", "Deja este rato libre.", "Deixe este horário livre.", "Garder ce créneau libre.", "Diese Zeit frei lassen.", "아무것도 넣지 않고 이 시간을 남겨 둡니다.", "什么都不放,留出这段时间。", "Оставить это время свободным."], + "この時間は、そのまま空けておきます": ["This time stays free", "Este rato queda libre", "Este horário fica livre", "Ce créneau reste libre", "Diese Zeit bleibt frei", "이 시간은 그대로 비워 둡니다", "这段时间保持空闲", "Это время остаётся свободным"], + "一時停止中 · {time} 進めました": ["Paused · {time} done", "En pausa · {time} avanzado", "Pausado · {time} feito", "En pause · {time} accompli", "Pausiert · {time} geschafft", "일시정지 중 · {time} 진행했습니다", "已暂停 · 已进行 {time}", "Пауза · сделано {time}"], + "続ける": ["Continue", "Continuar", "Continuar", "Continuer", "Weiter", "계속하기", "继续", "Продолжить"], + "今日の分は終わりました": ["Today's list is done", "Lo de hoy está hecho", "O de hoje está feito", "La journée est bouclée", "Für heute ist alles erledigt", "오늘 할 일을 마쳤습니다", "今天的任务都完成了", "На сегодня всё сделано"], + "{count}件 完了 · 合計 {time}": ["{count} done · {time} total", "{count} hechas · {time} en total", "{count} concluídas · {time} no total", "{count} terminées · {time} au total", "{count} erledigt · {time} insgesamt", "{count}개 완료 · 합계 {time}", "完成 {count} 项 · 共 {time}", "Выполнено: {count} · всего {time}"], + "明日の予定を見る": ["See tomorrow's plan", "Ver el plan de mañana", "Ver o plano de amanhã", "Voir le plan de demain", "Plan für morgen ansehen", "내일 일정 보기", "查看明天的安排", "План на завтра"], + "もう1件だけ選ぶ": ["Pick just one more", "Elegir solo una más", "Escolher só mais uma", "En choisir encore une", "Nur noch eine wählen", "하나만 더 고르기", "再选一项", "Выбрать ещё одну"], + "休憩 {time}": ["Break {time}", "Descanso {time}", "Pausa {time}", "Pause {time}", "Pause {time}", "휴식 {time}", "休息 {time}", "Перерыв {time}"], + "タスクの一覧から選ぶ": ["Choose from the task list", "Elegir de la lista de tareas", "Escolher na lista de tarefas", "Choisir dans la liste des tâches", "Aus der Aufgabenliste wählen", "작업 목록에서 고르기", "从任务列表中选择", "Выбрать из списка задач"], + "時間を選ぶと、そこに予定を入れられます。": ["Pick a time to plan something there.", "Elige una hora para planear algo ahí.", "Escolha um horário para planejar algo ali.", "Choisissez une heure pour y prévoir quelque chose.", "Wähle eine Uhrzeit, um dort etwas einzuplanen.", "시간을 고르면 그곳에 일정을 넣을 수 있습니다.", "选择一个时间,即可在那里安排任务。", "Выберите время, чтобы запланировать что-то."], + "この日の予定はありませんでした。": ["Nothing was planned for this day.", "No había nada planeado este día.", "Nada foi planejado para este dia.", "Rien n'était prévu ce jour-là.", "Für diesen Tag war nichts geplant.", "이날은 일정이 없었습니다.", "这一天没有安排。", "На этот день ничего не было запланировано."], + "まだ予定はありません。空いている時間も、大切に。": ["Nothing planned yet. Free time matters too.", "Aún no hay nada planeado. El tiempo libre también cuenta.", "Nada planejado ainda. Tempo livre também importa.", "Rien de prévu pour l'instant. Le temps libre compte aussi.", "Noch nichts geplant. Freie Zeit zählt auch.", "아직 일정이 없습니다. 비어 있는 시간도 소중히.", "还没有安排。空闲的时间也很珍贵。", "Пока ничего не запланировано. Свободное время тоже важно."], + "{day}日に追加": ["Add on the {day}th", "Añadir el día {day}", "Adicionar no dia {day}", "Ajouter le {day}", "Am {day}. hinzufügen", "{day}일에 추가", "添加到 {day} 日", "Добавить на {day}-е"], + "「{text}」は見つかりませんでした": ["No results for “{text}”", "No se encontró «{text}»", "Nada encontrado para “{text}”", "Aucun résultat pour « {text} »", "Keine Treffer für „{text}“", "\"{text}\"을(를) 찾지 못했습니다", "未找到“{text}”", "«{text}» не найдено"], + "完了としまったものも探しました。": ["Done and put-away tasks were searched too.", "También se buscó en las hechas y guardadas.", "Também procuramos nas concluídas e guardadas.", "Les tâches terminées et rangées ont aussi été cherchées.", "Erledigte und weggelegte Aufgaben wurden mitdurchsucht.", "완료한 것과 치운 것도 찾아봤습니다.", "已完成和已收起的任务也搜索过了。", "Поиск шёл и по выполненным, и по убранным."], + "「{text}」を追加": ["Add “{text}”", "Añadir «{text}»", "Adicionar “{text}”", "Ajouter « {text} »", "„{text}“ hinzufügen", "\"{text}\" 추가", "添加“{text}”", "Добавить «{text}»"], + "検索を消す": ["Clear search", "Borrar búsqueda", "Limpar busca", "Effacer la recherche", "Suche löschen", "검색 지우기", "清除搜索", "Сбросить поиск"], + "やることは全部終わりました。": ["Everything is done.", "Todo está hecho.", "Tudo concluído.", "Tout est fait.", "Alles ist erledigt.", "할 일을 모두 마쳤습니다.", "所有任务都完成了。", "Всё сделано."], + "タスクはありません": ["No tasks", "No hay tareas", "Nenhuma tarefa", "Aucune tâche", "Keine Aufgaben", "작업이 없습니다", "没有任务", "Задач нет"], + "思いついたことから、書き出してみましょう。": ["Start by writing down whatever comes to mind.", "Empieza anotando lo que se te ocurra.", "Comece anotando o que vier à mente.", "Commencez par noter ce qui vous vient.", "Schreib zuerst auf, was dir einfällt.", "떠오르는 것부터 적어 보세요.", "从想到的事情开始写下来吧。", "Начните с того, что приходит в голову."], + "この7日間に日付のあるタスクはありません": ["No dated tasks in the next 7 days", "No hay tareas con fecha en los próximos 7 días", "Nenhuma tarefa com data nos próximos 7 dias", "Aucune tâche datée sur les 7 prochains jours", "Keine datierten Aufgaben in den nächsten 7 Tagen", "앞으로 7일 동안 날짜가 있는 작업이 없습니다", "未来 7 天没有带日期的任务", "На ближайшие 7 дней задач с датой нет"], + "日付を入れると、ここに並びます。": ["Tasks with a date show up here.", "Las tareas con fecha aparecen aquí.", "Tarefas com data aparecem aqui.", "Les tâches datées s'affichent ici.", "Aufgaben mit Datum erscheinen hier.", "날짜를 넣으면 여기에 나타납니다.", "加上日期后会显示在这里。", "Задачи с датой появятся здесь."], + "一覧で見る": ["View as list", "Ver en lista", "Ver em lista", "Voir en liste", "Als Liste ansehen", "목록으로 보기", "以列表查看", "Открыть списком"], + "いま、判断を待っているものはありません。": ["Nothing is waiting for your decision.", "Nada espera tu decisión ahora.", "Nada aguarda sua decisão agora.", "Rien n'attend votre décision.", "Gerade wartet nichts auf deine Entscheidung.", "지금 판단을 기다리는 것은 없습니다.", "目前没有等待你判断的事项。", "Сейчас ничего не ждёт вашего решения."], + "まだAgentはいません。登録すると、タスクを任せて、結果をここで確認できます。": ["No agents yet. Register one to hand off tasks and check the results here.", "Aún no hay agentes. Registra uno para delegar tareas y revisar los resultados aquí.", "Ainda não há agentes. Registre um para delegar tarefas e conferir os resultados aqui.", "Aucun agent pour l'instant. Enregistrez-en un pour confier des tâches et vérifier les résultats ici.", "Noch keine Agents. Registriere einen, um Aufgaben abzugeben und die Ergebnisse hier zu prüfen.", "아직 Agent가 없습니다. 등록하면 작업을 맡기고 결과를 여기서 확인할 수 있습니다.", "还没有 Agent。注册后即可委派任务,并在这里查看结果。", "Агентов пока нет. Зарегистрируйте агента, чтобы поручать задачи и смотреть результаты здесь."], + "Agentを登録": ["Register agent", "Registrar agente", "Registrar agente", "Enregistrer un agent", "Agent registrieren", "Agent 등록", "注册 Agent", "Зарегистрировать агента"], + "ほかの絵文字を入力": ["Type another emoji", "Escribir otro emoji", "Digitar outro emoji", "Saisir un autre emoji", "Anderes Emoji eingeben", "다른 이모지 입력", "输入其他表情", "Ввести другой эмодзи"], + "絵文字を1つだけ入れられます。": ["One emoji only.", "Solo un emoji.", "Apenas um emoji.", "Un seul emoji.", "Nur ein Emoji.", "이모지 1개만 넣을 수 있습니다.", "只能输入一个表情。", "Только один эмодзи."], + "Agentに任せると、終わったところであなたが確認します。": ["Hand it to an agent and you check it when it's done.", "Si lo delegas a un agente, lo revisas cuando termine.", "Delegue a um agente e confira quando terminar.", "Confiez-la à un agent, vous vérifiez à la fin.", "Gib es einem Agent und prüfe es, wenn es fertig ist.", "Agent에게 맡기면 끝난 뒤 당신이 확인합니다.", "交给 Agent 后,完成时由你确认。", "Поручите агенту — проверите, когда он закончит."], + "ギルドで登録する": ["Register in the guild", "Registrar en el gremio", "Registrar na guilda", "Enregistrer dans la guilde", "In der Gilde registrieren", "길드에서 등록하기", "在公会中注册", "Зарегистрировать в гильдии"], + "使えるAgentがいません。": ["No agents available.", "No hay agentes disponibles.", "Nenhum agente disponível.", "Aucun agent disponible.", "Keine Agents verfügbar.", "사용할 수 있는 Agent가 없습니다.", "没有可用的 Agent。", "Нет доступных агентов."], + "{count}件 進行中": ["{count} in progress", "{count} en curso", "{count} em andamento", "{count} en cours", "{count} in Arbeit", "{count}개 진행 중", "{count} 项进行中", "В работе: {count}"], + "手が空いています": ["Free now", "Disponible", "Disponível", "Disponible", "Gerade frei", "여유 있음", "空闲", "Свободен"], + "Agentを足す": ["Add agent", "Añadir agente", "Adicionar agente", "Ajouter un agent", "Agent hinzufügen", "Agent 추가", "添加 Agent", "Добавить агента"], + "ほか{count}件。言葉を足すと絞れます。": ["{count} more. Add words to narrow it down.", "{count} más. Añade palabras para acotar.", "Mais {count}. Adicione palavras para refinar.", "{count} de plus. Ajoutez des mots pour affiner.", "{count} weitere. Mit mehr Wörtern eingrenzen.", "그 밖에 {count}개. 단어를 더하면 좁혀집니다.", "还有 {count} 项。添加词语可缩小范围。", "Ещё {count}. Добавьте слова, чтобы сузить поиск."], + "例:Scribe": ["e.g. Scribe", "p. ej.: Scribe", "ex.: Scribe", "ex. : Scribe", "z. B. Scribe", "예: Scribe", "例:Scribe", "напр.: Scribe"], + "名前": ["Name", "Nombre", "Nome", "Nom", "Name", "이름", "名称", "Имя"], + "何をするAgent?": ["What does this agent do?", "¿Qué hace este agente?", "O que este agente faz?", "Que fait cet agent ?", "Was macht dieser Agent?", "무엇을 하는 Agent인가요?", "这个 Agent 做什么?", "Что делает агент?"], + "英小文字・数字・ハイフン。MCPでAgentを指すときに使います。あとから変えられません。": ["Lowercase letters, numbers and hyphens. Used to point to the agent over MCP. It can't be changed later.", "Minúsculas, números y guiones. Sirve para identificar al agente por MCP. No se puede cambiar después.", "Letras minúsculas, números e hífens. Usado para indicar o agente via MCP. Não pode ser alterado depois.", "Minuscules, chiffres et tirets. Sert à désigner l'agent via MCP. Non modifiable ensuite.", "Kleinbuchstaben, Ziffern und Bindestriche. Damit wird der Agent über MCP angesprochen. Später nicht änderbar.", "영문 소문자·숫자·하이픈. MCP에서 Agent를 가리킬 때 씁니다. 나중에 바꿀 수 없습니다.", "小写字母、数字和连字符。用于在 MCP 中指定 Agent,之后无法更改。", "Строчные латинские буквы, цифры и дефисы. Нужен, чтобы указать агента через MCP. Позже изменить нельзя."], + "変更": ["Change", "Cambiar", "Alterar", "Modifier", "Ändern", "변경", "更改", "Изменить"], + "+お願いしておくこと": ["+ Standing instructions", "+ Instrucciones fijas", "+ Instruções fixas", "+ Consignes permanentes", "+ Feste Anweisungen", "+ 미리 부탁할 것", "+ 事先交代的事项", "+ Постоянные указания"], + "任意": ["Optional", "Opcional", "Opcional", "Facultatif", "Optional", "선택", "可选", "Необязательно"], + "担当を選ぶときに表示されます。": ["Shown when choosing who takes a task.", "Se muestra al elegir quién se encarga.", "Mostrado ao escolher o responsável.", "Affiché au moment de choisir qui s'en charge.", "Wird bei der Auswahl der Zuständigkeit angezeigt.", "담당을 고를 때 표시됩니다.", "选择负责人时显示。", "Показывается при выборе исполнителя."], + "まだMCPにつながっていません": ["Not connected to MCP yet", "Aún no conectado a MCP", "Ainda não conectado ao MCP", "Pas encore connecté à MCP", "Noch nicht mit MCP verbunden", "아직 MCP에 연결되지 않았습니다", "尚未连接 MCP", "Ещё не подключён к MCP"], + "つなぐ": ["Connect", "Conectar", "Conectar", "Connecter", "Verbinden", "연결", "连接", "Подключить"], + "{name}の操作": ["Actions for {name}", "Acciones de {name}", "Ações de {name}", "Actions pour {name}", "Aktionen für {name}", "{name} 작업", "{name} 的操作", "Действия: {name}"], + "MCP接続を設定": ["Set up MCP connection", "Configurar conexión MCP", "Configurar conexão MCP", "Configurer la connexion MCP", "MCP-Verbindung einrichten", "MCP 연결 설정", "设置 MCP 连接", "Настроить подключение MCP"], + "{name}を外しますか?": ["Remove {name}?", "¿Quitar a {name}?", "Remover {name}?", "Retirer {name} ?", "{name} entfernen?", "{name}을(를) 제외할까요?", "移除 {name}?", "Убрать {name}?"], + "担当に選べなくなり、MCPの紐付けも使えなくなります。": ["It can no longer take tasks, and its MCP link stops working.", "Ya no podrá encargarse de tareas y su vínculo MCP dejará de funcionar.", "Não poderá mais assumir tarefas e o vínculo MCP deixa de funcionar.", "Il ne pourra plus prendre de tâches et son lien MCP ne fonctionnera plus.", "Er kann keine Aufgaben mehr übernehmen, und die MCP-Verknüpfung endet.", "담당으로 고를 수 없게 되고, MCP 연결도 쓸 수 없게 됩니다.", "将无法再被选为负责人,MCP 关联也会失效。", "Его нельзя будет назначать, и связь MCP перестанет работать."], + "担当中の{count}件を自分に戻す": ["Take back its {count} tasks", "Recuperar sus {count} tareas", "Trazer de volta as {count} tarefas", "Reprendre ses {count} tâches", "Seine {count} Aufgaben zurücknehmen", "담당 중인 {count}개를 나에게 되돌리기", "将负责中的 {count} 项收回给自己", "Вернуть себе {count} задач"], + "{name}を登録しました。担当に選べます。": ["{name} is registered and can take tasks.", "{name} registrado. Ya puede encargarse de tareas.", "{name} registrado. Já pode assumir tarefas.", "{name} est enregistré et peut prendre des tâches.", "{name} ist registriert und kann Aufgaben übernehmen.", "{name}을(를) 등록했습니다. 담당으로 고를 수 있습니다.", "已注册 {name},可以选为负责人。", "{name} зарегистрирован и может брать задачи."], + "デモです。変更は保存されません。": ["This is a demo. Changes aren't saved.", "Esto es una demo. Los cambios no se guardan.", "Esta é uma demo. As alterações não são salvas.", "Ceci est une démo. Les modifications ne sont pas enregistrées.", "Das ist eine Demo. Änderungen werden nicht gespeichert.", "데모입니다. 변경 사항은 저장되지 않습니다.", "这是演示。更改不会保存。", "Это демо. Изменения не сохраняются."], + "少し時間がかかっています。そのままお待ちください。": ["This is taking a little longer. Please wait.", "Está tardando un poco más. Espera, por favor.", "Está demorando um pouco mais. Aguarde.", "Cela prend un peu plus de temps. Patientez.", "Das dauert etwas länger. Bitte warten.", "조금 시간이 걸리고 있습니다. 잠시 기다려 주세요.", "需要多一点时间,请稍候。", "Это займёт чуть больше времени. Подождите."], + "利用規約": ["Terms", "Términos", "Termos", "Conditions", "Nutzungsbedingungen", "이용약관", "使用条款", "Условия"], + "プライバシー": ["Privacy", "Privacidad", "Privacidade", "Confidentialité", "Datenschutz", "개인정보", "隐私", "Конфиденциальность"], + "Googleの画面を途中で閉じたか、通信が切れた可能性があります。": ["The Google window may have been closed, or the connection dropped.", "Puede que se cerrara la ventana de Google o se cortara la conexión.", "A janela do Google pode ter sido fechada ou a conexão caiu.", "La fenêtre Google a peut-être été fermée ou la connexion coupée.", "Das Google-Fenster wurde vielleicht geschlossen oder die Verbindung ist abgebrochen.", "Google 화면을 도중에 닫았거나 연결이 끊겼을 수 있습니다.", "可能中途关闭了 Google 页面,或网络连接中断。", "Возможно, окно Google закрыли или прервалась связь."], + "Googleアカウントで始められます。次からも、続きをそのまま使えます。": ["Start with your Google account. Next time, pick up right where you left off.", "Empieza con tu cuenta de Google. La próxima vez, sigue donde lo dejaste.", "Comece com sua conta Google. Da próxima vez, continue de onde parou.", "Commencez avec votre compte Google. La prochaine fois, reprenez où vous en étiez.", "Starte mit deinem Google-Konto. Beim nächsten Mal machst du einfach weiter.", "Google 계정으로 시작할 수 있습니다. 다음에도 이어서 쓸 수 있습니다.", "用 Google 账号即可开始。下次也能接着使用。", "Начните с аккаунта Google. В следующий раз продолжите с того же места."], + "デモはサインインなしで試せます。変更は保存されません。": ["Try the demo without signing in. Changes aren't saved.", "Prueba la demo sin iniciar sesión. Los cambios no se guardan.", "Experimente a demo sem entrar. As alterações não são salvas.", "Essayez la démo sans vous connecter. Rien n'est enregistré.", "Die Demo geht ohne Anmeldung. Änderungen werden nicht gespeichert.", "데모는 로그인 없이 써 볼 수 있습니다. 변경 사항은 저장되지 않습니다.", "演示无需登录即可试用。更改不会保存。", "Демо работает без входа. Изменения не сохраняются."], + "Agentに任せて、あなたは判断だけ": ["Agents do the work; you just decide", "Los agentes trabajan; tú solo decides", "Os agentes fazem; você só decide", "Les agents font, vous décidez", "Agents erledigen, du entscheidest nur", "Agent에게 맡기고, 당신은 판단만", "交给 Agent,你只需判断", "Работают агенты — вы только решаете"], + "画像を変更": ["Change picture", "Cambiar imagen", "Alterar imagem", "Changer l'image", "Bild ändern", "이미지 변경", "更换图片", "Сменить изображение"], + "画像を変えました": ["Picture changed", "Imagen cambiada", "Imagem alterada", "Image modifiée", "Bild geändert", "이미지를 바꿨습니다", "已更换图片", "Изображение изменено"], + "画像を保存できませんでした。もう一度試してください。": ["Couldn't save the picture. Try again.", "No se pudo guardar la imagen. Inténtalo de nuevo.", "Não foi possível salvar a imagem. Tente novamente.", "Impossible d'enregistrer l'image. Réessayez.", "Bild konnte nicht gespeichert werden. Bitte erneut versuchen.", "이미지를 저장하지 못했습니다. 다시 시도해 주세요.", "无法保存图片,请重试。", "Не удалось сохранить изображение. Попробуйте ещё раз."], + "画像を外しました": ["Picture removed", "Imagen quitada", "Imagem removida", "Image retirée", "Bild entfernt", "이미지를 뺐습니다", "已移除图片", "Изображение удалено"], + "画像を外せませんでした。もう一度試してください。": ["Couldn't remove the picture. Try again.", "No se pudo quitar la imagen. Inténtalo de nuevo.", "Não foi possível remover a imagem. Tente novamente.", "Impossible de retirer l'image. Réessayez.", "Bild konnte nicht entfernt werden. Bitte erneut versuchen.", "이미지를 빼지 못했습니다. 다시 시도해 주세요.", "无法移除图片,请重试。", "Не удалось удалить изображение. Попробуйте ещё раз."], + "ほかの人に見える情報です。": ["What other people can see.", "Lo que ven otras personas.", "O que outras pessoas veem.", "Ce que les autres voient.", "Was andere sehen können.", "다른 사람에게 보이는 정보입니다.", "其他人可以看到的信息。", "То, что видят другие."], + "止まっているタスクと、その理由が分かります": ["See stuck tasks and why", "Ve las tareas detenidas y por qué", "Veja tarefas paradas e o motivo", "Voir les tâches bloquées et pourquoi", "Blockierte Aufgaben und ihr Grund", "멈춘 작업과 그 이유를 봅니다", "查看停滞的任务及原因", "Застрявшие задачи и причины"], + "止まっているタスクと、その理由が分かります。": ["See which tasks are stuck and why.", "Ve qué tareas están detenidas y por qué.", "Veja quais tarefas estão paradas e por quê.", "Voyez quelles tâches sont bloquées et pourquoi.", "Sieh, welche Aufgaben blockiert sind und warum.", "멈춘 작업과 그 이유를 알 수 있습니다.", "了解哪些任务停滞以及原因。", "Видно, какие задачи застряли и почему."], + "タスクを終えるとMPがたまり、コマンドで戦えます。": ["Finishing tasks builds MP to fight with commands.", "Terminar tareas acumula MP para luchar con comandos.", "Concluir tarefas acumula MP para lutar com comandos.", "Terminer des tâches donne des PM pour combattre.", "Erledigte Aufgaben sammeln MP für Befehle im Kampf.", "작업을 끝내면 MP가 쌓여 커맨드로 싸울 수 있습니다.", "完成任务积累 MP,用指令战斗。", "Выполненные задачи копят MP для боя командами."], + "外のサービスとの同期を管理します。": ["Manage syncing with outside services.", "Gestiona la sincronización con servicios externos.", "Gerencie a sincronização com serviços externos.", "Gérez la synchronisation avec les services externes.", "Synchronisierung mit externen Diensten verwalten.", "외부 서비스와의 동기화를 관리합니다.", "管理与外部服务的同步。", "Управление синхронизацией с внешними сервисами."], + "Agentがタスクを進めるときに使える機能です。": ["What agents can use when working on tasks.", "Lo que los agentes pueden usar al trabajar en tareas.", "O que os agentes podem usar ao trabalhar nas tarefas.", "Ce que les agents peuvent utiliser pour avancer les tâches.", "Was Agents bei der Arbeit an Aufgaben nutzen können.", "Agent가 작업을 진행할 때 쓸 수 있는 기능입니다.", "Agent 推进任务时可使用的功能。", "Что агенты могут использовать в работе над задачами."], + "待機中": ["Waiting", "En espera", "Aguardando", "En attente", "Wartet", "대기 중", "等待中", "Ожидает"], + "止めているもの": ["Holding it up", "Lo que la frena", "O que está travando", "Ce qui bloque", "Was blockiert", "막고 있는 것", "阻塞因素", "Что мешает"], + "待っているもの": ["Waiting on it", "Lo que espera", "O que está aguardando", "Ce qui attend", "Was wartet", "기다리는 것", "等待中的项目", "Что ждёт"], + "Agent 無制限": ["Unlimited agents", "Agentes ilimitados", "Agentes ilimitados", "Agents illimités", "Unbegrenzte Agents", "Agent 무제한", "Agent 无限制", "Агенты без ограничений"], + "使えるAgent {count}体まで": ["Up to {count} agents", "Hasta {count} agentes", "Até {count} agentes", "Jusqu'à {count} agents", "Bis zu {count} Agents", "Agent 최대 {count}개", "最多 {count} 个 Agent", "До {count} агентов"], + "残りのCredits": ["Credits left", "Créditos restantes", "Créditos restantes", "Crédits restants", "Verbleibende Credits", "남은 Credits", "剩余 Credits", "Осталось Credits"], + "予約中": ["Reserved", "Reservados", "Reservados", "Réservés", "Reserviert", "예약 중", "预留中", "Зарезервировано"], + "調整待ち": ["Pending adjustment", "Ajuste pendiente", "Ajuste pendente", "Ajustement en attente", "Ausstehende Anpassung", "조정 대기", "待调整", "Ожидает корректировки"], + "次回の更新": ["Next renewal", "Próxima renovación", "Próxima renovação", "Prochain renouvellement", "Nächste Verlängerung", "다음 갱신", "下次续订", "Следующее продление"], + "通常のMCP・タスク・受け渡しの操作は0 Credits。自動で追加請求はされません。": ["Regular MCP, task and hand-over actions cost 0 Credits. You're never charged extra automatically.", "Las acciones habituales de MCP, tareas y traspasos cuestan 0 Credits. Nunca se cobra extra automáticamente.", "Ações comuns de MCP, tarefas e repasses custam 0 Credits. Nunca há cobrança extra automática.", "Les actions MCP, tâches et transferts courantes coûtent 0 Credits. Aucun supplément automatique.", "Normale MCP-, Aufgaben- und Übergabeaktionen kosten 0 Credits. Es wird nie automatisch nachberechnet.", "일반 MCP·작업·인계 작업은 0 Credits입니다. 자동 추가 청구는 없습니다.", "常规 MCP、任务和交接操作为 0 Credits,不会自动追加收费。", "Обычные действия MCP, задач и передачи стоят 0 Credits. Автоматических доплат нет."], + "Freeで使うAgent": ["Agents on Free", "Agentes en Free", "Agentes no Free", "Agents en Free", "Agents im Free-Plan", "Free에서 쓰는 Agent", "Free 版使用的 Agent", "Агенты на Free"], + "登録している{count}体のうち2体を選びます。選ばなかったAgentも消えません。": ["Choose 2 of your {count} agents. The others aren't deleted.", "Elige 2 de tus {count} agentes. Los demás no se eliminan.", "Escolha 2 dos seus {count} agentes. Os outros não são excluídos.", "Choisissez 2 de vos {count} agents. Les autres ne sont pas supprimés.", "Wähle 2 deiner {count} Agents. Die anderen werden nicht gelöscht.", "등록한 {count}개 중 2개를 고릅니다. 고르지 않은 Agent도 지워지지 않습니다.", "从已登记的 {count} 个中选择 2 个。未选的 Agent 不会被删除。", "Выберите 2 из {count} агентов. Остальные не удаляются."], + "返金・紹介を含む販売条件をすべて見る": ["See all terms, including refunds and referrals", "Ver todas las condiciones, incluidos reembolsos y referidos", "Ver todas as condições, incluindo reembolsos e indicações", "Voir toutes les conditions, remboursements et parrainage compris", "Alle Bedingungen ansehen, inkl. Erstattung und Empfehlungen", "환불·추천을 포함한 판매 조건 모두 보기", "查看全部销售条款(含退款与推荐)", "Все условия, включая возвраты и рефералов"], + "Proにする": ["Go Pro", "Pasar a Pro", "Assinar o Pro", "Passer à Pro", "Zu Pro wechseln", "Pro로 전환", "升级到 Pro", "Перейти на Pro"], + "支払いの周期": ["Billing period", "Periodo de pago", "Período de cobrança", "Période de facturation", "Abrechnungszeitraum", "결제 주기", "付款周期", "Период оплаты"], + "毎月 {count} Credits": ["{count} Credits a month", "{count} Credits al mes", "{count} Credits por mês", "{count} Credits par mois", "{count} Credits pro Monat", "매월 {count} Credits", "每月 {count} Credits", "{count} Credits в месяц"], + "年払いも毎月の付与で、各付与日から{days}日有効です。": ["Yearly plans also grant monthly; each grant lasts {days} days.", "El plan anual también se otorga cada mes; cada asignación dura {days} días.", "O plano anual também concede mensalmente; cada concessão vale {days} dias.", "L'annuel crédite aussi chaque mois ; chaque crédit dure {days} jours.", "Auch jährlich wird monatlich gutgeschrieben; jede Gutschrift gilt {days} Tage.", "연간 결제도 매월 지급되며, 지급일부터 {days}일간 유효합니다.", "年付也按月发放,每次发放后 {days} 天内有效。", "Годовой план тоже начисляет ежемесячно; каждое начисление действует {days} дней."], + "今は購入できません。": ["Purchases aren't available right now.", "Ahora no se puede comprar.", "Não é possível comprar agora.", "L'achat n'est pas disponible pour le moment.", "Kauf ist gerade nicht möglich.", "지금은 구매할 수 없습니다.", "目前无法购买。", "Сейчас покупка недоступна."], + "紹介コードを入力": ["Enter a referral code", "Introducir código de referido", "Inserir código de indicação", "Saisir un code de parrainage", "Empfehlungscode eingeben", "추천 코드 입력", "输入推荐码", "Ввести реферальный код"], + "このあとStripeの支払い画面に進みます。": ["Next, you'll go to Stripe to pay.", "A continuación irás a Stripe para pagar.", "Em seguida, você irá ao Stripe para pagar.", "Vous passerez ensuite au paiement Stripe.", "Danach geht es zur Zahlung bei Stripe.", "다음으로 Stripe 결제 화면으로 이동합니다.", "接下来将进入 Stripe 付款页面。", "Далее откроется оплата в Stripe."], + "コピーしました": ["Copied", "Copiado", "Copiado", "Copié", "Kopiert", "복사했습니다", "已复制", "Скопировано"], + "コピーできませんでした": ["Couldn't copy", "No se pudo copiar", "Não foi possível copiar", "Copie impossible", "Kopieren fehlgeschlagen", "복사하지 못했습니다", "无法复制", "Не удалось скопировать"], + "自分の紹介コード": ["Your referral code", "Tu código de referido", "Seu código de indicação", "Votre code de parrainage", "Dein Empfehlungscode", "내 추천 코드", "我的推荐码", "Ваш реферальный код"], + "Creditsの履歴": ["Credits history", "Historial de Credits", "Histórico de Credits", "Historique des Credits", "Credits-Verlauf", "Credits 내역", "Credits 记录", "История Credits"], + "担当Agentと次の行動を提案します。": ["Suggests who should take it and what to do next.", "Sugiere quién debe encargarse y qué hacer después.", "Sugere quem assume e o próximo passo.", "Suggère qui s'en charge et la prochaine action.", "Schlägt Zuständige und nächsten Schritt vor.", "담당 Agent와 다음 행동을 제안합니다.", "建议负责的 Agent 和下一步行动。", "Предлагает исполнителя и следующий шаг."], + "担当Agentと次の行動を提案します。{count} Credits": ["Suggests who should take it and what to do next. {count} Credits", "Sugiere quién debe encargarse y qué hacer después. {count} Credits", "Sugere quem assume e o próximo passo. {count} Credits", "Suggère qui s'en charge et la prochaine action. {count} Credits", "Schlägt Zuständige und nächsten Schritt vor. {count} Credits", "담당 Agent와 다음 행동을 제안합니다. {count} Credits", "建议负责的 Agent 和下一步行动。{count} Credits", "Предлагает исполнителя и следующий шаг. {count} Credits"], + "対象のタスク": ["Task", "Tarea", "Tarefa", "Tâche", "Aufgabe", "대상 작업", "目标任务", "Задача"], + "選んでください": ["Choose one", "Elige una", "Escolha uma", "Choisissez", "Bitte wählen", "선택하세요", "请选择", "Выберите"], + "提案を受けられるタスクがありません。": ["No tasks can get a suggestion yet.", "Aún no hay tareas que puedan recibir sugerencia.", "Ainda não há tarefas para sugestão.", "Aucune tâche ne peut encore recevoir de suggestion.", "Noch keine Aufgabe für einen Vorschlag.", "제안을 받을 수 있는 작업이 없습니다.", "暂无可获得建议的任务。", "Пока нет задач для предложения."], + "候補のAgent": ["Candidate agents", "Agentes candidatos", "Agentes candidatos", "Agents candidats", "Agents zur Auswahl", "후보 Agent", "候选 Agent", "Агенты-кандидаты"], + "送る内容を見る": ["See what will be sent", "Ver lo que se enviará", "Ver o que será enviado", "Voir ce qui sera envoyé", "Ansehen, was gesendet wird", "보낼 내용 보기", "查看将发送的内容", "Что будет отправлено"], + "表示したタスクの最小テキストと候補Agent情報をTypeSafeへ送信することに同意します。": ["I agree to send the minimal task text shown and the candidate agents' details to TypeSafe.", "Acepto enviar a TypeSafe el texto mínimo de la tarea mostrado y los datos de los agentes candidatos.", "Concordo em enviar ao TypeSafe o texto mínimo da tarefa exibido e os dados dos agentes candidatos.", "J'accepte d'envoyer à TypeSafe le texte minimal de la tâche affiché et les informations des agents candidats.", "Ich stimme zu, den angezeigten minimalen Aufgabentext und die Angaben der Agents an TypeSafe zu senden.", "표시된 작업의 최소 텍스트와 후보 Agent 정보를 TypeSafe로 보내는 데 동의합니다.", "我同意将所示任务的最少文本和候选 Agent 信息发送给 TypeSafe。", "Я согласен отправить в TypeSafe показанный минимальный текст задачи и данные агентов."], + "提案を受ける": ["Get a suggestion", "Recibir sugerencia", "Receber sugestão", "Obtenir une suggestion", "Vorschlag erhalten", "제안 받기", "获取建议", "Получить предложение"], + "{count} Creditsで提案を受ける": ["Get a suggestion for {count} Credits", "Recibir sugerencia por {count} Credits", "Receber sugestão por {count} Credits", "Obtenir une suggestion pour {count} Credits", "Vorschlag für {count} Credits", "{count} Credits로 제안 받기", "用 {count} Credits 获取建议", "Предложение за {count} Credits"], + "やり直す": ["Start over", "Empezar de nuevo", "Recomeçar", "Recommencer", "Neu beginnen", "다시 하기", "重新开始", "Начать заново"], + "まだ履歴はありません。": ["No history yet.", "Aún no hay historial.", "Ainda sem histórico.", "Aucun historique pour l'instant.", "Noch kein Verlauf.", "아직 내역이 없습니다.", "暂无记录。", "Истории пока нет."], + "← 利用プラン": ["← Plan", "← Plan", "← Plano", "← Forfait", "← Tarif", "← 이용 플랜", "← 套餐", "← Тариф"], + "担当Agentと次の行動の提案です。自動で担当を変更したり、タスクを完了したりしません。": ["A suggestion of who should take it and what's next. It never reassigns or completes a task on its own.", "Una sugerencia de quién se encarga y qué sigue. Nunca reasigna ni completa tareas por sí sola.", "Uma sugestão de quem assume e do próximo passo. Nunca reatribui nem conclui tarefas sozinha.", "Une suggestion de responsable et de suite. Elle ne réattribue ni ne termine jamais une tâche seule.", "Ein Vorschlag für Zuständige und nächsten Schritt. Er weist nie selbst neu zu und schließt nichts ab.", "담당 Agent와 다음 행동의 제안입니다. 자동으로 담당을 바꾸거나 작업을 완료하지 않습니다.", "这是关于负责 Agent 和下一步的建议,不会自动更改负责人或完成任务。", "Предложение исполнителя и следующего шага. Сам ничего не переназначает и не завершает."], + "再接続が必要": ["Reconnect needed", "Requiere reconexión", "Reconexão necessária", "Reconnexion requise", "Neu verbinden nötig", "재연결 필요", "需要重新连接", "Нужно переподключить"], + "判断待ち": ["Needs your call", "Espera tu decisión", "Aguarda sua decisão", "Attend votre décision", "Wartet auf Entscheidung", "판단 대기", "等待判断", "Ждёт решения"], + "進行中": ["In progress", "En curso", "Em andamento", "En cours", "In Arbeit", "진행 중", "进行中", "В работе"], + "未着手": ["Not started", "Sin empezar", "Não iniciada", "Pas commencée", "Nicht begonnen", "미착수", "未开始", "Не начата"], + "{name}の再接続が必要です": ["{name} needs to be reconnected", "Hay que reconectar {name}", "É preciso reconectar {name}", "{name} doit être reconnecté", "{name} muss neu verbunden werden", "{name} 재연결이 필요합니다", "需要重新连接 {name}", "Нужно переподключить {name}"], + "あなたの確認を待っています": ["Waiting for your check", "Espera tu revisión", "Aguardando sua verificação", "Attend votre vérification", "Wartet auf deine Prüfung", "당신의 확인을 기다리고 있습니다", "等待你确认", "Ждёт вашей проверки"], + "前のタスクが終わっていません": ["An earlier task isn't done", "Una tarea anterior no ha terminado", "Uma tarefa anterior não terminou", "Une tâche précédente n'est pas terminée", "Eine vorherige Aufgabe ist nicht erledigt", "앞의 작업이 끝나지 않았습니다", "前置任务尚未完成", "Предыдущая задача не завершена"], + "止まっている理由はまだ書かれていません": ["No reason written yet", "Aún no se ha escrito el motivo", "Motivo ainda não informado", "Aucune raison indiquée", "Noch kein Grund angegeben", "멈춘 이유가 아직 적혀 있지 않습니다", "尚未写明停滞原因", "Причина пока не указана"], + "再接続": ["Reconnect", "Reconectar", "Reconectar", "Reconnecter", "Neu verbinden", "재연결", "重新连接", "Переподключить"], + "開く": ["Open", "Abrir", "Abrir", "Ouvrir", "Öffnen", "열기", "打开", "Открыть"], + "このタスクを止めているもの": ["Holding this task up", "Lo que frena esta tarea", "O que trava esta tarefa", "Ce qui bloque cette tâche", "Was diese Aufgabe blockiert", "이 작업을 막고 있는 것", "阻塞此任务的因素", "Что мешает этой задаче"], + "止めているものは見つかりません。": ["Nothing found holding it up.", "No se encontró nada que la frene.", "Nada encontrado travando.", "Rien ne la bloque.", "Nichts Blockierendes gefunden.", "막고 있는 것을 찾지 못했습니다.", "未找到阻塞因素。", "Ничего мешающего не найдено."], + "このタスクを待っているもの": ["Waiting on this task", "Lo que espera esta tarea", "O que aguarda esta tarefa", "Ce qui attend cette tâche", "Was auf diese Aufgabe wartet", "이 작업을 기다리는 것", "等待此任务的项目", "Что ждёт эту задачу"], + "待っているタスクはありません。": ["Nothing is waiting on it.", "Nada espera esta tarea.", "Nada está aguardando.", "Rien n'attend cette tâche.", "Nichts wartet darauf.", "기다리는 작업이 없습니다.", "没有等待中的任务。", "Ничего не ждёт."], + "← つながり": ["← Network", "← Red", "← Rede", "← Réseau", "← Netzwerk", "← 연결", "← 关联", "← Связи"], + "止まっている理由": ["Why it's stuck", "Por qué está detenida", "Por que está parada", "Pourquoi c'est bloqué", "Warum es feststeckt", "멈춘 이유", "停滞原因", "Почему задача стоит"], + "図": ["Map", "Mapa", "Mapa", "Carte", "Karte", "그림", "图", "Схема"], + "止まっている起点 {roots}件・待っているタスク {waiting}件": ["{roots} stuck at the root · {waiting} waiting", "{roots} detenidas en origen · {waiting} en espera", "{roots} paradas na origem · {waiting} aguardando", "{roots} bloquées à la source · {waiting} en attente", "{roots} Ursachen blockiert · {waiting} warten", "멈춘 기점 {roots}건 · 기다리는 작업 {waiting}건", "停滞起点 {roots} 个 · 等待任务 {waiting} 个", "Точек остановки: {roots} · Ждут: {waiting}"], + "止まっているタスクはありません。": ["Nothing is stuck.", "No hay tareas detenidas.", "Nenhuma tarefa parada.", "Aucune tâche bloquée.", "Nichts steckt fest.", "멈춘 작업이 없습니다.", "没有停滞的任务。", "Застрявших задач нет."], + "待ち {count}": ["{count} waiting", "{count} en espera", "{count} aguardando", "{count} en attente", "{count} warten", "대기 {count}", "等待 {count}", "Ждут {count}"], + "更新": ["Refresh", "Actualizar", "Atualizar", "Actualiser", "Aktualisieren", "새로고침", "刷新", "Обновить"], + "戦闘の状態を読み込めませんでした。": ["Couldn't load the battle.", "No se pudo cargar la batalla.", "Não foi possível carregar a batalha.", "Impossible de charger le combat.", "Kampf konnte nicht geladen werden.", "전투 상태를 불러오지 못했습니다.", "无法载入战斗状态。", "Не удалось загрузить бой."], + "{count}ダメージ": ["{count} damage", "{count} de daño", "{count} de dano", "{count} dégâts", "{count} Schaden", "{count} 대미지", "{count} 点伤害", "{count} урона"], + "ターン {turn}": ["Turn {turn}", "Turno {turn}", "Turno {turn}", "Tour {turn}", "Zug {turn}", "턴 {turn}", "第 {turn} 回合", "Ход {turn}"], + "弱点は{kind}のタスク": ["Weak to {kind} tasks", "Débil ante tareas {kind}", "Fraco contra tarefas {kind}", "Faible face aux tâches {kind}", "Schwach gegen {kind}-Aufgaben", "약점은 {kind} 작업", "弱点是{kind}任务", "Уязвим к задачам «{kind}»"], + "1回きり": ["one-off", "únicas", "únicas", "ponctuelles", "einmalige", "1회성", "一次性", "разовым"], + "ボスのHP": ["Boss HP", "PV del jefe", "PV do chefe", "PV du boss", "Boss-HP", "보스 HP", "首领 HP", "HP босса"], + "自分のHP": ["Your HP", "Tus PV", "Seus PV", "Vos PV", "Deine HP", "내 HP", "我的 HP", "Ваши HP"], + "この戦闘は終わりました。": ["This battle is over.", "Esta batalla terminó.", "Esta batalha terminou.", "Ce combat est terminé.", "Dieser Kampf ist vorbei.", "이 전투는 끝났습니다.", "本场战斗已结束。", "Этот бой окончен."], + "あと {count}": ["{count} short", "faltan {count}", "faltam {count}", "il manque {count}", "{count} fehlen", "{count} 부족", "还差 {count}", "не хватает {count}"], + "変化なし": ["No change", "Sin cambios", "Sem mudança", "Inchangé", "Keine Änderung", "변화 없음", "无变化", "Без изменений"], + "選び直す": ["Choose again", "Elegir otra", "Escolher outra", "Choisir à nouveau", "Neu wählen", "다시 고르기", "重新选择", "Выбрать снова"], + "この手を実行": ["Play this move", "Hacer esta jugada", "Fazer esta jogada", "Jouer ce coup", "Diesen Zug ausführen", "이 수 실행", "执行这一步", "Сделать ход"], + "{command} を実行すると": ["If you use {command}", "Si usas {command}", "Se usar {command}", "Si vous utilisez {command}", "Wenn du {command} nutzt", "{command}을(를) 실행하면", "执行 {command} 后", "Если выполнить {command}"], + "ターン": ["Turn", "Turno", "Turno", "Tour", "Zug", "턴", "回合", "Ход"], + "結果を計算しています": ["Working out the result", "Calculando el resultado", "Calculando o resultado", "Calcul du résultat", "Ergebnis wird berechnet", "결과를 계산하고 있습니다", "正在计算结果", "Считаем результат"], + "実行しています": ["Running", "Ejecutando", "Executando", "En cours", "Wird ausgeführt", "실행하고 있습니다", "正在执行", "Выполняется"], + "まだ実行していません。": ["Nothing has been done yet.", "Aún no se ha ejecutado.", "Ainda não foi executado.", "Rien n'a encore été fait.", "Noch nicht ausgeführt.", "아직 실행하지 않았습니다.", "尚未执行。", "Ещё не выполнено."], + "最新にする": ["Get the latest", "Obtener lo último", "Atualizar agora", "Récupérer la dernière version", "Neu laden", "최신으로", "获取最新", "Обновить данные"], + "次の一手": ["Next move", "Siguiente jugada", "Próxima jogada", "Coup suivant", "Nächster Zug", "다음 수", "下一步", "Следующий ход"], + "タスクを終えてMPをためる": ["Finish tasks to build MP", "Termina tareas para acumular MP", "Conclua tarefas para acumular MP", "Terminez des tâches pour gagner des PM", "Aufgaben erledigen, MP sammeln", "작업을 끝내 MP 모으기", "完成任务积累 MP", "Завершайте задачи, копите MP"], + "これまでの記録": ["Record so far", "Registro hasta ahora", "Registro até agora", "Historique", "Bisheriger Verlauf", "지금까지의 기록", "历史记录", "История"], + "記録をすべて見る {count}件": ["See the full record ({count})", "Ver todo el registro ({count})", "Ver todo o registro ({count})", "Voir tout l'historique ({count})", "Gesamten Verlauf ansehen ({count})", "기록 모두 보기 {count}건", "查看全部记录({count} 条)", "Вся история ({count})"], + "許可が必要": ["Permission needed", "Requiere permiso", "Permissão necessária", "Autorisation requise", "Berechtigung nötig", "허가 필요", "需要授权", "Нужно разрешение"], + "同期に問題": ["Sync problem", "Problema de sincronización", "Problema de sincronização", "Problème de synchro", "Sync-Problem", "동기화 문제", "同步有问题", "Проблема синхронизации"], + "接続済み": ["Connected", "Conectado", "Conectado", "Connecté", "Verbunden", "연결됨", "已连接", "Подключено"], + "未接続": ["Not connected", "Sin conectar", "Não conectado", "Non connecté", "Nicht verbunden", "미연결", "未连接", "Не подключено"], + "近日対応": ["Coming soon", "Próximamente", "Em breve", "Bientôt", "Demnächst", "곧 지원", "即将支持", "Скоро"], + "次の開発段階で使えるようになります": ["Available in an upcoming stage", "Disponible en una próxima etapa", "Disponível numa próxima etapa", "Disponible lors d'une prochaine étape", "In einer kommenden Phase verfügbar", "다음 개발 단계에서 쓸 수 있게 됩니다", "将在后续开发阶段提供", "Появится на следующем этапе"], + "タスク{count}件の同期が止まっています": ["Sync stopped for {count} tasks", "Sincronización detenida en {count} tareas", "Sincronização parada em {count} tarefas", "Synchro arrêtée pour {count} tâches", "Sync für {count} Aufgaben gestoppt", "작업 {count}건의 동기화가 멈췄습니다", "{count} 个任务的同步已停止", "Синхронизация остановлена для {count} задач"], + "{time} の同期で問題がありました": ["A problem in the {time} sync", "Hubo un problema en la sincronización de {time}", "Houve um problema na sincronização de {time}", "Problème lors de la synchro de {time}", "Problem beim Sync um {time}", "{time} 동기화에서 문제가 있었습니다", "{time} 的同步出现问题", "Проблема при синхронизации {time}"], + "{time} に同期": ["Synced {time}", "Sincronizado {time}", "Sincronizado {time}", "Synchronisé {time}", "Synchronisiert {time}", "{time} 동기화", "{time} 已同步", "Синхронизировано {time}"], + "まだ同期していません": ["Not synced yet", "Aún sin sincronizar", "Ainda não sincronizado", "Pas encore synchronisé", "Noch nicht synchronisiert", "아직 동기화하지 않았습니다", "尚未同步", "Ещё не синхронизировано"], + "確認": ["Check", "Revisar", "Verificar", "Vérifier", "Prüfen", "확인", "查看", "Проверить"], + "設定する": ["Set up", "Configurar", "Configurar", "Configurer", "Einrichten", "설정하기", "设置", "Настроить"], + "接続を解除": ["Disconnect", "Desconectar", "Desconectar", "Déconnecter", "Trennen", "연결 해제", "断开连接", "Отключить"], + "接続を解除すると次のことが起きます": ["Disconnecting will do the following", "Al desconectar ocurrirá lo siguiente", "Ao desconectar, acontecerá o seguinte", "La déconnexion entraînera ceci", "Beim Trennen passiert Folgendes", "연결을 해제하면 다음이 일어납니다", "断开连接后将发生以下情况", "При отключении произойдёт следующее"], + "再接続する": ["Reconnect", "Reconectar", "Reconectar", "Reconnecter", "Neu verbinden", "재연결하기", "重新连接", "Переподключить"], + "止まっているタスク": ["Stopped tasks", "Tareas detenidas", "Tarefas paradas", "Tâches arrêtées", "Gestoppte Aufgaben", "멈춘 작업", "已停止的任务", "Остановленные задачи"], + "接続先": ["Account", "Cuenta", "Conta", "Compte", "Konto", "연결 대상", "连接账户", "Аккаунт"], + "最後の同期": ["Last sync", "Última sincronización", "Última sincronização", "Dernière synchro", "Letzter Sync", "마지막 동기화", "上次同步", "Последняя синхронизация"], + "操作できるAgent": ["Agents allowed", "Agentes con acceso", "Agentes com acesso", "Agents autorisés", "Berechtigte Agents", "조작 가능한 Agent", "可操作的 Agent", "Агенты с доступом"], + "認証方式と権限を見る": ["See sign-in method and permissions", "Ver método de acceso y permisos", "Ver método de acesso e permissões", "Voir la méthode d'accès et les autorisations", "Anmeldeart und Berechtigungen ansehen", "인증 방식과 권한 보기", "查看认证方式与权限", "Способ входа и права"], + "認証方式": ["Sign-in method", "Método de acceso", "Método de acesso", "Méthode d'accès", "Anmeldeart", "인증 방식", "认证方式", "Способ входа"], + "必要な権限": ["Permissions needed", "Permisos necesarios", "Permissões necessárias", "Autorisations requises", "Benötigte Berechtigungen", "필요한 권한", "所需权限", "Нужные права"], + "同期の向き": ["Sync direction", "Dirección de sincronización", "Direção da sincronização", "Sens de synchro", "Sync-Richtung", "동기화 방향", "同步方向", "Направление синхронизации"], + "← 連携": ["← Connections", "← Conexiones", "← Conexões", "← Connexions", "← Verbindungen", "← 연동", "← 集成", "← Интеграции"], + "すべて": ["All", "Todas", "Todas", "Toutes", "Alle", "전체", "全部", "Все"], + "要対応": ["Needs action", "Requiere acción", "Requer ação", "À traiter", "Handlungsbedarf", "조치 필요", "需处理", "Требует действий"], + "表示できる連携はありません。": ["No connections to show.", "No hay conexiones que mostrar.", "Nenhuma conexão para mostrar.", "Aucune connexion à afficher.", "Keine Verbindungen.", "표시할 연동이 없습니다.", "没有可显示的集成。", "Нет интеграций."], + "URLをコピー": ["Copy URL", "Copiar URL", "Copiar URL", "Copier l'URL", "URL kopieren", "URL 복사", "复制 URL", "Копировать URL"], + "接続を設定": ["Set up connection", "Configurar conexión", "Configurar conexão", "Configurer la connexion", "Verbindung einrichten", "연결 설정", "设置连接", "Настроить подключение"], + "まだつながっているAgentはいません": ["No agents connected yet", "Aún no hay agentes conectados", "Nenhum agente conectado ainda", "Aucun agent connecté", "Noch keine Agents verbunden", "아직 연결된 Agent가 없습니다", "尚无已连接的 Agent", "Подключённых агентов пока нет"], + "{count}件のクライアントがつながっています": ["{count} clients connected", "{count} clientes conectados", "{count} clientes conectados", "{count} clients connectés", "{count} Clients verbunden", "클라이언트 {count}개가 연결되어 있습니다", "已连接 {count} 个客户端", "Подключено клиентов: {count}"], + "機能やツール名を検索": ["Search features or tool names", "Buscar funciones o herramientas", "Buscar recursos ou ferramentas", "Rechercher une fonction ou un outil", "Funktionen oder Tools suchen", "기능이나 도구 이름 검색", "搜索功能或工具名称", "Поиск функций и инструментов"], + "機能の一覧を読み込めませんでした。": ["Couldn't load the feature list.", "No se pudo cargar la lista de funciones.", "Não foi possível carregar a lista de recursos.", "Impossible de charger la liste des fonctions.", "Funktionsliste konnte nicht geladen werden.", "기능 목록을 불러오지 못했습니다.", "无法载入功能列表。", "Не удалось загрузить список функций."], + "「{query}」に合う機能はありません。": ["Nothing matches \"{query}\".", "Nada coincide con «{query}».", "Nada corresponde a \"{query}\".", "Rien ne correspond à « {query} ».", "Nichts passt zu „{query}“.", "'{query}'에 맞는 기능이 없습니다.", "没有与「{query}」匹配的功能。", "Ничего не найдено по «{query}»."], + "使える機能はまだありません。": ["No features available yet.", "Aún no hay funciones disponibles.", "Ainda não há recursos disponíveis.", "Aucune fonction disponible.", "Noch keine Funktionen verfügbar.", "사용할 수 있는 기능이 아직 없습니다.", "暂无可用功能。", "Доступных функций пока нет."], + "使える機能": ["Available features", "Funciones disponibles", "Recursos disponíveis", "Fonctions disponibles", "Verfügbare Funktionen", "사용 가능한 기능", "可用功能", "Доступные функции"], + "使えません": ["Unavailable", "No disponible", "Indisponível", "Indisponible", "Nicht verfügbar", "사용 불가", "不可用", "Недоступно"], + "タスクの管理": ["Task management", "Gestión de tareas", "Gestão de tarefas", "Gestion des tâches", "Aufgabenverwaltung", "작업 관리", "任务管理", "Управление задачами"], + "タスクの作成・更新・担当・進行を管理します。": ["Create, update, assign and track tasks.", "Crea, actualiza, asigna y sigue tareas.", "Crie, atualize, atribua e acompanhe tarefas.", "Créer, modifier, attribuer et suivre les tâches.", "Aufgaben anlegen, ändern, zuweisen und verfolgen.", "작업의 생성·업데이트·담당·진행을 관리합니다.", "创建、更新、分配和跟进任务。", "Создание, изменение, назначение и ход задач."], + "Agentとリレー": ["Agents and relays", "Agentes y relevos", "Agentes e revezamentos", "Agents et relais", "Agents und Staffeln", "Agent와 릴레이", "Agent 与接力", "Агенты и эстафеты"], + "Agent、接続、担当、受け渡しを管理します。": ["Manage agents, connections, assignments and hand-overs.", "Gestiona agentes, conexiones, asignaciones y traspasos.", "Gerencie agentes, conexões, atribuições e repasses.", "Gérer agents, connexions, attributions et transferts.", "Agents, Verbindungen, Zuweisungen und Übergaben verwalten.", "Agent, 연결, 담당, 인계를 관리합니다.", "管理 Agent、连接、分配与交接。", "Агенты, подключения, назначения и передачи."], + "プロフィールと交流": ["Profile and friends", "Perfil y amigos", "Perfil e amigos", "Profil et amis", "Profil und Freunde", "프로필과 교류", "个人资料与交流", "Профиль и друзья"], + "プロフィールとフレンドを管理します。": ["Manage your profile and friends.", "Gestiona tu perfil y amigos.", "Gerencie seu perfil e amigos.", "Gérer votre profil et vos amis.", "Profil und Freunde verwalten.", "프로필과 친구를 관리합니다.", "管理个人资料和好友。", "Профиль и друзья."], + "バトルと報酬": ["Battle and rewards", "Batalla y recompensas", "Batalha e recompensas", "Combat et récompenses", "Kampf und Belohnungen", "배틀과 보상", "战斗与奖励", "Бой и награды"], + "バトルと報酬の状態を確認・操作します。": ["Check and act on battles and rewards.", "Consulta y gestiona batallas y recompensas.", "Veja e use batalhas e recompensas.", "Consulter et agir sur combats et récompenses.", "Kämpfe und Belohnungen ansehen und steuern.", "배틀과 보상 상태를 확인·조작합니다.", "查看并操作战斗与奖励。", "Просмотр и действия с боями и наградами."], + "税込です。": ["Tax included.", "Impuestos incluidos.", "Impostos incluídos.", "Taxes comprises.", "Inkl. Steuern.", "세금 포함입니다.", "含税。", "Налог включён."], + "税別です。": ["Tax not included.", "Impuestos no incluidos.", "Impostos não incluídos.", "Hors taxes.", "Zzgl. Steuern.", "세금 별도입니다.", "不含税。", "Без учёта налога."], + "月払いでProにする": ["Go Pro monthly", "Pasar a Pro mensual", "Assinar o Pro mensal", "Passer à Pro mensuel", "Pro monatlich", "월 결제로 Pro 전환", "按月升级到 Pro", "Pro помесячно"], + "年払いでProにする": ["Go Pro yearly", "Pasar a Pro anual", "Assinar o Pro anual", "Passer à Pro annuel", "Pro jährlich", "연 결제로 Pro 전환", "按年升级到 Pro", "Pro на год"], + "紐づくタスクの同期が止まります。タスクは削除しません。": ["Sync stops for linked tasks. The tasks aren't deleted.", "Se detiene la sincronización de las tareas vinculadas. Las tareas no se eliminan.", "A sincronização das tarefas vinculadas para. As tarefas não são excluídas.", "La synchro des tâches liées s'arrête. Les tâches ne sont pas supprimées.", "Der Sync verknüpfter Aufgaben stoppt. Aufgaben werden nicht gelöscht.", "연결된 작업의 동기화가 멈춥니다. 작업은 삭제되지 않습니다.", "关联任务的同步将停止,任务不会被删除。", "Синхронизация связанных задач остановится. Задачи не удаляются."], + "MPが足りません。タスクを終えてMPをためてください。": ["Not enough MP. Finish tasks to build MP.", "No hay MP suficiente. Termina tareas para acumular MP.", "MP insuficiente. Conclua tarefas para acumular MP.", "PM insuffisants. Terminez des tâches pour en gagner.", "Nicht genug MP. Erledige Aufgaben, um MP zu sammeln.", "MP가 부족합니다. 작업을 끝내 MP를 모으세요.", "MP 不足。完成任务来积累 MP。", "Не хватает MP. Завершайте задачи, чтобы накопить MP."], }; diff --git a/interaction-lab/all-for-adhd/emoji.ts b/interaction-lab/all-for-adhd/emoji.ts index 38cad0c..cf4e3e8 100644 --- a/interaction-lab/all-for-adhd/emoji.ts +++ b/interaction-lab/all-for-adhd/emoji.ts @@ -75,7 +75,10 @@ export function iconFor(title: string): string { } /** Choices for the picker: matches for the title first, then recent picks, then common ones. */ +// Everyday picks for people with no match or history yet; the dictionary order starts with medical ones. +const DAILY = ["📝", "📧", "🛒", "🧹", "☕", "🌿", "💭", "📞"]; + export function iconChoices(title: string, recent: readonly string[], count = 8): string[] { const matches = compiled.filter(([, pattern]) => pattern.test(title)).map(([emoji]) => emoji); - return [...new Set([...matches, ...recent, ...DICTIONARY.map(([emoji]) => emoji)])].slice(0, count); + return [...new Set([...matches, ...recent, ...DAILY])].slice(0, count); } diff --git a/interaction-lab/all-for-adhd/forge.ts b/interaction-lab/all-for-adhd/forge.ts index b5910f7..0e0b572 100644 --- a/interaction-lab/all-for-adhd/forge.ts +++ b/interaction-lab/all-for-adhd/forge.ts @@ -7,17 +7,22 @@ import type { BattleSession, Quest } from "../../types/questforge.ts"; import type { RelayForgeRuntime } from "../relay-forge/production.ts"; -import { relayText } from "../relay-forge/relay-copy.ts"; import { deriveMcpUrl } from "../relay-forge/screens/settings.ts"; import type { ScreenContext } from "../relay-forge/screens/runtime.ts"; import { initialQuestsState, normalizeQuestsModel, renderQuestsDesktop, renderQuestsMobile } from "../relay-forge/screens/quests.ts"; import { initialNetworkState, normalizeNetworkModel, renderNetworkDesktop, renderNetworkMobile } from "../relay-forge/screens/network.ts"; -import { initialBattleState, normalizeBattleModel, renderBattleDesktop, renderBattleMobile } from "../relay-forge/screens/battle.ts"; +import { initialBattleState, normalizeBattleModel } from "../relay-forge/screens/battle.ts"; import { validateBattleSession } from "../relay-forge/screens/battle-port.ts"; -import { initialConnectionsState, normalizeConnectionsModel, renderConnectionsDesktop, renderConnectionsMobile } from "../relay-forge/screens/connections.ts"; +import { initialConnectionsState, normalizeConnectionsModel } from "../relay-forge/screens/connections.ts"; import { REQUIRED_SCOPES } from "../relay-forge/screens/connections-port.ts"; import type { ConnectionActionResult } from "../relay-forge/screens/connections-model.ts"; -import { initialSkillsState, normalizeSkillsModel, renderSkillsDesktop, renderSkillsMobile } from "../relay-forge/screens/skills.ts"; +import { networkView, kindLabel, stateLabel } from "./network-view.ts"; +import { tx } from "./copy.ts"; +import { battleView } from "./battle-view.ts"; +import { connectionsView } from "./connections-view.ts"; +import { skillsView } from "./skills-view.ts"; +import { el } from "../relay-forge/primitives/dom.ts"; +import { initialSkillsState, normalizeSkillsModel } from "../relay-forge/screens/skills.ts"; export type ForgeScreen = "list" | "network" | "battle" | "connections" | "skills"; @@ -32,6 +37,7 @@ export interface ForgeHost { readonly refresh: () => Promise; readonly workTime?: (quest: Quest) => string; readonly relatedNotes?: (questId: string) => HTMLElement; + readonly openMcpSettings: () => void; } const DOMAIN: Readonly> = { @@ -46,11 +52,17 @@ export function forgeScreens(host: ForgeHost) { }; let battle: BattleSession | null = null; let battleVersion = 0; + let battleRequested = false; + let connectionsRequested = false; let integrations: RelayForgeRuntime["integrations"] | null = null; let tools: readonly unknown[] | null = null; let toolsLoading = false; let toolsError: string | null = null; let selected: string | null = null; + // Network: the list of blocked roots comes first; the drawing is one switch away. + let netMode: "list" | "graph" = "list"; + let netRoot: string | null = null; + let netDetail = false; const mobile = () => matchMedia("(max-width: 760px)").matches; const context = (): ScreenContext => ({ @@ -119,7 +131,10 @@ export function forgeScreens(host: ForgeHost) { } function enter(screen: ForgeScreen): void { - if (screen === "battle" && battle === null) { battle = host.runtime().battleSession; void refreshBattle(); } + // Entered on every render, so the first read is started only once. + if (screen === "battle" && battle === null && !battleRequested) { battleRequested = true; battle = host.runtime().battleSession; void refreshBattle(); } + // Connections are read on entry too, so a reload shows the current state. + if (screen === "connections" && !connectionsRequested) { connectionsRequested = true; void refreshConnections(); } if (screen === "skills" && tools === null && !toolsLoading) { const runtime = host.runtime(); if (runtime.mcpToolsLoadError === null && runtime.mcpTools.length > 0) tools = runtime.mcpTools; else void loadTools(); @@ -151,7 +166,36 @@ export function forgeScreens(host: ForgeHost) { questIds: quests.filter(q => q.externalLinks.some(link => link.service === entry.id)).map(q => q.id), })), }); - return pick(() => renderNetworkDesktop(model, state.network, ctx, selected), () => renderNetworkMobile(model, state.network, ctx, selected)).main; + const graph = (rootId: string | null) => { + state.network.view = "graph"; + if (rootId) state.network.focusId = rootId; + const drawn = pick(() => renderNetworkDesktop(model, state.network, ctx, rootId), () => renderNetworkMobile(model, state.network, ctx, rootId)).main; + // Only the drawing is kept; the numbers, the reasons column and Relay Forge's own heading stay out. + const canvas = drawn.querySelector(".rf-n-workspace > :first-child") ?? drawn; + canvas.querySelectorAll(".rf-n-node").forEach(card => { + const node = model.nodes.get(card.dataset.nodeId ?? ""); + if (!node) return; + // Type and state in the reader's words; no reference numbers. + const top = card.querySelector(".rf-n-node-top"); + // What waits on the centre reads as waiting, the same as the list. + const label = card.dataset.role === "downstream" && node.blocked ? { label: tx("待機中"), tone: "neutral" } : stateLabel(node); + top?.replaceChildren(el("span", { class: "a4-net-kind" }, kindLabel(node.kind), label ? el("span", { class: "a4-net-chip", "data-tone": label.tone }, label.label) : null)); + }); + const lanes = canvas.querySelectorAll(".rf-n-lane-labels > span"); + if (lanes[0]) lanes[0].textContent = tx("止めているもの"); + if (lanes[1]) lanes[1].textContent = tx("待っているもの"); + return el("div", { class: "a4-net-graph" }, canvas); + }; + return networkView(model, { + mobile: ctx.isMobile, mode: netMode, selected: netRoot, detailOpen: netDetail, + blockedReason: id => quests.find(q => q.id === id)?.handoff.blockedReason ?? "", + setMode: mode => { netMode = mode; host.render(); }, + select: id => { netRoot = id; netDetail = ctx.isMobile; host.render(); if (netDetail) requestAnimationFrame(() => document.querySelector(".a4-net h1")?.focus()); }, + closeDetail: () => { netDetail = false; host.render(); }, + openQuest: host.openQuest, + reconnect: id => ctx.onNavigate("connections", id), + graph, + }); } if (screen === "battle") { const model = normalizeBattleModel({ session: battle, notices: [], writeHeld: ctx.writeLocked, decisions: state.battle.decisions }); @@ -159,7 +203,7 @@ export function forgeScreens(host: ForgeHost) { port: runtime.battlePort, isDisposed: () => false, onRefresh: refreshBattle, onSession: (session: BattleSession) => { battleVersion += 1; battle = session; }, }; - return pick(() => renderBattleDesktop(model, state.battle, ctx, callbacks), () => renderBattleMobile(model, state.battle, ctx, callbacks)).main; + return battleView(model, state.battle, callbacks, { rerender: host.render, announce: host.announce, openQuest: host.openQuest }, battle === null); } if (screen === "connections") { const model = normalizeConnectionsModel({ @@ -170,20 +214,16 @@ export function forgeScreens(host: ForgeHost) { notices: [], writeHeld: ctx.writeLocked, refreshFailed: false, }); const port = connectionsPort(); - return pick(() => renderConnectionsDesktop(model, state.connections, ctx, port), () => renderConnectionsMobile(model, state.connections, ctx, port)).main; + return connectionsView(model, state.connections, { mobile: ctx.isMobile, rerender: host.render, announce: host.announce, openQuest: host.openQuest, port, + agentName: id => runtime.agents.find(agent => agent.agentId === id)?.displayName ?? id }); } const authorized = runtime.agentConnections.filter(connection => connection.authorized).length; const model = normalizeSkillsModel({ tools: tools ?? [], sourceLabel: "Guilduo MCP", sourceUrl: deriveMcpUrl(runtime.gatewayUrl), - connectionLabel: authorized === 0 ? relayText("skillsNoClients") : `${relayText("skillsOAuth")}: ${authorized}`, query: state.skills.query, + query: state.skills.query, loading: toolsLoading, connected: true, error: toolsError, }); - const callbacks = { - onSearch: (query: string) => { state.skills.query = query; host.render(); }, - onToggleGroup: (groupId: string) => { state.skills.expandedGroups[groupId] = state.skills.expandedGroups[groupId] !== true; host.render(); }, - onRetry: () => { void loadTools(); }, - }; - return pick(() => renderSkillsDesktop(model, state.skills, ctx, callbacks), () => renderSkillsMobile(model, state.skills, ctx, callbacks)).main; + return skillsView(model, state.skills, { authorized, rerender: host.render, announce: host.announce, openMcpSettings: host.openMcpSettings, retry: () => { void loadTools(); } }); } return { enter, render }; diff --git a/interaction-lab/all-for-adhd/images.ts b/interaction-lab/all-for-adhd/images.ts index 0839401..488423d 100644 --- a/interaction-lab/all-for-adhd/images.ts +++ b/interaction-lab/all-for-adhd/images.ts @@ -11,7 +11,7 @@ export function questImageEditor(port: QuestImagePort | undefined, quest: Quest, const rows = document.createElement("div"); rows.className = "a4-image-list"; const status = document.createElement("p"); status.className = "a4-meta"; status.setAttribute("role", "status"); const picker = document.createElement("input"); picker.type = "file"; picker.multiple = true; picker.accept = "image/png,image/jpeg,image/webp"; picker.hidden = true; - const add = document.createElement("button"); add.type = "button"; add.className = "a4-button"; add.textContent = tx("画像を添付"); + const add = document.createElement("button"); add.type = "button"; add.className = context === "response" ? "a4-link" : "a4-button"; add.textContent = context === "response" ? tx("+ 回答に画像を添付") : tx("画像を添付"); const hint = document.createElement("p"); hint.className = "a4-meta"; hint.textContent = tx("PNG・JPEG・WebP。1枚10MB、1タスク10枚。貼り付けやドロップも使えます。"); if (context === "review") root.append(title, rows, status); else root.append(title, rows, add, picker, hint, status); @@ -68,15 +68,15 @@ export function questImageEditor(port: QuestImagePort | undefined, quest: Quest, const labels = { reference: tx("参考画像"), result: tx("成果画像"), request: tx("確認用の画像"), response: tx("回答の画像") }; const visible = context === "review" ? reviewImages(images, quest.id, quest.humanRequest?.attachmentIds) : context === "response" ? images.filter(image => image.context === "response" && image.reviewQuestId === reviewId) : images; - if (context === "review") root.hidden = loaded && visible.length === 0; + if (context === "review") { root.hidden = loaded && visible.length === 0; title.replaceChildren(tx("確認用の画像"), Object.assign(document.createElement("span"), { textContent: tx("{count}枚", { count: visible.length }) })); } for (const image of visible) { const row = document.createElement("div"); row.className = "a4-image-row"; - const name = document.createElement("span"); name.textContent = `${labels[image.context]} · ${image.caption || image.filename}`; + const name = document.createElement("span"); name.textContent = context === "review" ? image.caption || image.filename : `${labels[image.context]} · ${image.caption || image.filename}`; row.append(name); if (image.deletedAt) { row.append(document.createTextNode(tx("削除済み"))); rows.append(row); continue; } if (removed.has(image.id)) { row.append(button(tx("元に戻す"), () => { removed.delete(image.id); paint(); })); rows.append(row); continue; } const img = document.createElement("img"); img.alt = image.caption || image.filename; img.loading = "lazy"; img.width = 160; img.height = 100; - const open = button(tx("拡大する"), () => { + const open = button(context === "review" ? "" : tx("拡大する"), () => { const dialog = document.createElement("dialog"); dialog.className = "a4-image-viewer"; dialog.setAttribute("aria-label", image.caption || image.filename); const large = document.createElement("img"); large.alt = img.alt; large.src = img.src; @@ -88,12 +88,16 @@ export function questImageEditor(port: QuestImagePort | undefined, quest: Quest, const url = URL.createObjectURL(blob); urls.add(url); large.src = url; }).catch(() => { if (!disposed) status.textContent = tx("画像を読み込めませんでした。再試行できます。"); }).finally(() => { original.disabled = false; }); }); - dialog.append(close, original, large); + original.className = "a4-link"; + const head = document.createElement("div"); head.className = "a4-image-viewer-head"; + head.append(Object.assign(document.createElement("strong"), { textContent: image.caption || image.filename }), original, close); + dialog.append(head, large); dialog.addEventListener("close", () => { dialog.remove(); if (open.isConnected) open.focus(); }); viewers.add(dialog); dialog.addEventListener("close", () => viewers.delete(dialog)); document.body.append(dialog); dialog.showModal(); close.focus(); }); open.prepend(img); row.append(open); + if (context === "review") { open.setAttribute("aria-label", `${tx("拡大する")}: ${name.textContent}`); row.append(name); } if (!readonly && (context !== "response" || image.context === "response" && image.reviewQuestId === reviewId)) row.append(button(tx("削除"), () => { removed.add(image.id); paint(); })); rows.append(row); void preview(image, img); } diff --git a/interaction-lab/all-for-adhd/main.ts b/interaction-lab/all-for-adhd/main.ts index 1d43e8e..338089a 100644 --- a/interaction-lab/all-for-adhd/main.ts +++ b/interaction-lab/all-for-adhd/main.ts @@ -25,6 +25,7 @@ import "./styles.css"; import "./polish.css"; import type { CharacterState, Quest } from "../../types/questforge.ts"; import { requireElement } from "../relay-forge/primitives/dom.ts"; +import { createElement, Sun, CalendarDays, Users, type IconNode } from "lucide"; import { observeAuthState, signIn, signOutUser, getIdToken } from "../auth.ts"; import { QuestForgeRepository } from "../repository.ts"; import { mountAllFor, type AllForActivity, type AllForAgent, type AllForConnection, type AllForPort, type NotifySettings, type Relay } from "./app.ts"; @@ -41,8 +42,23 @@ const localToday = () => new Intl.DateTimeFormat("en-CA", { year: "numeric", mon type BootKind = "loading" | "welcome" | "error"; /** Boot screens share one calm layout: wordmark, a headline, one line, then either a quiet placeholder or the actions. */ -function message(title: string, body: string, actions: readonly [string, () => void][] = [], kind: BootKind = actions.length ? "error" : "loading", points: readonly string[] = []): void { +interface BootExtra { + /** Icon and line pairs under the lead. */ + readonly points?: readonly (readonly [IconNode, string])[]; + readonly note?: string; + readonly legal?: boolean; + /** The first action is Google sign-in and carries the Google mark. */ + readonly google?: boolean; + readonly signOut?: boolean; +} + +// Google's own four colours, kept to the mark on the sign-in button (approved exception; not product tokens). +const GOOGLE_G = ''; +let slowTimer = 0; + +function message(title: string, body: string, actions: readonly [string, () => void][] = [], kind: BootKind = actions.length ? "error" : "loading", extra: BootExtra = {}): void { unmount?.(); unmount = null; + window.clearTimeout(slowTimer); const node = (tag: K, className = "", text = "") => { const item = document.createElement(tag); if (className) item.className = className; @@ -52,31 +68,57 @@ function message(title: string, body: string, actions: readonly [string, () => v const panel = node("main", "a4-boot"); panel.dataset.kind = kind; panel.setAttribute("aria-busy", String(kind === "loading")); + // The same brand mark as the app's rail. + const mark = node("p", "a4-boot-mark"); + for (const [src, cls] of [["/assets/brand/guilduo-mark-ink.svg", "a4-brand-light"], ["/assets/brand/guilduo-mark-gold.svg", "a4-brand-dark"]] as const) { + const img = node("img", cls); img.src = src; img.alt = ""; img.width = 19; img.height = 26; mark.append(img); + } + mark.append("Guilduo"); + panel.append(mark); const h1 = node("h1", "", title); h1.tabIndex = -1; - panel.append(node("p", "a4-boot-mark", "Guilduo"), h1, node("p", "a4-boot-lead", body)); - if (points.length) { + if (title) panel.append(h1); + const lead = node("p", "a4-boot-lead", body); + if (kind !== "loading") panel.append(lead); + if (extra.points?.length) { const list = node("ul", "a4-boot-points"); - for (const point of points) list.append(node("li", "", point)); + for (const [icon, point] of extra.points) { const item = node("li"); item.append(createElement(icon), point); list.append(item); } panel.append(list); } if (kind === "loading") { - // A faint outline of the Now card, so the wait looks like the screen that is coming. + // One screen while loading: a faint outline of the Now card, then one quiet line that turns patient after 3 s. const ghost = node("div", "a4-boot-ghost"); ghost.setAttribute("aria-hidden", "true"); for (const width of ["30%", "72%", "52%"]) { const bar = node("span"); bar.style.width = width; ghost.append(bar); } const chips = node("div", "a4-boot-ghost-chips"); for (let i = 0; i < 3; i++) chips.append(node("i")); ghost.append(chips); - panel.append(ghost); + lead.setAttribute("role", "status"); + panel.append(ghost, lead); + slowTimer = window.setTimeout(() => { lead.textContent = tx("少し時間がかかっています。そのままお待ちください。"); }, 3000); } const row = node("div", "a4-boot-actions"); actions.forEach(([label, run], index) => { const button = node("button", index === 0 ? "a4-button a4-primary" : "a4-button", label); button.type = "button"; + if (index === 0 && extra.google) { const tile = node("span", "a4-google"); tile.innerHTML = GOOGLE_G; button.prepend(tile); } button.addEventListener("click", run); row.append(button); }); if (actions.length) panel.append(row); + if (extra.note) panel.append(node("p", "a4-boot-note", extra.note)); + if (extra.signOut) { + const out = node("button", "a4-link a4-boot-out", tx("サインアウト")); + out.type = "button"; + out.addEventListener("click", () => { void signOutUser().finally(checkAuth); }); + panel.append(out); + } + if (extra.legal) { + const legal = node("p", "a4-boot-legal"); + for (const [label, href] of [[tx("利用規約"), "https://guilduo.com/terms/"], [tx("プライバシー"), "https://guilduo.com/privacy/"]] as const) { + const link = node("a", "", label); link.href = href; link.target = "_blank"; link.rel = "noopener noreferrer"; legal.append(link); + } + panel.append(legal); + } root.replaceChildren(panel); if (actions.length) h1.focus(); } @@ -274,7 +316,7 @@ function pushNotifications(repository: QuestForgeRepository): AllForPort["notifi } async function showProduction(uid: string, email: string): Promise { - message(tx("読み込んでいます"), tx("やることを取り出しています。少しだけ待ってください。")); + message("", tx("読み込んでいます")); try { const [{ createProductionRuntime, normalizeAgentRecord, normalizeAgentConnections }, { runHandoff }, { deriveMcpUrl }] = await Promise.all([ import("../relay-forge/production.ts"), import("../relay-forge/adapter.ts"), import("../relay-forge/screens/settings-model.ts")]); @@ -375,7 +417,7 @@ async function showProduction(uid: string, email: string): Promise { unmount?.(); unmount = mountAllFor(root, port); } catch { - message(tx("読み込めませんでした"), tx("通信を確認して、もう一度試してください。データは変更されていません。"), [[tx("再試行"), () => { void showProduction(uid, email); }], [tx("デモを見る"), () => { void showDemo(); }]]); + message(tx("読み込めませんでした"), tx("通信を確認して、もう一度試してください。データは変更されていません。"), [[tx("もう一度"), () => { void showProduction(uid, email); }], [tx("デモを見る"), () => { void showDemo(); }]], "error", { signOut: true }); } } @@ -383,11 +425,15 @@ function checkAuth(): void { stopAuth?.(); stopAuth = observeAuthState(state => { if (state.status === "authenticated") { void showProduction(state.user.uid, state.user.email); return; } - if (state.status === "checking") { message(tx("ようこそ"), tx("サインインの状態を確かめています。")); return; } - message(tx("ひとつずつ、迷わず進める。"), tx("Googleでサインインすると、いつものやることがそのまま使えます。"), [ - [tx("Googleでサインイン"), () => { void signIn().catch(() => message(tx("サインインできませんでした"), tx("もう一度試してください。"), [[tx("再試行"), checkAuth]])); }], + if (state.status === "checking") { message("", tx("読み込んでいます")); return; } + const failed = () => message(tx("サインインできませんでした"), tx("Googleの画面を途中で閉じたか、通信が切れた可能性があります。"), [[tx("もう一度"), () => { void signIn().catch(failed); }], [tx("デモを見る"), () => { void showDemo(); }]]); + // Coming back from a failed Google sign-in says so instead of starting over silently. + if (state.status === "oauth-failed") { failed(); return; } + message(tx("ひとつずつ、迷わず進める。"), tx("Googleアカウントで始められます。次からも、続きをそのまま使えます。"), [ + [tx("Googleでサインイン"), () => { void signIn().catch(failed); }], [tx("デモを見る"), () => { void showDemo(); }], - ], "welcome", [tx("今やることは、いつも1つだけ"), tx("予定は時間の帯で見える"), tx("Agent に任せて、あなたは判断だけ")]); + ], "welcome", { google: true, legal: true, note: tx("デモはサインインなしで試せます。変更は保存されません。"), + points: [[Sun, tx("今やることは、いつも1つだけ")], [CalendarDays, tx("予定は時間の帯で見える")], [Users, tx("Agentに任せて、あなたは判断だけ")]] }); }); } diff --git a/interaction-lab/all-for-adhd/network-view.ts b/interaction-lab/all-for-adhd/network-view.ts new file mode 100644 index 0000000..f02f9ea --- /dev/null +++ b/interaction-lab/all-for-adhd/network-view.ts @@ -0,0 +1,114 @@ +/** + * Network in All for ADHD's layout (Penpot page 17): the blocked roots come + * first as a list with their reasons; the drawing stays Relay Forge's own and + * opens from the switch. On a phone a root opens as its own page. + */ + +import { el } from "../relay-forge/primitives/dom.ts"; +import type { NetworkModel, NetworkNode, NetworkEdge } from "../relay-forge/screens/network-model.ts"; +import { tx } from "./copy.ts"; + +export interface NetworkViewHost { + readonly mobile: boolean; + readonly mode: "list" | "graph"; + readonly selected: string | null; + readonly detailOpen: boolean; + readonly blockedReason: (questId: string) => string; + readonly setMode: (mode: "list" | "graph") => void; + readonly select: (rootId: string) => void; + readonly closeDetail: () => void; + readonly openQuest: (questId: string) => void; + readonly reconnect: (connectionId: string) => void; + /** Relay Forge's drawing centred on a root, labels already in the reader's words. */ + readonly graph: (rootId: string | null) => HTMLElement; +} + +type Entry = { readonly node: NetworkNode; readonly edge: NetworkEdge }; + +export const kindLabel = (kind: NetworkNode["kind"]) => kind === "quest" ? tx("タスク") : kind === "connection" ? tx("連携") : tx("担当"); +export function stateLabel(node: NetworkNode): { label: string; tone: string } | null { + if (node.kind === "actor") return null; + if (node.kind === "connection") return node.state === "healthy" ? { label: tx("接続中"), tone: "done" } : { label: tx("再接続が必要"), tone: "review" }; + const labels: Record = { + review: [tx("判断待ち"), "review"], blocked: [tx("止まっている"), "review"], working: [tx("進行中"), "done"], + scheduled: [tx("未着手"), "neutral"], done: [tx("完了"), "done"], + }; + const pick = labels[node.state]; + return pick ? { label: pick[0], tone: pick[1] } : null; +} + +const chip = (label: string, tone: string) => el("span", { class: "a4-net-chip", "data-tone": tone }, label); + +export function networkView(model: NetworkModel, host: NetworkViewHost): HTMLElement { + const blockersOf = (id: string): Entry[] => model.edges + .filter(edge => edge.toId === id && edge.blocking && (edge.kind === "dependency" || edge.kind === "sync")) + .map(edge => ({ node: model.nodes.get(edge.fromId)!, edge })).filter(entry => entry.node); + const reason = (id: string): string => { + const written = host.blockedReason(id); + if (written) return written; + const blockers = blockersOf(id); + const connection = blockers.find(entry => entry.node.kind === "connection"); + if (connection) return tx("{name}の再接続が必要です", { name: connection.node.label }); + if (blockers.some(entry => entry.node.state === "review")) return tx("あなたの確認を待っています"); + if (blockers.length) return tx("前のタスクが終わっていません"); + return tx("止まっている理由はまだ書かれていません"); + }; + const chains = model.chains; + const selected = chains.find(chain => chain.rootId === host.selected)?.rootId ?? chains[0]?.rootId ?? null; + const waiting = new Set(chains.flatMap(chain => chain.waitingIds)).size; + + const entryRow = (node: NetworkNode, forcedChip?: { label: string; tone: string }) => { + const state = forcedChip ?? stateLabel(node); + const action = node.kind === "connection" ? el("button", { type: "button", class: "a4-button" }, tx("再接続")) : null; + action?.addEventListener("click", () => host.reconnect(node.id)); + const open = node.kind === "quest" ? el("button", { type: "button", class: "a4-net-entry-title" }, node.label) : el("strong", { class: "a4-net-entry-title" }, node.label); + if (node.kind === "quest") open.addEventListener("click", () => host.openQuest(node.id)); + return el("li", { class: "a4-net-entry" }, + el("div", { class: "a4-net-entry-main" }, + el("span", { class: "a4-net-kind" }, kindLabel(node.kind), state ? chip(state.label, state.tone) : null), + open, node.kind === "connection" || !node.sub ? null : el("span", { class: "a4-meta" }, node.sub)), + action); + }; + const group = (title: string, entries: readonly HTMLElement[], empty: string) => el("section", { class: "a4-net-group" }, + el("h3", { class: "a4-net-group-head" }, title, el("span", { class: "a4-meta" }, String(entries.length))), + entries.length ? el("ul", { class: "a4-net-entries" }, ...entries) : el("p", { class: "a4-meta" }, empty)); + + function detail(rootId: string): HTMLElement { + const root = model.nodes.get(rootId)!; + const chain = chains.find(item => item.rootId === rootId)!; + const open = el("button", { type: "button", class: "a4-button" }, tx("開く")); + open.addEventListener("click", () => host.openQuest(rootId)); + return el("div", { class: "a4-net-detail" }, + el("div", { class: "a4-net-detail-head" }, + el("div", {}, el("h2", { class: "a4-net-detail-title", tabindex: -1 }, root.label), root.sub ? el("span", { class: "a4-meta" }, root.sub) : null), open), + group(tx("このタスクを止めているもの"), blockersOf(rootId).map(entry => entryRow(entry.node)), tx("止めているものは見つかりません。")), + group(tx("このタスクを待っているもの"), chain.waitingIds.map(id => model.nodes.get(id)).filter((node): node is NetworkNode => !!node) + .map(node => entryRow(node, { label: tx("待機中"), tone: "neutral" })), tx("待っているタスクはありません。"))); + } + + // A phone shows one root at a time as its own page. + if (host.mobile && host.detailOpen && selected) { + const back = el("button", { type: "button", class: "a4-link" }, tx("← つながり")); + back.addEventListener("click", host.closeDetail); + return el("div", { class: "a4-net", "data-own-frame": "true" }, back, el("h1", { class: "a4-title", tabindex: -1 }, tx("止まっている理由")), detail(selected)); + } + + const toggle = el("div", { class: "a4-net-switch", role: "group", "aria-label": tx("表示") }, ...(["list", "graph"] as const).map(mode => { + const node = el("button", { type: "button", class: "a4-net-pill", "aria-pressed": String(host.mode === mode) }, mode === "list" ? tx("一覧") : tx("図")); + node.addEventListener("click", () => host.setMode(mode)); + return node; + })); + const summary = el("p", { class: "a4-net-summary" }, tx("止まっている起点 {roots}件・待っているタスク {waiting}件", { roots: chains.length, waiting })); + const head = el("div", { class: "a4-net-head" }, summary, toggle); + if (host.mode === "graph") return el("div", { class: "a4-net", "data-mode": "graph" }, head, host.graph(selected)); + if (!chains.length) return el("div", { class: "a4-net" }, head, el("p", { class: "a4-net-empty" }, tx("止まっているタスクはありません。"))); + const list = el("ul", { class: "a4-net-roots" }, ...chains.map(chain => { + const node = model.nodes.get(chain.rootId)!; + const row = el("button", { type: "button", class: "a4-net-root", "aria-current": String(!host.mobile && chain.rootId === selected) }, + el("span", { class: "a4-net-root-title" }, node.label, chain.waitingIds.length ? el("span", { class: "a4-meta" }, tx("待ち {count}", { count: chain.waitingIds.length })) : null), + el("span", { class: "a4-meta" }, reason(chain.rootId))); + row.addEventListener("click", () => host.select(chain.rootId)); + return el("li", {}, row); + })); + return el("div", { class: "a4-net" }, head, host.mobile ? list : el("div", { class: "a4-net-split" }, list, detail(selected!))); +} diff --git a/interaction-lab/all-for-adhd/polish.css b/interaction-lab/all-for-adhd/polish.css index 2228517..64fe6ba 100644 --- a/interaction-lab/all-for-adhd/polish.css +++ b/interaction-lab/all-for-adhd/polish.css @@ -323,3 +323,1185 @@ button.a4-emoji[data-empty="true"] { font-size:12px; width:44px; min-height:44px .a4-images-gallery .a4-image-row img { width: 100%; height: 200px; } .a4-image-viewer { color: var(--qf-color-fg-strong); background: var(--qf-color-surface); max-width: 95vw; max-height: 90dvh; overflow: auto; } .a4-image-viewer > img { display: block; max-width: 100%; height: auto; margin-top: 12px; } + +/* 2026-11 Penpot brushup: Today (page 05). Sizes follow the approved boards. */ +.a4-tab { min-height:46px; padding:0 14px; border-radius:8px; font-weight:400; color:var(--qf-color-fg-default); } +.a4-tab[aria-current="page"] { font-weight:600; background:var(--qf-color-selected); } +.a4-tabs { gap:4px; } +.a4-badge { display:grid; place-items:center; min-width:20px; height:20px; padding:0 4px; font-weight:600; } +.a4-profile { border-top:0; padding:12px 10px; } +.a4-profile-copy { display:flex; flex-direction:column; min-width:0; } +.a4-profile-copy strong { font-size:13px; font-weight:600; line-height:1.5; } +.a4-profile-copy small { font-size:11px; line-height:1.5; color:var(--qf-color-fg-muted); white-space:nowrap; } +.a4-top { height:auto; padding:18px 40px 0; min-height:62px; } +.a4-breadcrumb { display:flex; align-items:center; gap:8px; color:var(--qf-color-fg-default); } +.a4-breadcrumb strong { font-weight:400; color:var(--qf-color-fg-strong); } +.a4-app .a4-breadcrumb svg { width:13px; height:13px; color:var(--qf-color-fg-muted); } +.a4-main, .a4-main:has(> .a4-wide), .a4-main:has(.a4-plan), .a4-main:has(.a4-home) { padding-top:0; } +.a4-page-heading { padding:22px 0 20px; margin:0; } +.a4-date { font-weight:600; display:flex; align-items:baseline; gap:14px; } +.a4-date small { margin:0; font-size:14px; color:var(--qf-color-fg-default); } +.a4-summary { display:flex; align-items:center; gap:10px; margin:2px 0 0; line-height:1.5; color:var(--qf-color-fg-default); } +.a4-summary strong { font-weight:600; color:var(--qf-color-fg-strong); } +.a4-progress { width:72px; height:4px; border-radius:2px; background:linear-gradient(var(--qf-color-accent) 0 0) left / var(--a4-done, 0%) 100% no-repeat var(--qf-color-border-subtle); } + +.a4-today-grid { grid-template-columns:minmax(0,1fr) 281px; gap:40px; } +.a4-focus { padding:18px 20px 16px 24px; margin-bottom:8px; display:flex; flex-direction:column; gap:12px; } +.a4-focus-head { margin:0; gap:6px; justify-content:flex-start; min-height:18px; } +.a4-focus-label { font-weight:600; } +.a4-focus-span { margin-left:auto; font-size:12px; color:var(--qf-color-fg-muted); font-variant-numeric:tabular-nums; } +.a4-focus-head .a4-icon-button { width:18px; height:18px; margin:-13px 0; position:relative; color:var(--qf-color-fg-strong); } +.a4-focus-head .a4-icon-button::before { content:""; position:absolute; inset:-13px; } +.a4-focus-head .a4-focus-label + .a4-icon-button { margin-left:auto; } +.a4-focus-title { line-height:1.45; } +.a4-focus-title .a4-emoji { font-size:20px; line-height:1.5; } +.a4-focus-title .a4-row-title { font:600 21px/1.45 var(--qf-font-sans); } +.a4-focus .a4-step { margin:0; padding:10px 12px; display:flex; flex-direction:column; gap:2px; border-radius:6px; background:var(--a4-soft); overflow:visible; -webkit-line-clamp:none; } +.a4-step-head { display:flex; justify-content:space-between; gap:8px; margin:0; font-size:12px; line-height:1.5; color:var(--qf-color-fg-muted); } +.a4-focus .a4-step-label { margin:0; font-size:12px; font-weight:500; color:var(--qf-color-fg-muted); } +.a4-step-text { margin:0; font-size:16px; font-weight:500; line-height:1.55; color:var(--qf-color-fg-strong); display:-webkit-box; -webkit-line-clamp:3; -webkit-box-orient:vertical; overflow:hidden; overflow-wrap:anywhere; } +.a4-focus .a4-actions { margin:0; gap:12px; align-items:center; } +.a4-focus .a4-actions > .a4-primary { padding:0 22px; font-weight:500; } +.a4-focus .a4-actions .a4-link { display:inline-flex; align-items:center; gap:6px; min-height:44px; padding:0 4px; font-weight:400; } +.a4-app :is(.a4-link, .a4-focus-time, .a4-ask .a4-button) > svg { width:15px; height:15px; } +.a4-next-preview { min-height:44px; padding:0 2px; gap:8px; } +.a4-next-preview .a4-step-label { color:var(--qf-color-fg-muted); } +.a4-next-preview strong { font-weight:600; } +.a4-next-left { margin-left:auto; font-size:12px; color:var(--qf-color-fg-muted); white-space:nowrap; } +.a4-next-title { flex:0 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.a4-next-preview svg { color:var(--qf-color-fg-muted); } + +.a4-today > .a4-group-head { margin:0; min-height:44px; align-items:center; } +.a4-today .a4-subtitle, .a4-ai .a4-subtitle { font-size:17px; font-weight:600; } +.a4-group-head > .a4-link { display:inline-flex; align-items:center; gap:6px; min-height:44px; padding:0 4px; font-weight:400; } +.a4-day-flow { display:flex; flex-direction:column; gap:6px; margin-top:6px; } +.a4-earlier > summary { min-height:36px; padding:8px 0; display:flex; align-items:center; gap:8px; list-style:none; } +.a4-earlier > summary::-webkit-details-marker { display:none; } +.a4-earlier > summary::before { content:"▸"; font-size:11px; } +.a4-earlier[open] > summary::before { content:"▾"; } +.a4-timeline { display:flex; flex-direction:column; gap:6px; } +.a4-timeline::before { left:75px; top:20px; bottom:24px; } +.a4-timeline[data-even] .a4-slot { padding:0; align-items:start; } +.a4-timeline[data-even] .a4-slot-time { padding-top:8px; font-size:13px; font-weight:500; color:var(--qf-color-fg-strong); font-variant-numeric:tabular-nums; } +.a4-slot-time small { display:block; font-size:11px; font-weight:400; color:var(--qf-color-fg-muted); } +.a4-today .a4-slot:not([data-tone="focus"], [data-tone="working"]) { --a4-tone:var(--qf-color-border-strong); --a4-task-tint:var(--a4-soft); } +.a4-today .a4-slot:is([data-tone="focus"], [data-tone="working"]) { --a4-tone:var(--qf-color-accent); --a4-task-tint:var(--qf-color-selected); } +.a4-timeline[data-even] .a4-slot .a4-slot-pill { margin-top:5px; background:var(--qf-color-canvas); font-size:15px; line-height:1.2; position:relative; } +.a4-slot-card { padding:9px 10px 9px 12px; gap:8px; } +.a4-slot-card .a4-row-title { min-height:0; padding:0; font-size:15px; font-weight:500; line-height:1.5; } +.a4-slot-card .a4-meta { display:flex; gap:6px; margin-top:2px; font-size:12px; line-height:1.5; } +.a4-slot-card .a4-meta strong { font-weight:600; color:var(--qf-color-fg-strong); } +.a4-slot-card .a4-check, .a4-anytime-row .a4-check { width:22px; height:22px; margin:0; flex-shrink:0; } +.a4-slot-card .a4-check::before, .a4-anytime-row .a4-check::before { inset:-11px; } +.a4-timeline .a4-now-line { margin:0; min-height:28px; gap:10px; background:none; } +.a4-now-line .a4-slot-time { font-weight:600; color:var(--a4-now); padding:0; } +.a4-now-line::before { content:""; order:1; flex:0 0 34px; height:8px; background:radial-gradient(circle, var(--a4-now) 0 4px, transparent 4.5px) center / 8px 8px no-repeat var(--qf-color-canvas); position:relative; } +.a4-now-label { order:2; gap:10px; font-weight:600; color:var(--a4-now); } +.a4-timeline[data-even] .a4-free { display:block; padding:0 0 0 102px; } +.a4-free-line { display:flex; align-items:center; justify-content:space-between; min-height:44px; } +.a4-free-head { margin:0; font-size:12px; color:var(--qf-color-fg-muted); } +.a4-free-line .a4-link { display:inline-flex; align-items:center; gap:6px; min-height:44px; padding:0 4px; font-size:12px; font-weight:400; } +.a4-free-pick { display:flex; align-items:center; gap:8px; min-height:52px; padding:0 4px 0 12px; border:1px dashed var(--qf-color-border-strong); border-radius:8px; } +.a4-free-pick > div { flex:1; min-width:0; display:flex; flex-direction:column; } +.a4-free-pick small { font-size:11px; line-height:1.5; color:var(--qf-color-fg-muted); } +.a4-free-pick strong { font-size:13px; font-weight:500; line-height:1.5; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.a4-free-pick .a4-link { min-height:44px; padding:0 4px; font-weight:400; color:var(--qf-color-accent); white-space:nowrap; } +.a4-anytime { margin:0; padding-top:14px; } +.a4-anytime h3 { display:flex; gap:8px; margin:0 0 2px; font-size:13px; font-weight:400; line-height:1.5; color:var(--qf-color-fg-muted); } +.a4-anytime-row { min-height:44px; margin:0 0 2px; padding:0; gap:10px; border:0; border-bottom:1px solid var(--qf-color-border-subtle); border-radius:0; background:none; } +.a4-anytime-row .a4-emoji, .a4-anytime-row .a4-emoji[data-empty="true"]:not(button) { display:block; width:22px; font-size:15px; line-height:1.5; text-align:center; } +.a4-anytime-row .a4-row-title { flex:1; min-width:0; min-height:44px; font-size:14px; font-weight:500; line-height:1.5; } +.a4-anytime-row .a4-meta, .a4-daily { font-size:12px; color:var(--qf-color-fg-muted); white-space:nowrap; } +.a4-daily { display:inline-flex; align-items:center; gap:3px; } +.a4-app .a4-daily svg { width:12px; height:12px; } +.a4-work-fold > summary { display:flex; align-items:center; gap:8px; min-height:44px; font-size:13px; font-weight:400; color:var(--qf-color-fg-muted); cursor:pointer; list-style:none; } +.a4-work-fold > summary::-webkit-details-marker { display:none; } +.a4-app .a4-work-fold > summary svg { width:16px; height:16px; } + +.a4-ai > .a4-group-head { min-height:44px; margin:0; gap:8px; justify-content:flex-start; align-items:center; } +.a4-ai > .a4-group-head > svg { color:var(--qf-color-fg-muted); } +.a4-ask-count { margin-left:auto; font-size:12px; font-weight:600; color:var(--qf-color-review); white-space:nowrap; } +.a4-ask { display:flex; flex-direction:column; gap:6px; padding:14px 0; border-bottom:1px solid var(--qf-color-border-subtle); } +.a4-ask-state { display:flex; align-items:center; gap:6px; margin:0; font-size:12px; font-weight:600; line-height:1.5; color:var(--qf-color-review); } +.a4-ask-state::before { content:""; width:6px; height:6px; border-radius:50%; background:currentColor; } +.a4-ask > strong { font-size:14px; font-weight:600; line-height:1.5; overflow-wrap:anywhere; } +.a4-ask-note { margin:0; font-size:13px; line-height:1.5; color:var(--qf-color-fg-default); overflow-wrap:anywhere; } +.a4-ask-row { display:flex; align-items:center; gap:6px; min-height:44px; font-size:12px; color:var(--qf-color-fg-muted); } +.a4-ask-row .a4-avatar { width:18px; height:18px; border-radius:4px; font-size:10px; font-weight:600; } +.a4-ask-row .a4-button { margin-left:auto; min-height:44px; padding:0 12px; font-size:12px; font-weight:500; background:var(--qf-color-surface); border:1px solid var(--qf-color-border-subtle); white-space:nowrap; } +.a4-ai-foot { padding-top:12px; display:flex; flex-direction:column; align-items:flex-start; gap:2px; } +.a4-ai-foot .a4-muted { margin:0; font-size:12px; line-height:1.5; } +.a4-ai-foot .a4-link { display:inline-flex; align-items:center; gap:6px; min-height:44px; padding:0 4px; font-weight:400; } +@media (min-width:1100px) { + .a4-home { grid-template-columns:357px minmax(0,1fr); } + .a4-now { top:24px; } +} +.a4-today { display:flex; flex-direction:column; gap:6px; } +.a4-day-flow { margin-top:0; } +.a4-slot-card .a4-row-text { display:flex; flex-direction:column; gap:2px; } +.a4-slot-card .a4-meta { margin-top:0; } +.a4-free-line { height:44px; } +.a4-focus .a4-step-head { align-self:stretch; } +.a4-timeline[data-even] .a4-slot-time, .a4-now-line .a4-slot-time { font-family:var(--qf-font-sans); } +.a4-free-head { font-weight:400; } +@media (min-width:1100px) { + .a4-rail { width:177px; } + .a4-top { margin-left:177px; } + .a4-main, .a4-main:has(> .a4-wide), .a4-main:has(.a4-plan), .a4-main:has(.a4-home) { margin-left:177px; width:calc(100% - 177px); } + .a4-focus .a4-actions .a4-link { margin-left:auto; } +} +.a4-slot-card .a4-row-text { padding:0; } +.a4-slot-card .a4-row-title { line-height:23px; } +.a4-date { line-height:41px; } +.a4-anytime-row { min-height:47px; padding-bottom:2px; } +.a4-timeline .a4-now-line .a4-slot-time { color:var(--a4-now); font-size:12px; font-weight:600; padding:0; } +.a4-earlier > summary { font-weight:400; } +.a4-focus .a4-actions .a4-focus-time { margin-right:auto; } +:root[data-a4-palette] .a4-anytime-row[data-tone] > .a4-check:not([data-done]) { border-color:var(--qf-color-border-strong); } + +/* Today below 1380px: the Guild column becomes one line under the focus card. */ +.a4-top-brand { display:none; } +.a4-now > .a4-notice { display:none; align-items:center; gap:8px; min-height:48px; margin-top:12px; padding:0 12px; border:0; border-radius:8px; background:var(--a4-warning-soft); color:var(--qf-color-review); font-size:13px; text-decoration:none; } +.a4-now > .a4-notice strong { font-weight:600; } +.a4-now > .a4-notice span { font-size:12px; font-weight:400; } +.a4-now > .a4-notice svg:last-child { margin-left:auto; width:15px; height:15px; } +@media (max-width:1379px) { + .a4-today-grid { grid-template-columns:minmax(0,1fr); } + .a4-today-grid > .a4-ai { display:none; } + .a4-now > .a4-notice { display:flex; } +} +@media (min-width:900px) { + .a4-home { display:grid; grid-template-columns:357px minmax(0,1fr); gap:24px; align-items:start; } + .a4-now { position:sticky; top:24px; } +} +@media (max-width:899px) { + .a4-home { display:block; } + .a4-now { position:static; } + .a4-home > .a4-today-flow { margin-top:24px; } + .a4-focus .a4-actions .a4-link { margin-left:auto; } +} +@media (max-width:1099px) { + .a4-rail { min-height:61px; padding:8px 28px; } + .a4-tabs { gap:0; } + .a4-tab { min-height:44px; padding:0 10px; gap:8px; } + .a4-top { margin-left:0; padding:18px 28px 0; } + .a4-main, .a4-main:has(> .a4-wide), .a4-main:has(.a4-plan), .a4-main:has(.a4-home) { padding:0 28px 80px; } +} +@media (max-width:760px) { + .a4-top { min-height:58px; padding:0 20px; } + .a4-top-brand { display:block; font-size:19px; font-weight:600; color:var(--qf-color-fg-strong); } + .a4-breadcrumb { display:none; } + .a4-tabs { gap:2px; } + .a4-tab { min-height:54px; padding:6px 1px; gap:3px; } + .a4-main, .a4-main:has(> .a4-wide), .a4-main:has(.a4-plan), .a4-main:has(.a4-home) { padding:0 20px calc(142px + env(safe-area-inset-bottom)); } + .a4-page-heading { padding:8px 0 14px; } + .a4-date { font-size:26px; line-height:36px; gap:12px; } + .a4-focus { padding:18px 18px 16px 24px; } + .a4-focus .a4-actions .a4-link { margin-left:auto; font-size:13px; } + .a4-timeline[data-even] .a4-slot { grid-template-columns:40px 28px minmax(0,1fr); column-gap:8px; } + .a4-timeline::before { left:62px; } + .a4-now-line::before { flex-basis:28px; } + .a4-now-line .a4-slot-time { width:40px; } + .a4-timeline .a4-now-line { gap:8px; } + .a4-timeline[data-even] .a4-free { padding-left:84px; } + .a4-slot-card { padding:9px 10px 9px 12px; gap:8px; } + .a4-slot-card .a4-row-title { font-size:15px; } + .a4-slot-card .a4-meta { font-size:12px; } +} +.a4-today-panel .a4-now-label { padding:0; background:none; } +.a4-today-panel .a4-now-label::after { height:1px; background:var(--a4-now); opacity:1; } +.a4-now > .a4-notice { border:0; box-shadow:none; } +@media (max-width:1099px) { + .a4-brand { font-size:21px; } + .a4-tab { font-size:13px; } +} +@media (max-width:760px) { + .a4-top-brand { font-size:18px; } + .a4-tab { font-size:10px; } + .a4-tab .a4-badge { display:none; } + .a4-focus .a4-actions { flex-wrap:nowrap; gap:8px; } + .a4-focus .a4-actions > .a4-primary { padding:0 20px; } + .a4-free-pick strong { white-space:normal; } + .a4-mobile-add { right:20px; bottom:calc(72px + env(safe-area-inset-bottom)); width:54px; height:54px; border-radius:12px; } +} +.a4-slot-card .a4-meta strong { color:var(--qf-color-accent); } +:root[data-theme="dark"] .a4-focus .a4-step { background:#202c33; } +@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) .a4-focus .a4-step { background:#202c33; } } +.a4-top .a4-icon-button { color:var(--qf-color-fg-strong); } + +/* Focus card states: running and empty. */ +.a4-run { display:flex; flex-direction:column; gap:6px; } +.a4-run p { display:flex; align-items:baseline; gap:8px; margin:0; } +.a4-run strong { font-size:32px; font-weight:600; line-height:1.2; font-variant-numeric:tabular-nums; color:var(--qf-color-fg-strong); } +.a4-run small { font-size:13px; color:var(--qf-color-fg-muted); } +.a4-run-left { margin-left:auto; font-size:14px; font-weight:500; color:var(--qf-color-accent); } +.a4-run .a4-progress { width:100%; } +.a4-focus[data-running="true"] .a4-actions > .a4-primary { padding:0 16px; } +.a4-focus[data-running="true"] .a4-actions .a4-link { margin-left:0; } +.a4-empty-focus { display:flex; flex-direction:column; align-items:center; gap:8px; padding:32px 20px; border:1px dashed var(--qf-color-border-strong); border-radius:8px; text-align:center; } +.a4-empty-focus > * { margin:0; } +.a4-app .a4-empty-focus > svg { width:22px; height:22px; margin-bottom:4px; color:var(--qf-color-accent); } +.a4-empty-focus .a4-subtitle { font-size:18px; font-weight:600; } +.a4-empty-focus .a4-muted { font-size:14px; color:var(--qf-color-fg-default); } +.a4-empty-focus .a4-primary { margin-top:8px; padding:0 16px; font-weight:500; } +@media (max-width:760px) { + .a4-top { min-height:56px; padding:0 16px; } + .a4-top-brand { font-size:19px; } + .a4-page-heading { padding:8px 0 12px; } + .a4-date { font-size:28px; line-height:38px; } + .a4-tabs { padding:6px 6px max(10px,env(safe-area-inset-bottom)); } + .a4-tab { min-height:56px; font-size:11px; } + .a4-mobile-add { width:52px; height:52px; border-radius:14px; bottom:calc(89px + env(safe-area-inset-bottom)); } +} +.a4-app:has(.a4-empty-focus) .a4-mobile-add { display:none; } + +/* 2026-11 Penpot brushup: Calendar (page 06). */ +.a4-plan { gap:0; } +.a4-plan-main { gap:12px; } +.a4-plan-head { padding:18px 0 2px; align-items:center; gap:8px; flex-wrap:nowrap; } +.a4-plan-title { display:flex; flex-direction:column; gap:2px; min-width:0; } +.a4-plan .a4-title { margin:0; font-size:26px; font-weight:600; line-height:1.35; } +.a4-plan-sub { margin:0; font-size:13px; line-height:1.5; color:var(--qf-color-fg-default); } +.a4-plan-nav { gap:2px; margin-left:auto; } +.a4-seg { padding:3px; gap:2px; border:0; border-radius:8px; background:var(--a4-soft); } +.a4-seg-button { min-width:42px; min-height:38px; padding:0 8px; border:1px solid transparent; border-radius:6px; font-size:13px; font-weight:400; color:var(--qf-color-fg-default); } +.a4-seg-button[aria-pressed="true"] { border-color:var(--qf-color-border-subtle); background:var(--qf-color-surface); font-weight:600; color:var(--qf-color-fg-strong); } +.a4-plan-nav .a4-icon-button { color:var(--qf-color-fg-strong); } +.a4-plan-nav .a4-link { min-height:44px; padding:0 4px; font-weight:400; } +.a4-plan[data-mode="day"] { grid-template-columns:minmax(0,1fr) 261px; gap:24px; } +.a4-week { gap:0; padding:0; margin:0; border:0; } +.a4-day { display:flex; flex-direction:column; align-items:center; gap:4px; padding:6px 0; border:0; background:none; border-radius:8px; } +.a4-day-name { font-size:12px; line-height:1.5; color:var(--qf-color-fg-default); } +.a4-day .a4-day-num { width:30px; height:30px; font-size:16px; font-weight:600; line-height:1; color:var(--qf-color-fg-strong); background:none; } +.a4-day[aria-pressed="true"] .a4-day-num { box-shadow:inset 0 0 0 1.5px var(--qf-color-accent); color:var(--qf-color-fg-strong); background:none; } +.a4-day[data-today="true"] .a4-day-num { font-size:15px; color:var(--qf-color-fg-on-accent); background:var(--qf-color-accent); box-shadow:none; } +.a4-day-load { width:36px; height:3px; background:linear-gradient(var(--qf-color-fg-muted) 0 0) left / var(--a4-done, 0%) 100% no-repeat var(--qf-color-border-subtle); } +.a4-day .a4-day-total { min-height:17px; font:400 11px/1.5 var(--qf-font-sans); color:var(--qf-color-fg-muted); } +.a4-plan-side { gap:0; padding-left:24px; } +.a4-plan-side > .a4-subtitle { display:flex; align-items:center; gap:6px; min-height:44px; margin:0; font-size:15px; font-weight:600; } +.a4-plan-side > .a4-subtitle span, .a4-review-fold > summary span { font-size:13px; font-weight:400; color:var(--qf-color-fg-muted); } +.a4-inbox-row { display:flex; flex-direction:column; gap:4px; padding:12px 0; border-bottom:1px solid var(--qf-color-border-subtle); } +.a4-inbox-title { display:flex; align-items:baseline; gap:6px; } +.a4-inbox-title .a4-emoji { font-size:14px; line-height:1.5; } +.a4-inbox-title .a4-row-title { min-height:0; font-size:14px; font-weight:500; line-height:1.5; } +.a4-inbox-act { display:flex; align-items:center; justify-content:space-between; gap:8px; min-height:44px; } +.a4-inbox-act .a4-meta { font-size:12px; } +.a4-place { min-height:44px; padding:0 12px; font-size:13px; font-weight:500; background:var(--qf-color-surface); border:1px solid var(--qf-color-border-subtle); white-space:nowrap; } +.a4-review-fold > summary { min-height:44px; padding:0; font-weight:500; color:var(--qf-color-fg-strong); } +.a4-review-fold > .a4-muted { margin:0 0 4px; font-size:12px; } +.a4-plan > .a4-plan-head { grid-column:1 / -1; padding:18px 0 14px; } +.a4-plan[data-mode="day"] { gap:0 24px; } +.a4-plan-side { padding-right:0; } +.a4-plan .a4-title { line-height:36px; } +.a4-plan[data-mode="day"] { gap:0 24px; } +.a4-plan-side { padding-right:0; } + +/* Calendar week and month share the day-view frame: main column, inbox column at 1380px and up. */ +.a4-plan[data-mode="week"], .a4-plan[data-mode="month"] { display:grid; grid-template-columns:minmax(0,1fr) 261px; gap:0 24px; } +.a4-plan:not([data-mode="day"]) .a4-plan-side { padding:0 0 0 24px; margin:0; border-top:0; border-left:1px solid var(--qf-color-border-subtle); } +.a4-plan:not([data-mode="day"]) .a4-plan-side .a4-list { display:block; } +.a4-plan:not([data-mode="day"]) .a4-plan-main { gap:2px; } +.a4-daily-line { display:flex; align-items:center; gap:8px; min-height:36px; flex-wrap:wrap; } +.a4-daily-line .a4-link { display:inline-flex; align-items:baseline; gap:6px; min-height:36px; font-size:13px; font-weight:400; color:var(--qf-color-fg-strong); } +.a4-daily-line .a4-link strong { font-weight:500; } +.a4-daily-line .a4-link small { font-size:12px; color:var(--qf-color-fg-muted); } +.a4-app .a4-daily-line .a4-daily svg { width:14px; height:14px; margin-right:4px; } +.a4-wk { grid-template-columns:44px repeat(7,minmax(0,1fr)); min-width:0; column-gap:0; } +.a4-wk-day { min-height:0; padding:4px 0; gap:3px; } +.a4-wk-day .a4-day-num { width:30px; height:30px; font-size:16px; font-weight:600; line-height:1; color:var(--qf-color-fg-strong); background:none; } +.a4-wk-day:not([data-today="true"])[aria-pressed="true"] .a4-day-num { background:none; color:var(--qf-color-fg-strong); box-shadow:inset 0 0 0 1.5px var(--qf-color-accent); } +.a4-wk-day[data-today="true"] .a4-day-num { font-size:15px; color:var(--qf-color-fg-on-accent); } +.a4-wk-day .a4-day-total { min-height:17px; font:400 11px/1.5 var(--qf-font-sans); color:var(--qf-color-fg-muted); } +.a4-wk-label { align-self:center; padding:0; font-size:11px; line-height:1.5; } +.a4-wk-allday { min-height:44px; margin:8px 0 12px; padding:0 3px; border:0; gap:4px; background:none !important; } +.a4-wk-allday .a4-wk-item { height:44px; min-height:44px; padding:0 8px; display:flex; align-items:center; } +.a4-wk-allday .a4-wk-title { -webkit-line-clamp:1; display:block; white-space:nowrap; text-overflow:ellipsis; } +.a4-wk-allday .a4-meta { margin:0; font-size:11px; line-height:1.3; } +.a4-wk-item, .a4-wk-item[data-tone="done"], .a4-wk-item[data-run-start] { --a4-tone:var(--qf-color-border-strong); --a4-task-tint:var(--a4-soft); display:flex; align-items:flex-start; gap:2px; padding:5px 6px 0 8px; border-radius:6px; box-shadow:none; } +.a4-wk-item:is([data-tone="focus"], [data-tone="working"]) { --a4-tone:var(--qf-color-accent); --a4-task-tint:var(--qf-color-selected); } +.a4-wk-item[data-tone="done"] { border:1px solid var(--qf-color-border-subtle); background:var(--qf-color-canvas); padding-left:8px; } +.a4-wk-item[data-tone="done"] .a4-wk-title { text-decoration:none; color:var(--qf-color-fg-muted); } +.a4-app .a4-wk-item > svg { width:13px; height:13px; margin-top:1px; color:var(--qf-color-fg-muted); } +.a4-wk-text { min-width:0; } +.a4-wk-title { font-size:12px; font-weight:500; line-height:1.35; color:var(--qf-color-fg-strong); } +.a4-wk-col .a4-wk-item { box-shadow:none; } +.a4-wk-col[data-selected="true"] { background-color:color-mix(in srgb, var(--a4-soft) 55%, transparent); } +.a4-wk-col:last-child { border-right:1px solid var(--qf-color-border-subtle); } +.a4-wk-hours span { right:auto; left:0; font:400 11px/1.2 var(--qf-font-sans); } +.a4-wk-hours .a4-wk-now-time { z-index:1; padding:2px 0; font-weight:600; color:var(--a4-now); background:var(--qf-color-canvas); } +.a4-wk-now { height:2px; } +@media (max-width:1379px) { + .a4-plan[data-mode], .a4-plan[data-mode="day"] { grid-template-columns:minmax(0,1fr); } +} +.a4-wk-title { word-break:normal; overflow-wrap:anywhere; line-break:anywhere; } +.a4-wk-now { z-index:2; } +.a4-wk-col { border-bottom:1px solid var(--qf-color-border-subtle); } + +/* Calendar month: a ruled grid with plain lines of text. */ +.a4-plan[data-mode="month"] > .a4-plan-head { padding:18px 0 6px; min-height:76px; } +.a4-mo { gap:0; border-left:1px solid var(--qf-color-border-subtle); border-top:0; } +.a4-mo-name { padding:6px 0 5px; font-size:12px; letter-spacing:0; line-height:1.5; margin-left:-1px; background:var(--qf-color-canvas); } +.a4-mo-name + .a4-mo-cell, .a4-mo-cell { border:0; border-right:1px solid var(--qf-color-border-subtle); border-bottom:1px solid var(--qf-color-border-subtle); border-radius:0; } +.a4-mo-cell { min-height:93px; padding:6px 8px 4px; gap:0; background:none; box-shadow:0 -1px 0 var(--qf-color-border-subtle); } +.a4-mo-cell[data-selected="true"] { background:none; } +.a4-mo-cell[data-outside="true"] > :not(.a4-mo-day) { opacity:1; } +.a4-mo-day { min-height:24px; padding:0; align-items:flex-start; } +.a4-mo-day .a4-day-num { width:auto; min-width:24px; height:24px; justify-content:flex-start; font-size:13px; font-weight:500; background:none; color:var(--qf-color-fg-strong); box-shadow:none !important; } +.a4-mo-day[data-today="true"] .a4-day-num { width:24px; justify-content:center; font-size:12px; font-weight:600; border-radius:50%; background:var(--qf-color-accent); color:var(--qf-color-fg-on-accent); } +.a4-mo-cell[data-outside="true"] .a4-day-num { font-weight:400; color:var(--qf-color-fg-muted); } +.a4-mo-item { display:block; padding:0; border:0; background:none; } +.a4-mo-item .a4-row-title { display:block; width:100%; min-height:20px; font-size:12px; font-weight:400; line-height:20px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; position:relative; } +.a4-mo-item .a4-row-title[data-done="true"] { text-decoration:none; color:var(--qf-color-fg-muted); } +.a4-mo-more { min-height:18px; padding:0; border:0; background:none; font-size:11px; font-weight:400; line-height:18px; text-align:left; color:var(--qf-color-fg-muted); } +.a4-plan[data-mode="month"] > .a4-plan-head { padding:18px 0 14px; min-height:0; } +.a4-plan-head .a4-plan-title { justify-content:center; min-height:44px; } + +/* Calendar below 1380px: the inbox becomes one line that opens a sheet. */ +.a4-inbox-line { display:none; align-items:center; gap:8px; width:100%; min-height:44px; padding:0 12px; border:1px solid var(--qf-color-border-subtle); border-radius:8px; background:var(--qf-color-surface); font-size:13px; font-weight:400; color:var(--qf-color-fg-muted); text-align:left; cursor:pointer; } +.a4-inbox-line strong { font-weight:600; color:var(--qf-color-fg-strong); } +.a4-inbox-line-review { color:var(--qf-color-fg-default); } +.a4-app .a4-inbox-line svg { width:15px; height:15px; margin-left:auto; color:var(--qf-color-fg-default); } +.a4-inbox-sheet { width:min(460px, calc(100% - 32px)); padding:0; } +.a4-inbox-sheet .a4-sheet-head { display:flex; align-items:center; justify-content:space-between; padding:16px 20px 4px; border:0; } +.a4-inbox-sheet .a4-subtitle, .a4-inbox-sub { display:flex; align-items:baseline; gap:8px; margin:0; font-size:17px; font-weight:600; } +.a4-inbox-sheet .a4-subtitle span, .a4-inbox-sub span { font-size:13px; font-weight:400; color:var(--qf-color-fg-muted); } +.a4-inbox-sheet .a4-link { min-height:44px; font-weight:400; } +.a4-inbox-body { padding:0 20px 20px; } +.a4-inbox-sub { padding-top:12px; font-size:13px; } +.a4-inbox-body .a4-inbox-row:last-child { border-bottom:0; } +.a4-inbox-body .a4-inbox-row + .a4-inbox-sub, .a4-inbox-body .a4-list + .a4-inbox-sub { margin-top:0; } +.a4-wl-wrap { display:none; } +@media (max-width:1379px) { + .a4-plan > .a4-plan-side { display:none; } + .a4-inbox-line { display:flex; } + .a4-plan:not([data-mode="day"]) .a4-plan-main { gap:8px; } +} +@media (max-width:760px) { + .a4-plan > .a4-plan-head { flex-wrap:wrap; gap:8px; padding:4px 0; } + .a4-plan[data-mode="month"] > .a4-plan-head { padding:4px 0; } + .a4-plan-title { flex:1 0 100%; } + .a4-plan .a4-title { font-size:24px; line-height:33px; } + .a4-plan-head .a4-plan-title { min-height:0; } + .a4-plan-nav { width:100%; margin:0; flex-wrap:nowrap; } + .a4-plan-nav .a4-seg { margin-right:auto; } + .a4-plan-main { margin-top:12px; } + .a4-day .a4-day-total { display:none; } + .a4-day { padding:6px 0 2px; } + .a4-plan[data-mode="week"] .a4-wk-scroll { display:none; } + .a4-wl-wrap { display:block; } + .a4-wl { margin:0; padding:0; list-style:none; } + .a4-wl-day { display:flex; gap:12px; padding:12px 0; border-bottom:1px solid var(--qf-color-border-subtle); } + .a4-wl-date { display:flex; flex-direction:column; align-items:center; gap:2px; width:36px; padding:0; border:0; background:none; cursor:pointer; align-self:flex-start; } + .a4-wl-date .a4-day-num { width:30px; height:30px; font-size:16px; font-weight:600; color:var(--qf-color-fg-strong); background:none; } + .a4-wl-date[data-today="true"] .a4-day-num { font-size:15px; color:var(--qf-color-fg-on-accent); background:var(--qf-color-accent); } + .a4-wl-plan { flex:1; min-width:0; display:flex; flex-direction:column; gap:4px; } + .a4-wl-top { display:flex; align-items:center; gap:8px; min-height:20px; margin:0; } + .a4-wl-top .a4-day-total { font-size:12px; line-height:1.5; color:var(--qf-color-fg-muted); } + .a4-wl-done { margin-left:auto; font-size:12px; color:var(--qf-color-fg-muted); } + .a4-wl-item { display:flex; align-items:baseline; gap:8px; width:100%; min-height:21px; padding:0; border:0; background:none; text-align:left; cursor:pointer; position:relative; } + .a4-wl-item::before { content:""; position:absolute; inset:-2px 0; } + .a4-wl-time { flex:0 0 40px; font-size:12px; line-height:1.5; color:var(--qf-color-fg-muted); font-variant-numeric:tabular-nums; } + .a4-wl-title { min-width:0; font-size:14px; line-height:1.5; color:var(--qf-color-fg-strong); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } + .a4-wl-item:is([data-tone="focus"], [data-tone="working"]) .a4-wl-time { font-weight:600; color:var(--qf-color-accent); } + .a4-wl-item:is([data-tone="focus"], [data-tone="working"]) .a4-wl-title { font-weight:600; } + .a4-inbox-sheet { width:100%; max-width:none; margin:auto 0 0; border-radius:16px 16px 0 0; } + .a4-inbox-sheet::before { content:""; display:block; width:36px; height:4px; margin:10px auto 0; border-radius:2px; background:var(--qf-color-border-strong); } +} +.a4-mo-day .a4-day-load, .a4-mo-preview { display:none; } +@media (max-width:760px) { + .a4-plan[data-mode="month"] .a4-daily-line { display:none; } + .a4-mo { border:0; } + .a4-mo-name { margin:0; padding:6px 0; } + .a4-mo-cell, .a4-mo-name + .a4-mo-cell { min-height:48px; padding:0; border:0; box-shadow:none; } + .a4-mo-cell > :not(.a4-mo-day) { display:none; } + .a4-mo-day { align-items:center; gap:1px; min-height:48px; padding:4px 0 0; } + .a4-mo-day .a4-day-num { width:28px; min-width:28px; height:28px; justify-content:center; font-size:14px; } + .a4-mo-day[data-today="true"] .a4-day-num { width:28px; font-size:14px; } + .a4-mo-day:not([data-today="true"])[aria-pressed="true"] .a4-day-num { border-radius:50%; box-shadow:inset 0 0 0 1.5px var(--qf-color-accent) !important; } + .a4-mo-day .a4-day-load { display:block; width:24px; } + .a4-mo-day .a4-dots { display:none; } + .a4-mo-preview { display:block; margin-top:20px; } + .a4-mo-preview .a4-group-head { min-height:44px; margin:0; align-items:center; } + .a4-mo-preview .a4-subtitle { font-size:15px; font-weight:600; } + .a4-mo-preview li { border-bottom:1px solid var(--qf-color-border-subtle); } + .a4-mo-preview .a4-wl-item { min-height:41px; align-items:center; } +} + +/* 2026-11 Penpot brushup: Tasks (page 07). */ +.a4-sr { position:absolute; width:1px; height:1px; overflow:hidden; clip-path:inset(50%); white-space:nowrap; } +.a4-all { gap:0; } +.a4-all-head { display:flex; align-items:center; gap:12px; padding:14px 0 12px; position:relative; } +.a4-all-head .a4-search { flex:1; display:flex; align-items:center; gap:8px; min-height:44px; margin:0; padding:0 12px; border:1px solid var(--qf-color-border-subtle); border-radius:6px; background:var(--qf-color-surface); color:var(--qf-color-fg-muted); } +.a4-all-head .a4-search:focus-within { border-color:var(--qf-color-accent); } +.a4-all-head .a4-input { flex:1; min-height:42px; padding:0; border:0; outline:0; background:none; font-size:14px; box-shadow:none; } +.a4-all-head .a4-seg { margin-left:auto; } +.a4-all-head .a4-seg-button { min-width:62px; padding:0 12px; } +.a4-all-body > .a4-notice { display:flex; align-items:center; gap:8px; min-height:44px; margin:0 0 4px; padding:0 12px; border:0; border-radius:8px; box-shadow:none; background:var(--a4-warning-soft); color:var(--qf-color-review); font-size:13px; text-decoration:none; } +.a4-all-body > .a4-notice strong { font-weight:600; } +.a4-all-body > .a4-notice span { font-size:12px; font-weight:400; } +.a4-all-body > .a4-notice svg:last-child { margin-left:auto; width:15px; height:15px; } +.a4-all .a4-group-fold { margin:0; } +.a4-all .a4-group-fold > summary { display:flex; align-items:center; gap:8px; min-height:44px; font-size:15px; font-weight:600; list-style:none; cursor:pointer; } +.a4-all .a4-group-fold > summary::-webkit-details-marker { display:none; } +.a4-all .a4-group-fold > summary::before { content:"▸"; width:8px; font-size:11px; } +.a4-all .a4-group-fold[open] > summary::before { content:"▾"; } +.a4-all .a4-group-fold > summary span { font-size:13px; font-weight:400; color:var(--qf-color-fg-muted); } +.a4-task-row { display:flex; align-items:center; gap:10px; min-height:49px; border-bottom:1px solid var(--qf-color-border-subtle); } +.a4-task-row > .a4-check, .a4-task-row > .a4-check-space { width:22px; height:22px; margin:0; flex-shrink:0; } +.a4-task-row > .a4-check::before { inset:-11px; } +:root[data-a4-palette] .a4-task-row[data-tone] > .a4-check:not([data-done]) { border-color:var(--qf-color-border-strong); } +:root[data-a4-palette] .a4-task-row[data-tone="focus"] > .a4-check:not([data-done]) { border-color:var(--qf-color-accent); } +.a4-task-row .a4-emoji, .a4-task-row .a4-emoji[data-empty="true"]:not(button) { display:block; width:22px; font-size:16px; text-align:center; } +.a4-task-row .a4-row-title { flex:1; min-width:0; min-height:48px; font-size:15px; font-weight:500; line-height:1.5; } +.a4-task-row .a4-meta { display:inline-flex; align-items:center; gap:6px; font-size:12px; white-space:nowrap; } +.a4-task-row .a4-meta strong { font-weight:600; color:var(--qf-color-accent); } +.a4-task-row .a4-link { min-height:44px; padding:0 4px 0 8px; font-weight:400; color:var(--qf-color-accent); white-space:nowrap; } +.a4-board { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:12px; align-items:start; } +.a4-col { display:flex; flex-direction:column; gap:8px; padding:12px; border-radius:8px; background:var(--a4-soft); min-width:0; } +.a4-col h2 { display:flex; align-items:baseline; gap:6px; min-height:28px; margin:0; font-size:14px; font-weight:600; } +.a4-col h2 span { font-size:12px; font-weight:400; color:var(--qf-color-fg-muted); } +.a4-card { position:relative; display:flex; flex-direction:column; gap:2px; min-height:58px; padding:9px 28px 9px 11px; border:1px solid var(--qf-color-border-subtle); border-radius:6px; background:var(--qf-color-surface); text-align:left; cursor:pointer; } +.a4-card strong { font-size:14px; font-weight:500; line-height:1.5; color:var(--qf-color-fg-strong); overflow-wrap:anywhere; } +.a4-card .a4-meta { display:flex; align-items:center; gap:6px; font-size:12px; line-height:1.5; } +.a4-card .a4-meta strong { font-size:12px; font-weight:600; color:var(--qf-color-accent); } +.a4-card .a4-meta b { display:inline-flex; align-items:center; gap:6px; font-weight:600; color:var(--qf-color-review); } +.a4-card .a4-meta b::before { content:""; width:6px; height:6px; border-radius:50%; background:currentColor; } +.a4-card[data-tone="focus"] { border-color:transparent; background:var(--qf-color-selected); } +.a4-card[data-tone="done"] { background:none; } +.a4-card[data-tone="done"] strong { color:var(--qf-color-fg-muted); } +.a4-app .a4-card > svg { position:absolute; top:10px; right:10px; width:15px; height:15px; color:var(--qf-color-fg-muted); } +.a4-col > .a4-link { display:inline-flex; align-items:center; gap:6px; align-self:flex-start; min-height:44px; padding:0 4px; font-weight:400; } +.a4-gantt { display:flex; flex-direction:column; } +.a4-gantt-row { display:grid; grid-template-columns:242px repeat(7,minmax(0,1fr)); align-items:center; min-height:45px; border-bottom:1px solid var(--qf-color-border-subtle); } +.a4-gantt-head { min-height:44px; font-size:12px; color:var(--qf-color-fg-muted); } +.a4-gantt-head span { display:flex; align-items:center; gap:6px; } +.a4-gantt-head b { font-size:13px; font-weight:600; color:var(--qf-color-fg-strong); } +.a4-gantt-head [data-today="true"] b { display:grid; place-items:center; width:30px; height:30px; border-radius:50%; background:var(--qf-color-accent); color:var(--qf-color-fg-on-accent); } +.a4-gantt-row .a4-row-title { min-height:44px; font-size:14px; font-weight:400; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:100%; } +.a4-gantt-row[data-tone="focus"] .a4-row-title { font-weight:600; } +.a4-gantt-row > span:first-child { grid-column:1; min-width:0; padding-right:12px; } +.a4-gantt-bar { grid-row:1; height:26px; margin-right:4px; padding:0 6px; display:flex; align-items:center; border-left:2px solid var(--qf-color-border-strong); border-radius:3px; background:var(--a4-soft); font-size:11px; color:var(--qf-color-fg-default); } +.a4-gantt-row[data-tone="focus"] .a4-gantt-bar { border-left-color:var(--qf-color-accent); background:var(--qf-color-selected); } +.a4-gantt > .a4-muted { margin:16px 0; } +.a4-col-tabs { display:none; } +.a4-all .a4-group-fold > .a4-muted { margin:0 0 4px; font-size:12px; } +@media (max-width:899px) { + .a4-board { grid-template-columns:repeat(2,minmax(0,1fr)); } +} +@media (max-width:760px) { + .a4-all-head { flex-wrap:wrap; gap:8px; padding:12px 0; } + .a4-all-head .a4-search { flex:1 0 100%; } + .a4-all-head .a4-seg { width:100%; margin:0; } + .a4-all-head .a4-seg-button { flex:1; } + .a4-task-row { display:grid; grid-template-columns:22px 22px minmax(0,1fr) auto; column-gap:10px; align-items:center; padding:8px 0; min-height:61px; } + .a4-task-row > .a4-check, .a4-task-row > .a4-check-space { grid-row:1 / span 2; } + .a4-task-row .a4-emoji, .a4-task-row .a4-emoji[data-empty="true"]:not(button) { grid-row:1 / span 2; } + .a4-task-row .a4-row-title { grid-column:3 / -1; min-height:0; line-height:1.4; } + .a4-task-row .a4-meta { grid-column:3; justify-self:start; } + .a4-task-row .a4-link { grid-column:4; grid-row:2; min-height:32px; } + .a4-task-row[data-bucket="inbox"], .a4-task-row[data-bucket="review"] { row-gap:4px; } + .a4-col-tabs { display:flex; border-bottom:1px solid var(--qf-color-border-subtle); margin-bottom:12px; } + .a4-col-tabs button { flex:1; min-height:44px; padding:0 4px; border:0; border-bottom:2px solid transparent; margin-bottom:-1px; background:none; font-size:13px; color:var(--qf-color-fg-default); white-space:nowrap; cursor:pointer; } + .a4-col-tabs button span { margin-left:4px; font-size:12px; color:var(--qf-color-fg-muted); } + .a4-col-tabs button[aria-selected="true"] { border-bottom-color:var(--qf-color-accent); font-weight:600; color:var(--qf-color-fg-strong); } + .a4-board { grid-template-columns:minmax(0,1fr); } + .a4-col { padding:0; background:none; gap:10px; } + .a4-col:not([data-active="true"]), .a4-col h2 { display:none; } + .a4-gantt { overflow-x:auto; } + .a4-gantt-row { grid-template-columns:150px repeat(7,64px); width:max-content; min-width:100%; } +} + +/* 2026-11 Penpot brushup: Guild (page 08). */ +.a4-guild { display:grid; grid-template-columns:minmax(0,1fr); column-gap:40px; align-items:start; padding-top:14px; } +.a4-guild-main { display:flex; flex-direction:column; gap:10px; min-width:0; } +.a4-guild-side { display:none; } +.a4-guild-part { display:flex; flex-direction:column; gap:10px; } +.a4-guild-part + .a4-guild-part, .a4-guild-main > .a4-guild-fold + .a4-guild-part { margin-top:24px; gap:0; } +.a4-guild-head { display:flex; align-items:center; gap:8px; min-height:32px; margin:0; font-size:17px; font-weight:600; line-height:1.5; color:var(--qf-color-fg-strong); } +.a4-guild-head h2 { margin:0; font:inherit; } +.a4-guild-head .a4-link { margin-left:auto; min-height:44px; padding:0 4px; font-size:13px; font-weight:400; color:var(--qf-color-accent); } +.a4-guild-count { font-size:13px; font-weight:600; color:var(--qf-color-review); } +.a4-guild-at { margin-left:auto; font-size:13px; font-weight:400; color:var(--qf-color-fg-muted); } +.a4-dec { position:relative; display:flex; flex-direction:column; gap:8px; padding:20px 24px; border:1px solid var(--qf-color-border-subtle); border-radius:6px; background:var(--qf-color-surface); } +.a4-dec::before { content:""; position:absolute; left:-1px; top:20px; bottom:20px; width:3px; border-radius:2px; background:var(--qf-color-review); } +.a4-dec-from { display:flex; align-items:center; gap:8px; margin:0; font-size:13px; color:var(--qf-color-fg-default); } +.a4-dec-from .a4-avatar { width:24px; height:24px; font-size:11px; font-weight:600; } +.a4-dec-from[data-stuck="true"] { font-weight:600; color:var(--qf-color-review); } +.a4-dec-from[data-stuck="true"] .a4-avatar { margin-right:14px; position:relative; } +.a4-dec-from[data-stuck="true"] .a4-avatar::after { content:""; position:absolute; right:-14px; top:9px; width:6px; height:6px; border-radius:50%; background:var(--qf-color-review); } +.a4-dec-title { margin:0; font-size:20px; font-weight:600; line-height:1.4; color:var(--qf-color-fg-strong); overflow-wrap:anywhere; } +.a4-dec-lead { margin:0; font-size:14px; line-height:1.5; color:var(--qf-color-fg-default); } +.a4-dec-body { display:grid; grid-template-columns:repeat(auto-fit,minmax(0,1fr)); gap:16px; } +.a4-dec-body:not(:has(> :not([hidden]))) { display:none; } +.a4-dec-notes { display:flex; flex-direction:column; gap:6px; min-width:0; } +.a4-dec .a4-step-label { display:flex; gap:8px; margin:0; font-size:12px; font-weight:600; line-height:1.5; color:var(--qf-color-fg-default); } +.a4-dec-notes p { margin:0; padding:14px; border-radius:6px; background:var(--qf-color-canvas); font-size:14px; line-height:1.8; color:var(--qf-color-fg-strong); white-space:pre-wrap; overflow-wrap:anywhere; } +.a4-dec .a4-images { display:flex; flex-direction:column; gap:6px; min-width:0; padding:0; border:0; } +.a4-dec .a4-images[hidden] { display:none; } +.a4-dec .a4-images-gallery .a4-image-list { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; margin:0; } +.a4-dec .a4-images-gallery .a4-image-row { gap:6px; font-size:12px; color:var(--qf-color-fg-default); } +.a4-dec .a4-images-gallery .a4-image-row > button { min-height:0; padding:3px; border:1px solid var(--qf-color-border-subtle); border-radius:6px; background:var(--qf-color-surface); cursor:zoom-in; } +.a4-dec .a4-images-gallery .a4-image-row img { width:100%; height:118px; object-fit:cover; border-radius:3px; } +.a4-dec .a4-images:not(.a4-images-gallery) > .a4-step-label, .a4-dec .a4-images:not(.a4-images-gallery):not(:has(.a4-image-row)) > .a4-meta:not([role]) { display:none; } +.a4-dec .a4-images:not(.a4-images-gallery) > .a4-link { align-self:flex-start; min-height:44px; padding:0 4px; font-size:13px; font-weight:400; color:var(--qf-color-fg-default); } +.a4-dec .a4-images:not(.a4-images-gallery) > [role="status"]:empty { display:none; } +.a4-dec-more { display:flex; flex-wrap:wrap; align-items:flex-start; gap:0 16px; } +.a4-dec-more .a4-more { flex:1 1 auto; } +.a4-dec-more .a4-more[open] { flex-basis:100%; order:2; } +.a4-dec-more .a4-more summary { padding:0 4px; font-size:13px; font-weight:400; } +.a4-dec-links { display:flex; gap:16px; margin-left:auto; } +.a4-dec-links :is(.a4-link, a) { display:inline-flex; align-items:center; gap:6px; min-height:44px; padding:0 4px; font-size:13px; font-weight:400; color:var(--qf-color-fg-default); text-decoration:none; } +.a4-dec .a4-option { gap:10px; margin:0; font-size:14px; color:var(--qf-color-fg-strong); } +.a4-dec-actions { display:flex; flex-wrap:wrap; align-items:center; gap:8px; } +.a4-dec-actions .a4-button { min-height:44px; padding:0 16px; font-size:14px; font-weight:500; } +.a4-dec-actions .a4-primary { display:inline-flex; align-items:center; gap:8px; } +.a4-dec-actions .a4-primary:disabled { opacity:1; border-color:transparent; background:var(--a4-soft); color:var(--qf-color-fg-muted); } +.a4-dec-actions .a4-link { display:inline-flex; align-items:center; gap:6px; min-height:44px; padding:0 4px; font-size:13px; font-weight:400; color:var(--qf-color-fg-default); } +.a4-dec-actions .a4-dec-next { margin-left:auto; } +.a4-app .a4-dec-actions svg { width:16px; height:16px; } +.a4-members { margin:0; padding:0; list-style:none; } +.a4-member { border-bottom:1px solid var(--qf-color-border-subtle); } +.a4-member-row { display:flex; align-items:center; gap:12px; width:100%; min-height:64px; padding:0; border:0; background:none; text-align:left; cursor:pointer; color:inherit; } +.a4-member-row .a4-avatar { width:36px; height:36px; font-size:14px; font-weight:600; } +.a4-member-row .a4-avatar[data-kind="self"] { color:var(--qf-color-accent); background:var(--qf-color-selected); } +.a4-member-copy { flex:1; min-width:0; display:flex; flex-direction:column; gap:1px; } +.a4-member-copy strong { font-size:14px; font-weight:600; line-height:1.5; color:var(--qf-color-fg-strong); } +.a4-member-now { display:flex; gap:6px; font-size:12px; line-height:1.5; color:var(--qf-color-fg-muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } +.a4-member-now b { font-weight:600; color:var(--qf-color-fg-default); } +.a4-member-now b[data-state="working"] { color:var(--qf-color-accent); } +.a4-member-now b:is([data-state="blocked"], [data-state="review_required"]) { color:var(--qf-color-review); } +.a4-member-count { font-size:13px; color:var(--qf-color-fg-muted); } +.a4-app .a4-member-row > svg { width:16px; height:16px; margin:0 4px 0 8px; color:var(--qf-color-fg-default); transition:transform .15s; } +.a4-member[data-open="true"] .a4-member-row > svg { transform:rotate(90deg); } +.a4-member[data-inactive="true"] .a4-member-copy strong { color:var(--qf-color-fg-muted); } +.a4-member-work { display:flex; flex-direction:column; align-items:flex-start; gap:4px; padding:0 0 12px 48px; } +.a4-member-work .a4-doing-list { width:100%; } +.a4-member-work .a4-link { min-height:44px; font-weight:400; } +.a4-guild-fold { margin:0; } +.a4-guild-fold > summary { display:flex; align-items:center; gap:8px; min-height:44px; font-size:14px; font-weight:600; list-style:none; cursor:pointer; color:var(--qf-color-fg-strong); } +.a4-guild-fold > summary::-webkit-details-marker { display:none; } +.a4-guild-fold > summary::before { content:"▸"; width:8px; font-size:11px; } +.a4-guild-fold[open] > summary::before { content:"▾"; } +.a4-guild-fold > summary span { font-size:13px; font-weight:400; color:var(--qf-color-fg-muted); } +.a4-guild .a4-activity-list { margin:0; padding:0; list-style:none; } +.a4-guild .a4-activity { display:flex; align-items:flex-start; gap:10px; min-height:0; padding:7px 0; border:0; font-size:13px; line-height:1.55; color:var(--qf-color-fg-default); } +.a4-guild .a4-activity-time { flex:0 0 36px; font-family:inherit; font-size:12px; line-height:1.65; color:var(--qf-color-fg-muted); } +.a4-guild nav.a4-group { margin:12px 0 0; } +.a4-guild .a4-extras { display:block; border-top:1px solid var(--qf-color-border-subtle); } +.a4-guild .a4-extras .a4-row { min-height:61px; padding:0; border:0; border-bottom:1px solid var(--qf-color-border-subtle); border-radius:0; background:none; box-shadow:none; } +.a4-guild .a4-extras .a4-row-main { display:flex; align-items:center; min-height:60px; padding:0; } +.a4-guild .a4-extras .a4-row-text { flex:1; gap:0; } +.a4-guild .a4-extras .a4-row-title { min-height:0; font-size:14px; font-weight:400; line-height:1.5; color:var(--qf-color-fg-strong); } +.a4-guild .a4-extras .a4-meta { font-size:12px; line-height:1.5; } +.a4-app .a4-guild .a4-extras svg { width:16px; height:16px; margin-right:4px; color:var(--qf-color-fg-default); } +.a4-image-viewer { width:min(720px, calc(100% - 32px)); padding:16px; border:0; border-radius:8px; } +.a4-image-viewer::backdrop { background:rgb(23 22 26 / .4); } +.a4-image-viewer-head { display:flex; align-items:center; gap:12px; min-height:44px; } +.a4-image-viewer-head strong { flex:1; min-width:0; font-size:14px; font-weight:600; } +.a4-image-viewer-head .a4-link { min-height:44px; font-size:13px; font-weight:400; color:var(--qf-color-fg-default); } +.a4-image-viewer > img { width:100%; margin-top:12px; border-radius:4px; background:var(--a4-soft); } +@media (min-width:1380px) { + .a4-guild { grid-template-columns:minmax(0,1fr) 281px; } + .a4-guild-side { display:block; padding-left:24px; border-left:1px solid var(--qf-color-border-subtle); } + .a4-guild-main > .a4-guild-fold:last-of-type, .a4-guild-main > nav.a4-group { display:none; } +} +@media (max-width:760px) { + .a4-guild { padding-top:10px; } + .a4-dec { padding:18px; } + .a4-dec::before { top:18px; bottom:18px; } + .a4-dec-body { grid-template-columns:minmax(0,1fr); gap:8px; } + .a4-dec-actions .a4-button:not(:has(svg)) { flex:1 1 40%; justify-content:center; } + .a4-dec .a4-images-gallery .a4-image-row img { height:96px; } +} +.a4-guild .a4-avatar { margin:0; } +.a4-guild-head .a4-link { margin-block:-6px; } +@media (max-width:760px) { .a4-guild-part + .a4-guild-part, .a4-guild-main > .a4-guild-fold + .a4-guild-part { margin-top:10px; } } + +/* 2026-11 Penpot brushup: Settings (page 09). */ +.a4-settings { display:grid; grid-template-columns:minmax(0,1fr); gap:20px 48px; align-items:start; padding-top:14px; } +.a4-set-col { display:flex; flex-direction:column; gap:20px; min-width:0; } +.a4-set-group { display:flex; flex-direction:column; } +.a4-set-head { display:flex; align-items:baseline; gap:8px; min-height:32px; margin:0 0 4px; font-size:15px; font-weight:600; line-height:1.5; color:var(--qf-color-fg-strong); } +.a4-set-head span { font-weight:600; } +.a4-set-row { display:flex; align-items:center; gap:12px; width:100%; min-height:53px; padding:8px 0; border:0; border-bottom:1px solid var(--qf-color-border-subtle); border-radius:0; background:none; color:inherit; font:inherit; text-align:left; text-decoration:none; } +:is(button, a, label).a4-set-row { cursor:pointer; } +.a4-set-copy { flex:1; min-width:0; display:flex; flex-direction:column; } +.a4-set-copy strong { font-size:14px; font-weight:500; line-height:1.5; color:var(--qf-color-fg-strong); } +.a4-set-copy small { font-size:12px; line-height:1.5; color:var(--qf-color-fg-muted); } +.a4-set-value { font-size:13px; color:var(--qf-color-fg-default); white-space:nowrap; } +.a4-app .a4-set-row > svg { width:16px; height:16px; margin:0 4px 0 4px; color:var(--qf-color-fg-default); flex-shrink:0; } +.a4-set-row .a4-input { width:auto; min-height:40px; margin:0; font-size:14px; } +.a4-set-row .a4-seg { margin:0; } +.a4-set-pair { display:flex; align-items:center; gap:10px; } +.a4-set-out { border-bottom:0; } +.a4-switch { appearance:none; flex:none; position:relative; width:44px; height:24px; margin:0; border-radius:12px; background:var(--qf-color-border-strong); cursor:pointer; transition:background .15s; } +.a4-switch::after { content:""; position:absolute; top:3px; left:3px; width:18px; height:18px; border-radius:50%; background:var(--qf-color-surface); transition:transform .15s; } +.a4-switch:checked { background:var(--qf-color-accent); } +.a4-switch:checked::after { transform:translateX(20px); } +.a4-switch:focus-visible { outline:2px solid var(--qf-color-accent); outline-offset:2px; } +.a4-set-page { display:flex; flex-direction:column; gap:0; max-width:720px; padding-top:14px; } +.a4-set-page > .a4-link:first-child { align-self:flex-start; min-height:44px; padding:0 4px; font-size:13px; font-weight:400; color:var(--qf-color-accent); } +.a4-set-page > .a4-title { margin:6px 0 0; font-size:22px; font-weight:600; line-height:1.4; } +.a4-set-page > .a4-lead { margin:2px 0 14px; font-size:13px; line-height:1.5; color:var(--qf-color-fg-default); } +.a4-set-page > .a4-set-head { margin-top:20px; } +.a4-set-page > .a4-muted { margin:12px 0; } +.a4-set-banner { display:flex; flex-wrap:wrap; align-items:center; gap:4px 20px; min-height:72px; margin:0; padding:12px 14px; border-radius:8px; background:var(--qf-color-selected); font-size:14px; color:var(--qf-color-fg-default); } +.a4-set-banner strong { flex:1; font-weight:600; color:var(--qf-color-accent); } +.a4-set-banner .a4-link { min-height:44px; padding:0 4px; font-size:13px; font-weight:400; color:var(--qf-color-fg-default); } +.a4-set-banner[data-off="true"] { background:var(--a4-soft); } +.a4-set-page .a4-step-form { display:flex; gap:8px; margin:0; } +.a4-set-page .a4-step-form .a4-input { flex:1; min-height:44px; margin:0; } +.a4-conns { margin:0; padding:0; list-style:none; } +.a4-conn { border-bottom:1px solid var(--qf-color-border-subtle); } +.a4-conn-main { display:flex; align-items:center; gap:12px; min-height:76px; padding:6px 0; } +.a4-conn-main .a4-avatar { width:36px; height:36px; margin:0; font-size:14px; font-weight:600; } +.a4-conn-text { flex:1; min-width:0; display:flex; flex-direction:column; font-size:12px; line-height:1.6; color:var(--qf-color-fg-muted); } +.a4-conn-text strong { font-size:14px; font-weight:600; line-height:1.5; color:var(--qf-color-fg-strong); } +.a4-conn-text span:empty { display:none; } +.a4-conn-main .a4-link { min-height:44px; padding:0 4px; font-size:13px; font-weight:400; color:var(--qf-color-accent); } +.a4-conn > .a4-conn-edit { padding:0 0 12px 48px; } +.a4-set-page .a4-guild-fold { margin-top:8px; } +.a4-set-page .a4-embed .rf-work-settings > h2 { display:none; } +@media (min-width:900px) { + .a4-settings { grid-template-columns:repeat(2,minmax(0,1fr)); } +} +@media (max-width:760px) { + .a4-settings, .a4-set-page { padding-top:6px; } + .a4-set-banner strong { flex-basis:100%; } +} +.a4-app .a4-set-page { margin-inline:0; } +.a4-app .a4-set-row .a4-input { flex:0 0 auto; width:auto; min-width:0; } + +/* 2026-11 Penpot brushup: Detail (page 10). */ +.a4-sheet { width:460px; } +.a4-sheet-head { display:flex; align-items:center; gap:4px; min-height:65px; padding:10px 12px 10px 24px; } +.a4-sheet-name { flex:1; margin:0; font-size:14px; font-weight:600; line-height:1.5; color:var(--qf-color-fg-strong); } +.a4-sheet-head > .a4-link { flex:1; justify-content:flex-start; text-align:left; font-weight:400; } +.a4-sheet-head .a4-icon-button { color:var(--qf-color-fg-strong); border-radius:6px; } +.a4-app .a4-sheet-head .a4-icon-button svg, .a4-sheet-head .a4-icon-button svg { width:18px; height:18px; } +.a4-menu { position:relative; } +.a4-menu > summary { list-style:none; cursor:pointer; } +.a4-menu > summary::-webkit-details-marker { display:none; } +.a4-menu-list { position:absolute; right:0; top:46px; z-index:3; display:flex; flex-direction:column; min-width:180px; padding:6px; border:1px solid var(--qf-color-border-subtle); border-radius:8px; background:var(--qf-color-surface); box-shadow:0 8px 24px rgb(23 22 26 / .12); } +.a4-menu-list :is(.a4-link, a) { display:flex; align-items:center; justify-content:flex-start; min-height:44px; padding:0 10px; border-radius:6px; font-size:14px; font-weight:400; color:var(--qf-color-fg-strong); text-decoration:none; white-space:nowrap; } +.a4-menu-list :is(.a4-link, a):hover { background:var(--a4-soft); } +.a4-sheet-body { padding:20px 24px 24px; } +.a4-sheet-form { display:flex; flex-direction:column; gap:16px; } +.a4-sheet-form > .a4-group, .a4-sheet-body .a4-sheet-form > .a4-group { margin:0; } +.a4-sheet-title { font-size:22px; font-weight:600; line-height:1.45; } +.a4-sheet-nextbox { display:flex; align-items:center; gap:10px; padding:8px 8px 8px 14px; border-radius:8px; background:var(--a4-soft); } +.a4-sheet-nextcopy { flex:1; min-width:0; display:flex; flex-direction:column; } +.a4-sheet-nextcopy .a4-step-label { margin:0; font-size:11px; font-weight:600; color:var(--qf-color-fg-default); } +.a4-sheet-nextbox .a4-sheet-next { width:100%; min-height:0; padding:0; border:0; background:none; box-shadow:none; font-size:15px; line-height:1.6; color:var(--qf-color-fg-strong); } +.a4-sheet-nextbox .a4-sheet-next:focus { outline:0; } +.a4-sheet-nextbox:focus-within { box-shadow:inset 0 0 0 1px var(--qf-color-accent); } +.a4-sheet-nextbox > .a4-button { align-self:center; min-height:44px; padding:0 14px; background:var(--qf-color-surface); font-size:14px; font-weight:500; white-space:nowrap; } +.a4-sheet-form .a4-step-label { display:flex; align-items:baseline; justify-content:space-between; margin:0 0 6px; font-size:12px; font-weight:600; color:var(--qf-color-fg-default); } +.a4-sheet-form .a4-group > h3.a4-step-label { font-size:14px; color:var(--qf-color-fg-strong); } +.a4-sheet-form .a4-step-label .a4-meta { font-size:12px; font-weight:400; } +.a4-sheet-steps { display:flex; flex-direction:column; } +.a4-step-draft { display:flex; align-items:center; min-height:48px; } +.a4-step-draft .a4-option { flex:1; min-width:0; gap:22px; margin:0; font-size:15px; color:var(--qf-color-fg-strong); } +.a4-step-draft .a4-option input { width:22px; height:22px; } +.a4-step-draft .a4-icon-button { color:var(--qf-color-fg-default); } +.a4-app .a4-step-draft .a4-icon-button svg, .a4-step-draft .a4-icon-button svg { width:16px; height:16px; } +.a4-step-add { display:flex; align-items:center; gap:24px; min-height:48px; padding-left:1px; color:var(--qf-color-fg-default); } +.a4-app .a4-step-add > svg, .a4-step-add > svg { width:18px; height:18px; flex-shrink:0; } +.a4-step-add .a4-input { flex:1; min-height:44px; padding:0; border:0; background:none; box-shadow:none; font-size:15px; } +.a4-step-add .a4-input:focus { outline:0; box-shadow:inset 0 -1px 0 var(--qf-color-accent); } +.a4-sheet-form > .a4-field-row { display:flex; flex-direction:column; gap:0; margin:0; } +.a4-sheet-form .a4-textarea { min-height:68px; padding:10px 12px; border-radius:6px; font-size:15px; line-height:1.6; resize:vertical; } +.a4-sheet-form > .a4-images { padding:0; border:0; } +.a4-sheet-form > .a4-images .a4-image-list { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:10px; margin:8px 0; } +.a4-sheet-form > .a4-images .a4-image-row { font-size:12px; gap:6px; color:var(--qf-color-fg-default); } +.a4-sheet-form > .a4-images .a4-image-row img { width:100%; height:92px; object-fit:cover; border-radius:6px; } +.a4-sheet-form > .a4-images > .a4-meta { font-size:12px; } +.a4-sheet-foot { display:flex; justify-content:flex-end; align-items:center; gap:16px; padding:14px 24px max(14px,env(safe-area-inset-bottom)); } +.a4-sheet-foot > .a4-link { min-height:44px; padding:0 8px; font-size:14px; font-weight:400; color:var(--qf-color-fg-default); } +.a4-sheet-foot > .a4-primary { min-height:44px; padding:0 20px; font-size:14px; } +@media (max-width:760px) { + .a4-sheet { width:100%; } + .a4-sheet-head { min-height:56px; padding:6px 8px 6px 20px; } + .a4-sheet-foot { padding:10px 20px max(12px,env(safe-area-inset-bottom)); } + .a4-sheet-foot > .a4-primary { flex:1; } +} +.a4-sheet-form .a4-step-draft { border:0; padding:0; } +.a4-sheet-titlerow { display:flex; align-items:flex-start; gap:12px; } +.a4-sheet-titlerow .a4-emoji-lg { width:44px; height:44px; font-size:30px; line-height:44px; } + +/* 2026-11 Penpot brushup: Add task (page 11). */ +.a4-add-dialog { width:min(480px, calc(100vw - 32px)); border-radius:12px; } +.a4-add-dialog .a4-sheet-head { display:flex; align-items:center; min-height:64px; padding:10px 12px 10px 24px; border-bottom:1px solid var(--qf-color-border-subtle); } +.a4-add-dialog .a4-subtitle { flex:1; margin:0; font-size:15px; font-weight:600; } +.a4-add-dialog .a4-icon-button { color:var(--qf-color-fg-strong); border-radius:6px; } +.a4-add-dialog .a4-capture { display:flex; flex-direction:column; flex-wrap:nowrap; gap:10px; padding:16px 24px 20px; } +.a4-add-dialog .a4-capture-input { flex:none; width:100%; height:52px; padding:0 12px; border:1px solid var(--qf-color-border-strong); border-radius:6px; background:var(--qf-color-canvas); font-size:17px; } +.a4-add-dialog .a4-capture-input:focus { outline:0; border-color:var(--qf-color-accent); box-shadow:0 0 0 1px var(--qf-color-accent); } +.a4-add-dialog .a4-parse { min-height:20px; margin:0; font-size:13px; line-height:1.5; color:var(--qf-color-fg-default); } +.a4-add-dialog .a4-save-error { margin:0; } +.a4-capture-row { display:flex; align-items:center; justify-content:space-between; gap:12px; } +.a4-capture-row .a4-chip { min-height:44px; padding:0 14px; font-size:13px; } +.a4-capture-row .a4-primary { flex:0 0 auto; min-height:44px; padding:0 20px; font-size:14px; } +.a4-capture-added { margin:6px 0 -8px; padding:6px 0 0; list-style:none; border-top:1px solid var(--qf-color-border-subtle); } +.a4-capture-added:empty { display:none; } +.a4-capture-added li { display:flex; flex-wrap:wrap; align-items:center; gap:0 10px; min-height:44px; font-size:14px; color:var(--qf-color-fg-strong); } +.a4-capture-added li > svg { width:16px; height:16px; color:var(--qf-color-accent); } +.a4-capture-added li > span { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.a4-capture-added .a4-link { min-height:44px; padding:0 8px; font-size:13px; font-weight:400; color:var(--qf-color-fg-default); } +.a4-capture-added .a4-button { margin:0 0 8px 26px; min-height:44px; padding:0 14px; font-size:14px; } +@media (max-width:760px) { + .a4-add-dialog { width:100%; max-width:none; margin:auto 0 0; border-radius:16px 16px 0 0; border:0; } + .a4-add-dialog .a4-capture { padding:14px 20px max(16px,env(safe-area-inset-bottom)); } + .a4-add-dialog .a4-sheet-head { padding-left:20px; } +} + +/* 2026-11 Penpot brushup: Done and choose (page 12). */ +.a4-picker { width:min(480px, calc(100vw - 32px)); padding:0; border-radius:12px; } +.a4-picker .a4-sheet-head { display:flex; align-items:center; gap:12px; min-height:64px; padding:12px 12px 12px 22px; border-bottom:1px solid var(--qf-color-border-subtle); } +.a4-picker .a4-sheet-head > div { flex:1; min-width:0; } +.a4-picker .a4-subtitle { margin:0; font-size:15px; font-weight:600; } +.a4-picker .a4-sheet-head .a4-meta { margin:2px 0 0; font-size:12px; color:var(--qf-color-fg-default); } +.a4-picker .a4-icon-button { color:var(--qf-color-fg-strong); } +.a4-picks { list-style:none; margin:0; padding:0 20px; } +.a4-picks > li + li { border-top:1px solid var(--qf-color-border-subtle); } +.a4-picker .a4-pick { flex-direction:row; align-items:center; gap:12px; min-height:66px; padding:8px 0; border:0; border-radius:0; background:none; color:var(--qf-color-fg-strong); } +.a4-picker .a4-pick:hover { background:none; } +.a4-picker .a4-pick:hover strong { text-decoration:underline; text-underline-offset:3px; } +.a4-picker .a4-pick > svg { flex:none; width:18px; height:18px; } +.a4-pick-icon { flex:none; display:grid; place-items:center; width:26px; font-size:18px; line-height:1; } +.a4-pick-icon svg { width:18px; height:18px; color:var(--qf-color-fg-default); } +.a4-pick .a4-avatar { flex:none; width:26px; height:26px; font-size:11px; } +.a4-pick-copy { flex:1; min-width:0; display:flex; flex-direction:column; gap:2px; text-align:left; } +.a4-pick-copy strong { font-size:14px; font-weight:600; line-height:1.5; overflow-wrap:anywhere; } +.a4-pick-copy small { font-size:12px; color:var(--qf-color-fg-muted); } +.a4-pick-more { margin:0 20px; border-top:1px solid var(--qf-color-border-subtle); } +.a4-pick-more > summary { display:flex; align-items:center; justify-content:space-between; min-height:48px; padding-left:38px; list-style:none; cursor:pointer; font-size:14px; color:var(--qf-color-fg-default); } +.a4-pick-more > summary::-webkit-details-marker { display:none; } +.a4-pick-more > summary::after { content:""; width:8px; height:8px; margin-right:6px; border:solid currentColor; border-width:0 1.5px 1.5px 0; transform:rotate(45deg) translateY(-3px); } +.a4-pick-more[open] > summary::after { transform:rotate(225deg); } +.a4-pick-more .a4-picks { padding:0; border-top:1px solid var(--qf-color-border-subtle); } +.a4-pick-all { display:flex; align-items:center; gap:6px; min-height:56px; margin:0 20px; padding:0 0 0 38px; width:calc(100% - 40px); border-top:1px solid var(--qf-color-border-subtle); border-radius:0; font-size:13px; color:var(--qf-color-accent); } +.a4-pick-all svg { width:14px; height:14px; } +.a4-picks:last-child, .a4-pick-more:last-child { margin-bottom:10px; } +.a4-pick-empty { display:flex; flex-direction:column; align-items:flex-start; gap:12px; padding:20px 22px 22px; } +.a4-pick-empty p { margin:0; color:var(--qf-color-fg-default); } +@media (max-width:760px) { + .a4-picker { width:100%; max-width:none; margin:auto 0 0; border:0; border-radius:16px 16px 0 0; padding-bottom:env(safe-area-inset-bottom); } +} + +/* Toasts are dark, short, and centered over the content column. */ +.a4-toast { gap:24px; min-height:48px; padding:6px 16px 6px 18px; border:0; border-radius:8px; background:var(--qf-color-fg-strong); color:var(--qf-color-canvas); font-size:14px; } +.a4-toast .a4-link { min-height:36px; padding:0; color:inherit; font-size:14px; font-weight:600; text-decoration:underline; text-underline-offset:3px; } +.a4-toast > svg { flex:none; width:18px; height:18px; margin-right:-12px; } +.a4-toast .a4-icon-button { width:36px; height:36px; min-height:36px; margin-left:-8px; color:inherit; } +.a4-toast .a4-icon-button:hover { background:color-mix(in srgb, var(--qf-color-canvas) 14%, transparent); } +@media (min-width:1100px) { + .a4-toast, .a4-dock { left:calc(50% + 88px); } +} + +/* Running dock: timer, the task, pause, done. */ +.a4-dock { width:min(520px, calc(100% - 32px)); gap:12px; padding:8px 8px 8px 16px; border:1px solid var(--qf-color-border-strong); border-radius:8px; } +.a4-dock .a4-run { flex:none; display:flex; align-items:baseline; gap:6px; min-width:88px; font:600 14px/1 var(--qf-font-mono); color:var(--qf-color-accent); font-variant-numeric:tabular-nums; } +.a4-dock .a4-run small { font:400 12px/1 var(--qf-font-sans); color:var(--qf-color-fg-muted); } +.a4-dock .a4-row-title { min-height:0; font-size:13px; line-height:1.5; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.a4-dock .a4-meta { font-size:11px; color:var(--qf-color-fg-muted); } +.a4-dock .a4-icon-button { flex:none; width:44px; height:44px; color:var(--qf-color-fg-strong); } +.a4-dock .a4-button { flex:none; display:inline-flex; align-items:center; gap:6px; min-height:44px; padding:0 14px; font-size:13px; } +.a4-dock .a4-button svg { width:16px; height:16px; } + +/* Paused and all-done cards. */ +.a4-all-done .a4-focus-title { margin:4px 0 6px; font-size:20px; } +.a4-all-done .a4-muted { margin:0 0 16px; font-size:13px; } +.a4-all-done .a4-actions { justify-content:space-between; } +.a4-all-done .a4-actions .a4-link { font-weight:400; color:var(--qf-color-fg-default); } +.a4-free.a4-rest { display:block; padding:0 0 0 102px; } +.a4-rest .a4-link { display:inline-flex; align-items:center; min-height:44px; padding:0 4px; font-size:12px; font-weight:400; color:var(--qf-color-fg-muted); } +@media (max-width:760px) { + .a4-free.a4-rest { padding-left:84px; } +} +.a4-dock .a4-run p { display:flex; align-items:baseline; gap:6px; margin:0; } +.a4-dock .a4-run-left, .a4-dock .a4-run .a4-progress { display:none; } +.a4-dock .a4-run strong { font-size:14px; } +.a4-run-inline .a4-run p { display:flex; align-items:baseline; gap:4px; margin:0; } +.a4-run-inline .a4-run strong { font:400 13px/1 var(--qf-font-sans); color:inherit; font-variant-numeric:tabular-nums; } +.a4-run-inline .a4-run small { font-size:13px; color:inherit; } +.a4-run-inline .a4-run-left, .a4-run-inline .a4-progress { display:none; } +.a4-focus[data-running="true"] .a4-actions > .a4-link:last-child { margin-left:auto; font-weight:400; color:var(--qf-color-fg-default); } + +/* 2026-11 Penpot brushup: Empty states (page 13). */ +.a4-plan-side > .a4-muted { margin:0; font-size:14px; } +.a4-plan-sub:empty { display:none; } +.a4-day-empty { display:flex; flex-direction:column; align-items:flex-start; gap:12px; margin:8px 0 0; font-size:14px; color:var(--qf-color-fg-default); } +.a4-day-empty p { margin:0; } +p.a4-day-empty { margin-top:16px; } +.a4-day-empty .a4-button { display:inline-flex; align-items:center; gap:8px; min-height:44px; padding:0 16px; font-size:13px; } +.a4-day-empty .a4-button svg { width:16px; height:16px; } +.a4-wk-hint { margin:0 0 4px; font-size:12px; color:var(--qf-color-fg-default); } +.a4-wk-scroll[data-blank="true"] .a4-wk-col { cursor:copy; } +.a4-wl-add { display:inline-flex; align-items:center; gap:8px; margin-left:auto; min-height:44px; padding:0 4px; font-size:13px; font-weight:400; color:var(--qf-color-fg-default); } +.a4-wl-add svg { width:16px; height:16px; } +.a4-wl-plan:has(> .a4-wl-add:only-child) { display:flex; align-items:center; } +.a4-wk-scroll[data-blank="true"] .a4-wk { grid-template-rows:auto var(--a4-wk-height); } +.a4-wk-scroll[data-blank="true"] .a4-wk-day { padding-bottom:12px; } +.a4-empty-box { display:flex; flex-direction:column; align-items:center; gap:8px; padding:40px 20px; border:1px dashed var(--qf-color-border-strong); border-radius:8px; text-align:center; } +.a4-empty-box > * { margin:0; } +.a4-app .a4-empty-box > svg { width:22px; height:22px; margin-bottom:4px; color:var(--qf-color-accent); } +.a4-empty-box .a4-subtitle { font-size:18px; font-weight:600; } +.a4-empty-box .a4-muted { font-size:14px; } +.a4-empty-box .a4-primary { display:inline-flex; align-items:center; gap:8px; margin-top:12px; min-height:44px; padding:0 16px; } +.a4-empty-box .a4-primary svg { width:16px; height:16px; } +.a4-all-clear { margin:24px 0 16px; font-size:16px; font-weight:600; color:var(--qf-color-fg-strong); } +.a4-gantt-empty, .a4-search-none { display:flex; flex-direction:column; align-items:flex-start; gap:4px; padding:24px 0; } +.a4-gantt-empty > *, .a4-search-none > * { margin:0; } +.a4-gantt-empty .a4-subtitle, .a4-search-none .a4-subtitle { font-size:16px; font-weight:600; } +.a4-gantt-empty .a4-muted, .a4-search-none .a4-muted { font-size:14px; } +.a4-gantt-empty .a4-link { display:inline-flex; align-items:center; gap:4px; min-height:44px; margin-top:4px; padding:0 4px; font-size:13px; font-weight:400; color:var(--qf-color-fg-default); } +.a4-gantt-empty .a4-link svg { width:14px; height:14px; } +.a4-search-none .a4-actions { display:flex; align-items:center; gap:12px; margin-top:16px; } +.a4-search-none .a4-button { display:inline-flex; align-items:center; gap:8px; min-height:44px; padding:0 16px; font-size:14px; } +.a4-search-none .a4-button svg { width:16px; height:16px; } +.a4-search-none .a4-link { min-height:44px; font-size:13px; font-weight:400; color:var(--qf-color-fg-default); } +.a4-member-row:disabled { cursor:default; opacity:1; } +.a4-member-row:disabled:hover { background:none; } +.a4-guild-none { display:flex; flex-direction:column; align-items:flex-start; gap:12px; padding:20px 0 0; } +.a4-guild-none p { margin:0; font-size:14px; color:var(--qf-color-fg-default); } +.a4-guild-none .a4-button { display:inline-flex; align-items:center; gap:8px; min-height:44px; padding:0 16px; font-size:14px; } +.a4-guild-none .a4-button svg { width:16px; height:16px; } +.a4-guild-quiet .a4-muted { display:flex; align-items:center; gap:16px; margin:0; min-height:44px; font-size:14px; } +.a4-guild-quiet .a4-link { min-height:44px; font-size:14px; font-weight:400; color:var(--qf-color-accent); } +.a4-guild-main > .a4-guild-part + .a4-guild-part { margin-top:28px; } +@media (min-width:1380px) { .a4-guild-main > .a4-guild-quiet { display:none; } } + +/* 2026-11 Penpot brushup: Small parts (page 14). */ +.a4-icon-pane { display:flex; flex-direction:column; flex-wrap:nowrap; align-items:stretch; gap:10px; padding:14px; border-radius:8px; background:var(--a4-soft); } +.a4-icon-pane .a4-step-label { margin:0; font-size:12px; font-weight:600; color:var(--qf-color-fg-strong); } +.a4-icon-choices { display:flex; flex-wrap:wrap; gap:6px; } +.a4-icon-choices .a4-icon-choice { width:44px; height:44px; min-height:44px; padding:0; justify-content:center; border-radius:999px; background:var(--qf-color-surface); font-size:20px; } +.a4-icon-choices .a4-icon-choice[aria-pressed="true"] { border-color:var(--qf-color-accent); background:var(--qf-color-selected); box-shadow:0 0 0 1px var(--qf-color-accent); } +.a4-icon-more { display:flex; align-items:center; gap:12px; } +.a4-icon-more .a4-chip { min-height:44px; padding:0 16px; border-radius:999px; background:var(--qf-color-surface); } +.a4-icon-more .a4-link { min-height:44px; font-size:13px; font-weight:400; color:var(--qf-color-fg-default); } +.a4-icon-typed { height:44px; font-size:18px; background:var(--qf-color-surface); } +.a4-icon-pane > .a4-meta { margin:0; font-size:12px; color:var(--qf-color-fg-default); } +.a4-icon-pane .a4-icon-typed { flex:none; height:44px; min-height:0; max-height:44px; padding:0 12px; resize:none; } +.a4-icon-choices { gap:4px; } +.a4-icon-choices .a4-icon-choice { width:42px; height:42px; min-height:42px; font-size:19px; } +.a4-relay-solo { display:flex; align-items:center; gap:12px; min-height:52px; list-style:none; } +.a4-relay-solo .a4-avatar, .a4-relay-cand .a4-avatar { flex:none; width:32px; height:32px; font-size:13px; } +.a4-relay-cand-copy { flex:1; min-width:0; display:flex; flex-direction:column; text-align:left; } +.a4-relay-cand-copy strong { font-size:14px; font-weight:600; color:var(--qf-color-fg-strong); } +.a4-relay-cand-copy small { font-size:12px; color:var(--qf-color-fg-default); } +.a4-relay-add.a4-button { display:inline-flex; align-items:center; gap:6px; min-height:44px; padding:0 12px; border:1px solid var(--qf-color-border-strong); border-style:solid; border-radius:6px; background:var(--qf-color-surface); color:var(--qf-color-fg-strong); font-size:14px; font-weight:500; } +.a4-relay-add.a4-button[aria-expanded="true"] { border-color:var(--qf-color-accent); background:var(--qf-color-selected); color:var(--qf-color-accent); } +.a4-relay-add svg { width:16px; height:16px; } +.a4-relay .a4-relay-palette { display:block; margin-top:8px; padding:12px 14px; border-radius:8px; background:var(--a4-soft); list-style:none; } +.a4-relay-palette > p { margin:0 0 4px; font-size:12px; font-weight:600; color:var(--qf-color-fg-strong); } +.a4-relay-palette > p:not(.a4-step-label) { font-size:14px; font-weight:400; } +.a4-relay-palette ul { margin:0; padding:0; list-style:none; } +.a4-relay-palette li + li { border-top:1px solid var(--qf-color-border-subtle); } +.a4-relay-cand { display:flex; align-items:center; gap:12px; width:100%; min-height:60px; padding:0; border:0; background:none; cursor:pointer; -webkit-user-select:none; user-select:none; } +.a4-relay-cand:hover strong { text-decoration:underline; text-underline-offset:3px; } +.a4-relay-cand .a4-meta { flex:none; font-size:12px; color:var(--qf-color-fg-default); } +.a4-relay-cand[data-dragging="true"] { position:relative; z-index:2; background:var(--qf-color-raised); box-shadow:var(--qf-shadow-overlay); } +.a4-relay-palette .a4-link { display:inline-flex; align-items:center; gap:4px; min-height:44px; font-size:13px; color:var(--qf-color-accent); } +.a4-relay-palette .a4-link svg { width:14px; height:14px; } +.a4-relay-grip { flex:none; display:grid; place-items:center; width:16px; margin-right:-4px; color:var(--qf-color-fg-muted); cursor:grab; } +.a4-relay-grip svg { width:14px; height:14px; } +.a4-relay-x svg { width:18px; height:18px; } +.a4-relay-node[data-kind="agent"] { border-color:var(--qf-color-agent); } +.a4-search { position:relative; } +.a4-search input::-webkit-search-cancel-button { -webkit-appearance:none; appearance:none; display:none; } +.a4-search-clear { flex:none; width:36px; height:36px; min-height:36px; margin-right:-6px; color:var(--qf-color-fg-default); } +.a4-search-clear svg { width:16px; height:16px; } +.a4-hits { margin-top:8px; } +.a4-hit-count { margin:0 0 4px; font-size:13px; color:var(--qf-color-fg-default); } +.a4-hits .a4-list { gap:0; } +.a4-hit { align-items:flex-start; } +.a4-hit > .a4-check, .a4-hit > .a4-check-space { margin-top:2px; } +.a4-hit-text { flex:1; min-width:0; display:flex; flex-direction:column; justify-content:center; min-height:44px; } +.a4-hit-text .a4-row-title { min-height:0; padding:10px 0 2px; } +.a4-hit-line { display:block; padding-bottom:8px; font-size:12px; color:var(--qf-color-fg-default); } +.a4-hit-line > span:first-child { margin-right:8px; color:var(--qf-color-fg-muted); } +.a4-hit mark { background:none; color:var(--qf-color-accent); font-weight:600; } +.a4-hit[data-state="done"] .a4-row-title { text-decoration:line-through; color:var(--qf-color-fg-muted); } +.a4-hit[data-state="archived"] .a4-row-title { color:var(--qf-color-fg-default); } +.a4-hit > .a4-meta { align-self:center; } +.a4-hit > .a4-link { align-self:center; min-height:44px; font-weight:400; } +.a4-hits > .a4-muted { margin:12px 0 0; font-size:13px; } +.a4-hit { align-items:center; } +.a4-hit > .a4-check, .a4-hit > .a4-check-space { margin-top:8px; margin-bottom:8px; } +.a4-hit:has(.a4-hit-line) > .a4-check { align-self:flex-start; margin-top:6px; } + +/* 2026-11 Penpot brushup: Agent management (page 15). */ +.a4-member-line { display:flex; align-items:center; gap:8px; } +.a4-member-line > .a4-member-row { flex:1; min-width:0; } +.a4-member-link { display:inline-flex; align-items:center; gap:4px; min-height:44px; padding:0 6px; font-size:13px; font-weight:600; color:var(--qf-color-accent); white-space:nowrap; } +.a4-member-link svg { width:14px; height:14px; } +.a4-member-menu > summary { width:44px; height:44px; display:grid; place-items:center; border-radius:6px; color:var(--qf-color-fg-strong); } +.a4-member-menu[open] > summary { background:var(--a4-soft); } +.a4-member-menu .a4-menu-list .a4-link { justify-content:flex-start; min-height:44px; padding:0 12px; font-size:14px; font-weight:400; color:var(--qf-color-fg-strong); text-align:left; } +.a4-member-menu .a4-menu-list .a4-link:hover { background:var(--a4-soft); text-decoration:none; } +.a4-confirm { width:min(480px, calc(100vw - 32px)); padding:0; border-radius:12px; } +.a4-confirm .a4-sheet-head { display:flex; align-items:center; gap:12px; min-height:64px; padding:10px 12px 10px 22px; border-bottom:1px solid var(--qf-color-border-subtle); } +.a4-confirm .a4-subtitle { flex:1; margin:0; font-size:16px; font-weight:600; } +.a4-confirm-body { display:flex; flex-direction:column; gap:10px; padding:20px 22px 4px; font-size:14px; color:var(--qf-color-fg-default); } +.a4-confirm-body > p { margin:0; } +.a4-confirm-body .a4-option strong { color:var(--qf-color-fg-strong); font-weight:600; } +.a4-confirm-list { display:flex; flex-direction:column; gap:6px; margin:0 0 0 32px; padding:0; list-style:none; font-size:13px; color:var(--qf-color-fg-strong); } +.a4-confirm-list li { display:flex; align-items:center; gap:8px; } +.a4-confirm-foot { justify-content:flex-end; gap:12px; padding:14px 22px 20px; } +.a4-confirm-foot .a4-link { min-height:44px; padding:0 12px; font-weight:400; color:var(--qf-color-fg-default); } +.a4-confirm-foot .a4-button { min-height:44px; padding:0 22px; } +.a4-agent-sheet .a4-sheet-head { display:flex; align-items:center; min-height:64px; padding:10px 12px 10px 24px; border-bottom:1px solid var(--qf-color-border-subtle); } +.a4-agent-sheet .a4-subtitle { flex:1; margin:0; font-size:15px; font-weight:600; } +.a4-agent-form { display:flex; flex-direction:column; gap:18px; padding:24px; } +.a4-agent-form .a4-field-row { display:flex; flex-direction:column; gap:6px; margin:0; } +.a4-agent-form .a4-step-label { display:flex; align-items:baseline; gap:8px; font-size:12px; font-weight:600; color:var(--qf-color-fg-strong); } +.a4-agent-form .a4-step-label small { font-weight:400; color:var(--qf-color-fg-muted); } +.a4-agent-form .a4-input { height:44px; font-size:15px; } +.a4-agent-form .a4-textarea { height:auto; } +.a4-agent-form .a4-muted, .a4-field-error { font-size:12px; } +.a4-field-error { color:var(--qf-color-danger); } +.a4-agent-form [aria-invalid="true"] { border-color:var(--qf-color-danger); } +.a4-agent-id { display:flex; align-items:center; gap:8px; margin:-8px 0 0; font-size:12px; color:var(--qf-color-fg-default); } +.a4-agent-id strong { font:400 12px var(--qf-font-mono); } +.a4-agent-id .a4-link { min-height:32px; font-size:12px; color:var(--qf-color-accent); } +.a4-powers { display:flex; flex-direction:column; gap:8px; margin:0; padding:0; border:0; } +.a4-powers legend { margin-bottom:8px; } +.a4-power { display:flex; align-items:center; gap:14px; min-height:62px; padding:10px 16px; border:1px solid var(--qf-color-border-subtle); border-radius:8px; background:var(--qf-color-surface); cursor:pointer; } +.a4-power:has(input:checked) { border-color:var(--qf-color-accent); background:var(--qf-color-selected); } +.a4-power input { flex:none; width:20px; height:20px; accent-color:var(--qf-color-accent); } +.a4-power span { display:flex; flex-direction:column; } +.a4-power strong { font-size:14px; font-weight:600; color:var(--qf-color-fg-strong); } +.a4-power small { font-size:12px; color:var(--qf-color-fg-default); } +.a4-agent-review { font-size:14px; } +.a4-agent-review input { accent-color:var(--qf-color-accent); } +.a4-agent-form > .a4-chip { align-self:flex-start; min-height:44px; padding:0 16px; border-radius:999px; } +.a4-agent-sheet .a4-sheet-foot { justify-content:flex-end; gap:12px; padding:12px 24px; border-top:1px solid var(--qf-color-border-subtle); } +.a4-agent-sheet .a4-sheet-foot .a4-link { min-height:44px; padding:0 16px; font-weight:400; color:var(--qf-color-fg-default); } +.a4-agent-sheet .a4-sheet-foot .a4-primary { min-height:44px; padding:0 20px; } +@media (max-width:760px) { + .a4-confirm { width:100%; max-width:none; margin:auto 0 0; border:0; border-radius:16px 16px 0 0; padding-bottom:env(safe-area-inset-bottom); } +} +.a4-confirm input[type="checkbox"] { width:20px; height:20px; accent-color:var(--qf-color-accent); } +.a4-agent-sheet[open] { display:flex; flex-direction:column; } +.a4-agent-sheet .a4-agent-form { flex:1; overflow:auto; } +.a4-agent-form .a4-step-label { justify-content:flex-start; } + +/* 2026-11 Penpot brushup: Sign-in (page 16). */ +.a4-boot { width:min(440px,100%); gap:16px; padding:48px 24px; } +.a4-boot-mark { gap:8px; font-size:22px; font-weight:600; letter-spacing:0; } +.a4-boot-mark::before { display:none; } +.a4-boot-mark img { width:19px; height:26px; } +.a4-boot h1 { margin:12px 0 0; font-size:28px; } +.a4-boot-lead { font-size:15px; line-height:1.6; } +.a4-boot-points { gap:10px; margin-top:4px; } +.a4-boot-points li { gap:12px; font-size:15px; } +.a4-boot-points li::before { display:none; } +.a4-boot-points li svg { flex:none; width:18px; height:18px; color:var(--qf-color-accent); } +.a4-boot-actions { gap:8px; margin-top:8px; } +.a4-boot-actions .a4-button { display:inline-flex; align-items:center; justify-content:center; gap:10px; min-height:48px; font-size:15px; } +.a4-google { display:grid; place-items:center; width:28px; height:28px; border-radius:4px; background:#fff; } +.a4-boot-note { font-size:12px; color:var(--qf-color-fg-default); } +.a4-boot-legal { display:flex; gap:16px; margin-top:16px !important; font-size:12px; } +.a4-boot-legal a { color:var(--qf-color-fg-default); text-underline-offset:3px; } +.a4-boot-out { align-self:flex-start; min-height:44px; padding:0 4px; font-size:13px; font-weight:400; color:var(--qf-color-fg-default); } +.a4-boot[data-kind="loading"] .a4-boot-ghost { margin-top:8px; } +.a4-boot[data-kind="loading"] .a4-boot-lead { font-size:13px; } +.a4-demo { display:flex; align-items:center; justify-content:center; gap:24px; min-height:44px; padding:0 16px; font-size:13px; } +.a4-demo a { color:var(--qf-color-accent); font-weight:600; text-decoration:none; } + +/* 2026-11 Penpot brushup: Production-only screens (page 17). */ +.a4-profile .a4-profile-form { display:flex; flex-direction:column; gap:18px; max-width:480px; } +.a4-profile-avatar { display:flex; align-items:center; gap:14px; } +.a4-profile-face { flex:none; display:grid; place-items:center; width:64px; height:64px; overflow:hidden; border-radius:8px; background:var(--qf-color-selected); font-size:20px; font-weight:600; color:var(--qf-color-accent); } +.a4-profile-face img { width:100%; height:100%; object-fit:cover; } +.a4-profile-avatar .a4-button { min-height:44px; padding:0 16px; font-size:13px; } +.a4-profile-avatar .a4-link { min-height:44px; padding:0 4px; font-size:13px; font-weight:400; color:var(--qf-color-fg-default); } +.a4-profile .a4-field-row { display:flex; flex-direction:column; gap:6px; margin:0; } +.a4-profile .a4-step-label { font-size:13px; font-weight:600; color:var(--qf-color-fg-strong); } +.a4-profile .a4-input { height:44px; padding:0 12px; font-size:15px; } +.a4-profile .a4-textarea { height:auto; min-height:76px; padding:10px 12px; line-height:1.6; } +.a4-at-field { display:flex; align-items:center; gap:2px; height:44px; padding-left:12px; border:1px solid var(--qf-color-border-strong); border-radius:6px; background:var(--qf-color-field); color:var(--qf-color-fg-muted); } +.a4-at-field:focus-within { border-color:var(--qf-color-accent); box-shadow:0 0 0 1px var(--qf-color-accent); } +.a4-at-field .a4-input { flex:1; height:42px; padding-left:2px; border:0; background:none; box-shadow:none; outline:0; } +.a4-at-field:has([aria-invalid="true"]) { border-color:var(--qf-color-review); } +.a4-profile .a4-field-error { color:var(--qf-color-review); font-size:13px; line-height:1.5; } +.a4-profile .a4-count { align-self:flex-end; font-size:12px; color:var(--qf-color-fg-muted); } +.a4-profile .a4-muted { font-size:12px; } +.a4-profile .a4-actions .a4-primary { min-height:44px; padding:0 22px; } +.a4-extra-page { max-width:1000px; } +.a4-extra-page .rf-screen-heading, .a4-extra-page .rf-screen-meta { display:none; } +.a4-extra-page .rf-screen-header:not(:has(.rf-screen-actions)) { display:none; } +.a4-extra-page .rf-screen-header { justify-content:flex-end; min-height:0; padding:0; border:0; } +.a4-extra-page .rf-region-label { display:none; } + +/* Plan and Credits (page 17): plan card, Free Agents, going Pro, Credits; Jev and the history are child pages. */ +.a4-bill-page { max-width:720px; gap:0; } +.a4-bill-page > .a4-title { margin-bottom:16px; } +.a4-bill-page > .a4-lead { margin:-8px 0 20px; } +.a4-bill-card { display:flex; flex-direction:column; gap:8px; padding:16px; border:1px solid var(--qf-color-border-subtle); border-radius:6px; background:var(--qf-color-surface); } +.a4-bill-card p { margin:0; } +.a4-bill-card > .a4-button { align-self:stretch; margin-top:8px; } +.a4-bill-plan { display:flex; align-items:center; gap:12px; } +.a4-bill-plan strong { font-size:22px; font-weight:650; color:var(--qf-color-fg-strong); } +.a4-bill-chip { padding:2px 8px; border-radius:999px; background:var(--a4-soft); color:var(--qf-color-fg-default); font-size:12px; } +.a4-bill-credits { display:flex; align-items:baseline; gap:8px; font-size:13px; color:var(--qf-color-fg-muted); } +.a4-bill-credits strong { font-size:16px; font-weight:650; color:var(--qf-color-fg-strong); } +.a4-bill-section { display:flex; flex-direction:column; gap:8px; margin-top:28px; } +.a4-bill-section > .a4-subtitle { font-size:15px; font-weight:600; } +.a4-bill-head { display:flex; align-items:baseline; justify-content:space-between; } +.a4-bill-section > .a4-meta, .a4-bill-page .a4-meta { margin:0; } +.a4-bill-list { display:flex; flex-direction:column; } +.a4-bill-check { display:flex; align-items:flex-start; gap:12px; min-height:40px; padding:10px 0; border-bottom:1px solid var(--qf-color-border-subtle); font-size:14px; line-height:1.5; color:var(--qf-color-fg-strong); cursor:pointer; } +.a4-bill-check input { flex:none; width:20px; height:20px; margin:1px 0 0; accent-color:var(--qf-color-accent); } +.a4-bill-check[data-muted=true] { color:var(--qf-color-fg-muted); cursor:default; } +.a4-bill-section > .a4-bill-check, .a4-bill-page > .a4-bill-check { border-bottom:0; font-size:13px; } +.a4-bill-choices { display:grid; grid-template-columns:1fr 1fr; gap:10px; } +.a4-bill-choice { display:flex; flex-direction:column; align-items:flex-start; gap:4px; padding:14px; border:1px solid var(--qf-color-border-subtle); border-radius:6px; background:var(--qf-color-surface); color:inherit; font:inherit; text-align:left; cursor:pointer; } +.a4-bill-choice strong { font-size:20px; font-weight:650; color:var(--qf-color-fg-strong); } +.a4-bill-choice[aria-checked=true] { border-color:var(--qf-color-accent); background:var(--qf-color-selected); } +.a4-bill-choice[aria-checked=true] > .a4-meta:first-child { color:var(--qf-color-accent); font-weight:600; } +.a4-bill-points, .a4-bill-terms { margin:4px 0 0; padding:0; list-style:none; font-size:13px; line-height:1.75; color:var(--qf-color-fg-default); } +.a4-bill-points li::before, .a4-bill-terms li::before { content:"・"; } +.a4-bill-fold > summary { display:inline-flex; align-items:center; gap:6px; min-height:36px; color:var(--qf-color-accent); font-size:13px; cursor:pointer; list-style:none; } +.a4-bill-fold > summary::-webkit-details-marker { display:none; } +.a4-bill-fold[open] > summary svg { transform:rotate(180deg); } +.a4-bill-fold > .a4-input { max-width:320px; margin:4px 0 8px; } +.a4-bill-section > .a4-primary, .a4-bill-page > .a4-primary { align-self:flex-start; min-height:44px; padding:0 22px; } +.a4-bill-alert { display:flex; align-items:center; gap:12px; margin:0 0 12px; padding:10px 12px; border-radius:6px; background:var(--a4-warning-soft); color:var(--qf-color-fg-strong); font-size:13px; } +.a4-bill-payload { padding:8px 12px; border-left:2px solid var(--qf-color-border-subtle); font-size:13px; color:var(--qf-color-fg-default); } +.a4-bill-payload p { margin:0 0 4px; } +.a4-bill-page > .a4-field-row { margin-bottom:16px; } +.a4-bill-page > .a4-bill-section:has(.a4-step-label) { margin-top:0; } +.a4-bill-amount { font-variant-numeric:tabular-nums; color:var(--qf-color-fg-strong); } +.a4-bill-result { margin:0; } +@media (max-width:760px) { + .a4-bill-section > .a4-primary, .a4-bill-page > .a4-primary { align-self:stretch; } +} + +/* Network (page 17): blocked roots first with their reasons; the drawing opens from the switch. */ +.a4-net { display:flex; flex-direction:column; gap:12px; max-width:1000px; } +.a4-net-head { display:flex; align-items:center; justify-content:space-between; gap:12px; } +.a4-net-summary { margin:0; font-size:14px; font-weight:600; color:var(--qf-color-fg-strong); } +.a4-net-switch { display:flex; gap:8px; } +.a4-net-pill { min-width:44px; height:44px; padding:0 14px; border:1px solid var(--qf-color-border-subtle); border-radius:999px; background:var(--qf-color-surface); color:var(--qf-color-fg-strong); font:inherit; font-size:13px; cursor:pointer; } +.a4-net-pill[aria-pressed="true"] { border-color:var(--qf-color-accent); background:var(--qf-color-selected); color:var(--qf-color-accent); font-weight:600; } +.a4-net-split { display:grid; grid-template-columns:minmax(0,380px) minmax(0,1fr); gap:24px; align-items:start; } +.a4-net-roots { display:flex; flex-direction:column; gap:2px; margin:0; padding:0; list-style:none; } +.a4-net-root { display:flex; flex-direction:column; gap:2px; width:100%; padding:12px; border:0; border-radius:6px; background:none; color:inherit; font:inherit; text-align:left; cursor:pointer; } +.a4-net-root:hover { background:var(--a4-soft); } +.a4-net-root[aria-current="true"] { background:var(--qf-color-selected); } +.a4-net-root-title { display:flex; justify-content:space-between; gap:8px; font-size:14px; font-weight:600; color:var(--qf-color-fg-strong); } +.a4-net-root .a4-meta { margin:0; font-weight:400; } +.a4-net-detail { display:flex; flex-direction:column; gap:20px; padding:20px; border:1px solid var(--qf-color-border-subtle); border-radius:6px; background:var(--qf-color-surface); } +[data-own-frame] .a4-net-detail { padding:0; border:0; background:none; } +.a4-net-detail-head { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; } +.a4-net-detail-title { margin:0; font-size:18px; font-weight:600; color:var(--qf-color-fg-strong); } +.a4-net-group-head { display:flex; justify-content:space-between; margin:0 0 4px; font-size:15px; font-weight:600; color:var(--qf-color-fg-strong); } +.a4-net-group-head .a4-meta { font-weight:400; } +.a4-net-entries { margin:0; padding:0; list-style:none; } +.a4-net-entry { display:flex; align-items:center; gap:12px; padding:12px 0; border-bottom:1px solid var(--qf-color-border-subtle); } +.a4-net-entry:last-child { border-bottom:0; } +.a4-net-entry-main { flex:1; min-width:0; display:flex; flex-direction:column; gap:4px; } +.a4-net-kind { display:flex; align-items:center; gap:8px; font-size:12px; color:var(--qf-color-fg-muted); } +.a4-net-entry-title { padding:0; border:0; background:none; font:inherit; font-size:14px; font-weight:600; text-align:left; color:var(--qf-color-fg-strong); cursor:pointer; } +strong.a4-net-entry-title { cursor:default; } +.a4-net-entry .a4-meta { margin:0; font-size:12px; } +.a4-net-chip { padding:1px 8px; border-radius:999px; background:var(--a4-soft); color:var(--qf-color-fg-default); font-size:11px; line-height:1.6; } +.a4-net-chip[data-tone="review"] { background:var(--a4-warning-soft); color:var(--qf-color-review); } +.a4-net-chip[data-tone="done"] { background:var(--qf-color-selected); color:var(--qf-color-accent); } +.a4-net-empty { margin:0; padding:24px; border:1px dashed var(--qf-color-border-subtle); border-radius:6px; color:var(--qf-color-fg-muted); text-align:center; } +.a4-net-graph { border:1px solid var(--qf-color-border-subtle); border-radius:6px; background:var(--qf-color-surface); overflow:hidden; } +.a4-net-graph > * { margin:0; border:0; box-shadow:none; } +.a4-net-graph .rf-region-label, .a4-net-graph .rf-screen-heading { display:none; } +/* Cards keep two lines; the lane names and the controls sit outside the cards. */ +.a4-net-graph .rf-n-node-label { display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; white-space:normal; overflow:hidden; } +.a4-net-graph .rf-n-lane-labels { pointer-events:none; } +@media (max-width:760px) { + .a4-net-head { flex-direction:column-reverse; align-items:flex-start; } +} +.a4-net-graph .rf-n-map-controls { top:20px; right:20px; bottom:auto; left:auto; gap:8px; padding:0; border:0; background:none; box-shadow:none; } +.a4-net-graph .rf-n-map-control { min-width:38px; height:38px; padding:0 10px; border:1px solid var(--qf-color-border-subtle); border-radius:6px; background:var(--qf-color-surface); color:var(--qf-color-fg-strong); } +.a4-net-graph [data-network-control="networkCentreOn"] { display:none; } + +/* Battle (page 17): one card, equal commands, preview before execute, folds after three and one. */ +.a4-bt { display:flex; flex-direction:column; gap:12px; max-width:720px; } +.a4-bt-card { gap:10px; padding:16px 20px; } +.a4-bt-head { display:flex; align-items:center; gap:10px; } +.a4-bt-head > .a4-link { margin-left:auto; } +.a4-bt-boss { font-size:18px; font-weight:650; color:var(--qf-color-fg-strong); } +.a4-bt-bar { display:flex; flex-direction:column; gap:4px; font-size:13px; color:var(--qf-color-fg-default); } +.a4-bt-bar-head { display:flex; justify-content:space-between; } +.a4-bt-bar-head > :last-child { font-variant-numeric:tabular-nums; color:var(--qf-color-fg-strong); } +.a4-bt-track { display:block; height:6px; border-radius:999px; background:var(--a4-soft); overflow:hidden; } +.a4-bt-fill { display:block; height:100%; border-radius:inherit; background:var(--qf-color-accent); } +.a4-bt-bar[data-tone="boss"] .a4-bt-fill { background:var(--qf-color-review); } +.a4-bt-bar[data-tone="mp"] .a4-bt-fill { background:var(--qf-color-agent); } +.a4-bt-statuses { display:flex; flex-wrap:wrap; gap:6px; } +.a4-bt-sub { margin:16px 0 0; font-size:15px; font-weight:600; color:var(--qf-color-fg-strong); } +.a4-bt-hand { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; } +.a4-bt-cmd { display:flex; flex-direction:column; align-items:flex-start; gap:2px; min-height:66px; padding:10px 12px; border:1px solid var(--qf-color-border-strong); border-radius:6px; background:var(--qf-color-surface); color:var(--qf-color-fg-strong); font:inherit; text-align:left; cursor:pointer; } +.a4-bt-cmd strong { font-size:14px; font-weight:600; } +.a4-bt-cmd .a4-meta { display:flex; gap:8px; font-size:12px; } +.a4-bt-cmd[data-short="true"] { border-style:dashed; border-color:var(--qf-color-border-subtle); color:var(--qf-color-fg-muted); } +.a4-bt-cmd[aria-pressed="true"] { border-color:var(--qf-color-accent); background:var(--qf-color-selected); color:var(--qf-color-accent); } +.a4-bt-cmd:disabled { cursor:default; } +.a4-bt-short { color:var(--qf-color-review); } +.a4-bt-preview .a4-bt-sub { margin:0 0 4px; font-size:14px; } +.a4-bt-change { display:grid; grid-template-columns:1fr auto 64px; gap:12px; align-items:baseline; padding:6px 0; font-size:13px; } +.a4-bt-change strong { font-variant-numeric:tabular-nums; color:var(--qf-color-fg-strong); } +.a4-bt-change .a4-meta { text-align:right; } +.a4-bt-rows { margin:0; padding:0; list-style:none; } +.a4-bt-row { display:flex; justify-content:space-between; align-items:center; gap:12px; width:100%; min-height:46px; padding:8px 0; border:0; border-bottom:1px solid var(--qf-color-border-subtle); background:none; color:var(--qf-color-fg-strong); font:inherit; font-size:14px; text-align:left; } +button.a4-bt-row { cursor:pointer; } +li.a4-bt-row > :first-child { color:var(--qf-color-fg-default); font-size:13px; } +.a4-bt-mp { color:var(--qf-color-agent); font-size:13px; font-weight:600; } +@media (max-width:760px) { .a4-bt-hand { grid-template-columns:repeat(2,minmax(0,1fr)); } } +.a4-bt-preview .a4-actions { flex-direction:row; justify-content:flex-start; gap:10px; } +.a4-bt-preview .a4-actions > .a4-button { flex:none; width:auto; min-height:44px; padding:0 18px; } + +/* Connections (page 17): attention first, chips not colour lines, the action on the row, details folded. */ +.a4-cn { display:flex; flex-direction:column; gap:12px; max-width:1000px; } +.a4-cn-split { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,416px); gap:24px; align-items:start; } +.a4-cn-group .a4-bt-sub { margin:4px 0 6px; } +.a4-cn-rows { display:flex; flex-direction:column; gap:2px; margin:0 0 12px; padding:0; list-style:none; } +.a4-cn-row { display:flex; align-items:center; gap:12px; padding:0 12px 0 0; border-radius:6px; } +.a4-cn-row[data-selected="true"] { background:var(--qf-color-selected); } +.a4-cn-row[data-muted="true"] .a4-cn-name { color:var(--qf-color-fg-muted); } +.a4-cn-open { flex:1; min-width:0; display:flex; flex-direction:column; gap:4px; padding:12px; border:0; background:none; color:inherit; font:inherit; text-align:left; cursor:pointer; } +.a4-cn-open .a4-meta { margin:0; } +.a4-cn-name { display:flex; align-items:center; gap:8px; font-size:14px; font-weight:600; color:var(--qf-color-fg-strong); } +.a4-cn-title h2 { margin:0; font-size:18px; font-weight:600; } +.a4-cn-detail { display:flex; flex-direction:column; gap:12px; } +.a4-cn-summary { margin:0; font-size:14px; color:var(--qf-color-fg-strong); } +.a4-cn-error { padding:10px 12px; border-radius:6px; background:var(--a4-warning-soft); color:var(--qf-color-review); font-size:13px; } +.a4-cn-detail > .a4-primary { min-height:44px; } +.a4-cn-facts, .a4-cn-auth { display:flex; flex-direction:column; gap:6px; font-size:13px; } +.a4-cn-fact { display:flex; justify-content:space-between; gap:12px; } +.a4-cn-fact > :last-child { color:var(--qf-color-fg-strong); text-align:right; overflow-wrap:anywhere; } +.a4-cn-auth { padding:4px 0 8px; } +.a4-cn-disconnect { align-self:flex-start; color:var(--qf-color-fg-muted); } +.a4-cn-preview { display:flex; flex-direction:column; gap:4px; font-size:13px; } +.a4-cn-confirm .a4-bt-sub { margin:0; font-size:15px; } +.a4-cn-confirm .a4-actions { flex-direction:row; gap:10px; } +.a4-cn-confirm .a4-actions > .a4-button { flex:none; width:auto; min-height:44px; padding:0 20px; } +@media (max-width:760px) { .a4-cn > .a4-net-switch { flex-wrap:wrap; } } + +/* Skills (page 17): endpoint with copy, one search field, one column of categories. */ +.a4-sk { display:flex; flex-direction:column; gap:12px; max-width:720px; } +.a4-sk-source { gap:10px; } +.a4-sk-source .a4-bt-head > :first-child { margin-right:auto; } +.a4-sk-source .a4-bt-head > .a4-link { margin-left:auto; } +.a4-sk-url { font-family:var(--qf-font-mono); font-size:13px; color:var(--qf-color-fg-default); overflow-wrap:anywhere; } +.a4-sk-search { display:flex; align-items:center; gap:8px; height:44px; padding:0 12px; border:1px solid var(--qf-color-border-strong); border-radius:6px; background:var(--qf-color-surface); color:var(--qf-color-fg-muted); } +.a4-sk-search .a4-sk-input { flex:1; padding:0; box-shadow:none; border-radius:0; min-width:0; height:100%; border:0; outline:0; background:none; color:var(--qf-color-fg-strong); font:inherit; font-size:14px; } +.a4-sk-search:focus-within { border-color:var(--qf-color-accent); box-shadow:0 0 0 2px var(--qf-color-selected); } +.a4-sk-groups, .a4-sk-tools { margin:0; padding:0; list-style:none; } +.a4-sk-group { border-bottom:1px solid var(--qf-color-border-subtle); } +.a4-sk-group-head { display:flex; align-items:center; gap:12px; width:100%; min-height:64px; padding:10px 0; border:0; background:none; color:inherit; font:inherit; text-align:left; cursor:pointer; } +.a4-sk-tools { display:flex; flex-direction:column; gap:14px; padding:4px 14px 16px; } +.a4-sk-tool { display:flex; flex-direction:column; gap:2px; font-size:13px; color:var(--qf-color-fg-default); } +.a4-sk-tool strong { display:flex; align-items:center; gap:8px; font-size:14px; font-weight:600; color:var(--qf-color-fg-strong); } +.a4-sk-tool code { font-family:var(--qf-font-mono); font-size:12px; } +.a4-sk-search input.a4-sk-input { appearance:none; height:42px; border:0 !important; outline:0; background:transparent !important; box-shadow:none !important; } diff --git a/interaction-lab/all-for-adhd/skills-view.ts b/interaction-lab/all-for-adhd/skills-view.ts new file mode 100644 index 0000000..2d75105 --- /dev/null +++ b/interaction-lab/all-for-adhd/skills-view.ts @@ -0,0 +1,69 @@ +/** + * Skills in All for ADHD's layout (Penpot page 17): the MCP endpoint with a + * copy button, one search field, one column of categories, and a mark only on + * a tool that cannot be used. Tool names and descriptions are the server's. + */ + +import { createElement, ChevronDown, ChevronRight, Search } from "lucide"; +import { el } from "../relay-forge/primitives/dom.ts"; +import type { SkillsModel, SkillsState } from "../relay-forge/screens/skills-model.ts"; +import { tx } from "./copy.ts"; + +export interface SkillsViewHost { + readonly authorized: number; + readonly rerender: () => void; + readonly announce: (message: string) => void; + readonly openMcpSettings: () => void; + readonly retry: () => void; +} + +// Category names in this product's words (task, hand-over, friends, battle). +const CATEGORY: Record readonly [string, string]> = { + "quest-management": () => [tx("タスクの管理"), tx("タスクの作成・更新・担当・進行を管理します。")], + "agent-relay": () => [tx("Agentとリレー"), tx("Agent、接続、担当、受け渡しを管理します。")], + "profile-social": () => [tx("プロフィールと交流"), tx("プロフィールとフレンドを管理します。")], + "battle-rewards": () => [tx("バトルと報酬"), tx("バトルと報酬の状態を確認・操作します。")], +}; +const icon = (node: typeof Search) => createElement(node, { width: 18, height: 18, "stroke-width": 1.75, "aria-hidden": "true" }); + +export function skillsView(model: SkillsModel, state: SkillsState, host: SkillsViewHost): HTMLElement { + const copy = el("button", { type: "button", class: "a4-link", disabled: !model.sourceUrl }, tx("URLをコピー")); + copy.addEventListener("click", () => { void navigator.clipboard?.writeText(model.sourceUrl).then(() => host.announce(tx("コピーしました")), () => host.announce(tx("コピーできませんでした"))); }); + const setup = el("button", { type: "button", class: "a4-link" }, tx("接続を設定")); + setup.addEventListener("click", host.openMcpSettings); + const source = el("section", { class: "a4-bill-card a4-sk-source" }, + el("div", { class: "a4-bt-head" }, el("strong", {}, model.sourceLabel), copy), + el("code", { class: "a4-sk-url" }, model.sourceUrl || "—"), + el("div", { class: "a4-bt-head" }, el("span", { class: "a4-meta" }, host.authorized === 0 ? tx("まだつながっているAgentはいません") : tx("{count}件のクライアントがつながっています", { count: host.authorized })), setup)); + + // One field; typing in Japanese waits for the composition to finish. + const input = el("input", { type: "search", class: "a4-sk-input", value: state.query, placeholder: tx("機能やツール名を検索"), "aria-label": tx("機能やツール名を検索") }); + const apply = () => { + state.query = input.value; host.rerender(); + requestAnimationFrame(() => { const next = document.querySelector(".a4-sk-input"); if (next) { next.focus(); next.setSelectionRange(next.value.length, next.value.length); } }); + }; + input.addEventListener("input", event => { if (!(event as InputEvent).isComposing) apply(); }); + input.addEventListener("compositionend", apply); + const search = el("label", { class: "a4-sk-search" }, icon(Search), input); + + let body: HTMLElement; + if (model.status === "loading") body = el("p", { class: "a4-meta", role: "status" }, tx("読み込んでいます")); + else if (model.status === "error") { + const again = el("button", { type: "button", class: "a4-link" }, tx("もう一度")); + again.addEventListener("click", host.retry); + body = el("div", { class: "a4-bill-alert", role: "alert" }, el("span", {}, tx("機能の一覧を読み込めませんでした。")), again); + } else if (!model.groups.length) body = el("p", { class: "a4-net-empty" }, model.query ? tx("「{query}」に合う機能はありません。", { query: model.query }) : tx("使える機能はまだありません。")); + else body = el("section", {}, + el("h2", { class: "a4-net-group-head" }, tx("使える機能"), el("span", { class: "a4-meta" }, tx("{count}件", { count: model.visibleToolCount }))), + el("ul", { class: "a4-sk-groups" }, ...model.groups.map(group => { + const open = model.query !== "" || state.expandedGroups[group.id] === true; + const head = el("button", { type: "button", class: "a4-sk-group-head", "aria-expanded": String(open) }, + el("span", { class: "a4-set-copy" }, el("strong", {}, CATEGORY[group.id]?.()[0] ?? group.title), el("small", {}, CATEGORY[group.id]?.()[1] ?? group.description)), + el("span", { class: "a4-meta" }, tx("{count}件", { count: group.tools.length })), icon(open ? ChevronDown : ChevronRight)); + head.addEventListener("click", () => { state.expandedGroups[group.id] = !open; host.rerender(); }); + return el("li", { class: "a4-sk-group" }, head, open ? el("ul", { class: "a4-sk-tools" }, ...group.tools.map(tool => el("li", { class: "a4-sk-tool" }, + el("strong", {}, tool.title, tool.availability === "unavailable" ? el("span", { class: "a4-net-chip", "data-tone": "review" }, tx("使えません")) : null), + tool.description ? el("span", {}, tool.description) : null, el("code", { class: "a4-meta" }, tool.name)))) : null); + }))); + return el("div", { class: "a4-sk" }, source, search, body); +} diff --git a/interaction-lab/relay-forge/screens/billing.ts b/interaction-lab/relay-forge/screens/billing.ts index 46024e9..f016f9c 100644 --- a/interaction-lab/relay-forge/screens/billing.ts +++ b/interaction-lab/relay-forge/screens/billing.ts @@ -375,5 +375,24 @@ export function createBillingUi(options: BillingUiOptions) { } return section; } - return { enter: () => { void load(); }, retry: () => { void load(true); }, render: billingRegion }; + // The same state and guarded actions for another renderer (All for ADHD draws its own layout). + const api = { + status: () => status, busy: () => busy, error: () => error, message: () => message, canWrite, agents, candidates, quests, + selectedIds: () => selectedIds ?? [], validFree: () => validFreeAgentSelection(selectedIds ?? [], agents()), + toggleFree: (id: string) => { selectedIds = (selectedIds ?? []).includes(id) ? selectedIds!.filter(value => value !== id) : [...selectedIds ?? [], id]; changed(); }, + saveAgents, purchasable: () => !!status && billingPurchaseAvailable(status), checkout, portal, + referralCode: () => referralCode, setReferral: (value: string) => { referralCode = value; purchaseConsent = false; }, + purchaseConsent: () => purchaseConsent, setPurchaseConsent: (value: boolean) => { purchaseConsent = value; changed(); }, + purchaseAttempt: () => purchaseAttempt, + ledger: () => ledger, ledgerMore: () => !!ledgerCursor && !ledgerError, ledgerError: () => ledgerError, loadLedger, entryLabel, + questId: () => questId, setQuest: (id: string) => { questId = id; resetQuote(); }, + candidateIds: () => candidateIds, toggleCandidate: (id: string) => { candidateIds = candidateIds.includes(id) ? candidateIds.filter(value => value !== id) : [...candidateIds, id]; resetQuote(); }, + consent: () => consent, setConsent: (value: boolean) => { consent = value; changed(); }, + allowedCandidates, quoteCurrent, quote: () => quote, estimate, execute, + decisionId: () => decisionId, decisionVersion: () => decisionVersion, decisionState: () => billingString(decision?.status), hasDecision: () => decision !== null, + applied: () => applied, suggestion, checkResult, apply, + clearDecision: () => { decisionId = ""; decisionVersion = ""; decision = null; decisionCandidateIds = []; applied = false; quote = null; consent = false; changed(); }, + contextText, + }; + return { enter: () => { void load(); }, retry: () => { void load(true); }, render: billingRegion, api }; } diff --git a/tests/all-for-adhd-capture.test.ts b/tests/all-for-adhd-capture.test.ts index 71ec77a..afe8d19 100644 --- a/tests/all-for-adhd-capture.test.ts +++ b/tests/all-for-adhd-capture.test.ts @@ -50,7 +50,7 @@ test("titles get an emoji from the dictionary in any locale, or none", () => { assert.equal(iconFor("이메일 답장"), "📧"); assert.equal(iconFor("Пробежка в парке"), "🏃"); assert.equal(iconFor("ほげほげ"), ""); - assert.deepEqual(iconChoices("散歩する", ["🎨"], 3), ["🚶", "🎨", "💊"]); + assert.deepEqual(iconChoices("散歩する", ["🎨"], 3), ["🚶", "🎨", "📝"]); }); test("short English keywords only match whole words", () => { diff --git a/tests/all-for-adhd-copy.test.ts b/tests/all-for-adhd-copy.test.ts index cf5cbc6..922f54c 100644 --- a/tests/all-for-adhd-copy.test.ts +++ b/tests/all-for-adhd-copy.test.ts @@ -5,7 +5,7 @@ import { COPY } from "../interaction-lab/all-for-adhd/copy.ts"; test("every tx() key in All for ADHD has all eight translations with matching placeholders", () => { const used = new Set(); - for (const file of ["app.ts", "main.ts", "model.ts", "images.ts"]) { + for (const file of ["app.ts", "main.ts", "model.ts", "images.ts", "forge.ts", "billing-view.ts", "network-view.ts", "battle-view.ts", "connections-view.ts", "skills-view.ts"]) { const source = readFileSync(new URL(`../interaction-lab/all-for-adhd/${file}`, import.meta.url), "utf8"); for (const match of source.matchAll(/tx\("((?:[^"\\]|\\.)*)"/g)) used.add(match[1]!); } diff --git a/tools/verify-quest-images.mts b/tools/verify-quest-images.mts index 5e8c3d8..aa43ea3 100644 --- a/tools/verify-quest-images.mts +++ b/tools/verify-quest-images.mts @@ -46,7 +46,7 @@ try { assert.equal(originalReads.length, 0, "The original is never prefetched"); const open = page.locator(".a4-image-row button").filter({ has: page.locator("img") }).first(); await open.click(); await page.locator("dialog.a4-image-viewer").waitFor(); - await page.locator("dialog.a4-image-viewer button").nth(1).click(); + await page.locator("dialog.a4-image-viewer .a4-image-viewer-head .a4-link").click(); await page.waitForFunction("document.querySelector('dialog img')?.naturalWidth === 1800"); await page.keyboard.press("Escape"); assert.equal(await open.evaluate(node => node.matches(":focus")), true); const overflow = await page.evaluate("document.documentElement.scrollWidth > innerWidth"); assert.equal(overflow, false); @@ -63,13 +63,14 @@ try { const originals: string[] = [], writes: string[] = []; page.on("request", request => { if (request.url().includes("variant=original")) originals.push(request.url()); if (!["GET", "HEAD"].includes(request.method())) writes.push(request.method()); }); await page.goto(`${origin}/tests/browser/quest-images.html?api=${encodeURIComponent(fixture.baseUrl)}&quest=${fixture.first.quest.id}&surface=decision&theme=dark&lang=ja#guild`); - const gallery = page.locator(".a4-decision .a4-images-gallery"); + const gallery = page.locator(".a4-dec .a4-images-gallery"); await gallery.locator("img").first().waitFor(); await page.waitForFunction("document.querySelector('.a4-images-gallery img')?.naturalWidth > 0"); assert.equal(await gallery.locator("img").count(), 8, "Requests without selected IDs show source attachments directly on the card"); - assert.equal(await page.locator(".a4-decision h2").textContent(), "Review first"); - const activity = page.locator(".a4-activity"); - await activity.last().waitFor(); + assert.equal(await page.locator(".a4-dec .a4-dec-title").textContent(), "Review first"); + // The rail (wide) and the fold (narrow) hold the same list; CSS shows one. + const activity = page.locator(".a4-activity-list").first().locator(".a4-activity"); + await activity.last().waitFor({ state: "attached" }); // Folded on narrow widths. const lines = await activity.locator("span:last-child").allTextContents(); assert.ok(lines[0]!.includes("「確認してほしい」にしました")); assert.ok(lines[1]!.includes("「確認してほしい」にしました"), "Stored relay events carry the state in details.assignee"); @@ -83,13 +84,13 @@ try { assert.equal(await gallery.locator("input").count(), 0); assert.equal(await gallery.getByRole("button", { name: "削除", exact: true }).count(), 0); assert.equal(await page.locator(".a4-sheet").count(), 0); - assert.equal(await page.locator('.a4-decision input[type="checkbox"]').isChecked(), false); + assert.equal(await page.locator('.a4-dec input[type="checkbox"]').isChecked(), false); assert.equal(originals.length, 0); await page.screenshot({ path: resolve(output, `inline-decision-${width}.png`), fullPage: true }); const open = gallery.locator("button").filter({ has: page.locator("img") }).first(); await open.click(); await page.locator("dialog.a4-image-viewer").waitFor(); await page.keyboard.press("Escape"); assert.equal(await open.evaluate(node => node.matches(":focus")), true); - assert.equal(await page.locator('.a4-decision input[type="checkbox"]').isChecked(), false); + assert.equal(await page.locator('.a4-dec input[type="checkbox"]').isChecked(), false); assert.equal(await page.evaluate("document.documentElement.scrollWidth > innerWidth"), false); assert.deepEqual(writes, []); assert.equal(originals.length, 0); checks.push(`${width}: actual AFA decision shows source attachments, one-tap viewer, focus restoration and no implicit approval/write`); @@ -112,7 +113,7 @@ try { const params = new URLSearchParams({ api: fixture.baseUrl, quest: fixture.first.quest.id, peer: peer.quest.id, surface: "decision", theme: "dark", lang: "ja", artifact }); params.append("peer", fixture.first.quest.id); await page.goto(`${appOrigin}/tests/browser/quest-images.html?${params}#guild`); - await page.locator(".a4-decision h2").filter({ hasText: "Review first" }).waitFor({ timeout: 10_000 }).catch(async error => { + await page.locator(".a4-dec .a4-dec-title").filter({ hasText: "Review first" }).waitFor({ timeout: 10_000 }).catch(async error => { console.log(JSON.stringify({ appOrigin, artifact, errors, body: (await page.locator("body").textContent())?.slice(0, 600) })); await page.screenshot({ path: resolve(output, `artifact-failure-${width}.png`), fullPage: true }); throw error; }); @@ -123,9 +124,9 @@ try { page.on("popup", popup => popups.push(popup.url())); await visit(`${appOrigin}/next/all-for-adhd/#today`); await page.getByRole("button", { name: "直してほしい", exact: true }).click(); - await page.locator(".a4-decision textarea").fill("Keep this reply and its image."); - await page.locator('.a4-decision input[type="checkbox"]').check(); - await page.locator('.a4-decision input[type="file"]').setInputFiles({ name: "unsent-response.png", mimeType: "image/png", buffer: png }); + await page.locator(".a4-dec textarea").fill("Keep this reply and its image."); + await page.locator('.a4-dec input[type="checkbox"]').check(); + await page.locator('.a4-dec input[type="file"]').setInputFiles({ name: "unsent-response.png", mimeType: "image/png", buffer: png }); const timeOrigin = await page.evaluate(() => performance.timeOrigin); navigation.length = 0; for (const modifiers of [{ ctrlKey: true }, { metaKey: true }, { shiftKey: true }, { altKey: true }, { button: 1 }]) { @@ -139,13 +140,13 @@ try { await resultLink.focus(); await page.keyboard.press("Enter"); await page.locator('.a4-main[data-view="plan"]').waitFor(); await page.locator('a[data-view="guild"]').click(); - assert.equal(await page.locator(".a4-decision h2").textContent(), "Review first"); - assert.equal(await page.locator(".a4-decision textarea").inputValue(), "Keep this reply and its image."); - assert.equal(await page.locator('.a4-decision input[type="checkbox"]').isChecked(), true); - assert.equal(await page.locator(".a4-decision .a4-image-row").filter({ hasText: "unsent-response.png" }).count(), 1); + assert.equal(await page.locator(".a4-dec .a4-dec-title").textContent(), "Review first"); + assert.equal(await page.locator(".a4-dec textarea").inputValue(), "Keep this reply and its image."); + assert.equal(await page.locator('.a4-dec input[type="checkbox"]').isChecked(), true); + assert.equal(await page.locator(".a4-dec .a4-image-row").filter({ hasText: "unsent-response.png" }).count(), 1); await page.screenshot({ path: resolve(output, `artifact-retained-${width}.png`), fullPage: true }); checks.push(`${width}: internal artifact keyboard navigation preserves selected decision, reply, confirmation and unsent image; modifiers stay native`); - await page.getByRole("button", { name: "元のタスクを見る", exact: true }).click(); + await page.getByRole("button", { name: "元のタスク", exact: true }).click(); await page.locator('.a4-sheet [name="title"]').fill("Unsaved task title"); await resultLink.evaluate(node => node.click()); await page.locator(".a4-discard-dialog").waitFor(); @@ -157,16 +158,16 @@ try { await page.getByRole("button", { name: "破棄する", exact: true }).click(); await page.locator('.a4-main[data-view="plan"]').waitFor(); await page.locator('a[data-view="guild"]').click(); - assert.equal(await page.locator(".a4-decision textarea").inputValue(), "Keep this reply and its image."); - assert.equal(await page.locator('.a4-decision input[type="checkbox"]').isChecked(), true); + assert.equal(await page.locator(".a4-dec textarea").inputValue(), "Keep this reply and its image."); + assert.equal(await page.locator('.a4-dec input[type="checkbox"]').isChecked(), true); assert.equal(await page.evaluate(() => performance.timeOrigin), timeOrigin); assert.deepEqual(navigation, []); assert.deepEqual(popups, []); assert.deepEqual(writes, []); checks.push(`${width}: internal artifact uses the draft discard/cancel guard without popup, document navigation, runtime restart or write`); await visit(`${appOrigin}/next/all-for-adhd/?quest=${peer.quest.id}#guild`); - await page.locator('.a4-decision input[type="checkbox"]').check(); + await page.locator('.a4-dec input[type="checkbox"]').check(); const peerTimeOrigin = await page.evaluate(() => performance.timeOrigin); navigation.length = 0; const firstUrl = page.url(); - await page.getByRole("button", { name: "元のタスクを見る", exact: true }).click(); + await page.getByRole("button", { name: "元のタスク", exact: true }).click(); await page.locator('.a4-sheet [name="title"]').fill("Keep the guarded Quest draft"); await resultLink.evaluate(node => node.click()); await page.locator(".a4-discard-dialog").waitFor(); @@ -176,30 +177,30 @@ try { assert.equal(await page.locator('.a4-sheet [name="title"]').inputValue(), "Keep the guarded Quest draft"); await resultLink.evaluate(node => node.click()); await page.getByRole("button", { name: "破棄する", exact: true }).click(); - assert.equal(await page.locator(".a4-decision h2").textContent(), "Review artifact-peer"); + assert.equal(await page.locator(".a4-dec .a4-dec-title").textContent(), "Review artifact-peer"); assert.equal(new URL(page.url()).searchParams.get("quest"), peer.quest.id); - assert.equal(await page.locator('.a4-decision input[type="checkbox"]').isChecked(), false); + assert.equal(await page.locator('.a4-dec input[type="checkbox"]').isChecked(), false); await page.goBack(); - await page.locator(".a4-decision h2").filter({ hasText: "Review first" }).waitFor(); + await page.locator(".a4-dec .a4-dec-title").filter({ hasText: "Review first" }).waitFor(); assert.equal(new URL(page.url()).searchParams.get("quest"), fixture.first.quest.id); - assert.equal(await page.locator(".a4-decision h2").textContent(), "Review first"); - assert.equal(await page.locator('.a4-decision input[type="checkbox"]').isChecked(), true); + assert.equal(await page.locator(".a4-dec .a4-dec-title").textContent(), "Review first"); + assert.equal(await page.locator('.a4-dec input[type="checkbox"]').isChecked(), true); await page.goForward(); - await page.locator(".a4-decision h2").filter({ hasText: "Review artifact-peer" }).waitFor(); + await page.locator(".a4-dec .a4-dec-title").filter({ hasText: "Review artifact-peer" }).waitFor(); assert.equal(new URL(page.url()).searchParams.get("quest"), peer.quest.id); - assert.equal(await page.locator('.a4-decision input[type="checkbox"]').isChecked(), false); + assert.equal(await page.locator('.a4-dec input[type="checkbox"]').isChecked(), false); await page.getByRole("button", { name: "次の判断へ", exact: true }).click(); - assert.equal(await page.locator(".a4-decision h2").textContent(), "Review first"); + assert.equal(await page.locator(".a4-dec .a4-dec-title").textContent(), "Review first"); assert.equal(new URL(page.url()).searchParams.get("quest"), fixture.first.quest.id); await page.goBack(); - await page.locator(".a4-decision h2").filter({ hasText: "Review artifact-peer" }).waitFor(); + await page.locator(".a4-dec .a4-dec-title").filter({ hasText: "Review artifact-peer" }).waitFor(); assert.equal(await page.evaluate(() => performance.timeOrigin), peerTimeOrigin); assert.deepEqual(navigation, []); assert.deepEqual(popups, []); checks.push(`${width}: quest-specific clicks/back/forward and next-decision selection keep URL, exact decision and per-Quest confirmation aligned without document navigation`); await page.reload(); - await page.locator(".a4-decision h2").filter({ hasText: "Review artifact-peer" }).waitFor(); + await page.locator(".a4-dec .a4-dec-title").filter({ hasText: "Review artifact-peer" }).waitFor(); assert.equal(new URL(page.url()).searchParams.get("quest"), peer.quest.id); - assert.ok((await page.locator(".a4-decision .a4-eyebrow").textContent())?.includes("/ 2")); + assert.ok((await page.locator(".a4-guild-at").textContent())?.includes("/ 2")); checks.push(`${width}: reloading a quest-specific artifact URL restores the targeted pending decision`); for (const artifact of [`${appOrigin}/next/all-for-adhd/#guild`, `${appOrigin}/next/all-for-adhd/?quest=${fixture.first.quest.id}#guild`, ...(appOrigin !== origin ? ["https://app.guilduo.com/#guild"] : [])]) { await visit(artifact); @@ -222,7 +223,7 @@ try { const popup = await popupPromise; await popup.waitForLoadState(); assert.equal(popup.url(), "https://example.test/result"); assert.equal(await popup.evaluate("window.opener === null"), true); - assert.equal(await page.locator(".a4-decision h2").textContent(), "Review first"); + assert.equal(await page.locator(".a4-dec .a4-dec-title").textContent(), "Review first"); assert.deepEqual(navigation, []); assert.deepEqual(writes, []); checks.push(`${width}: a real external click opens its own safe tab and leaves the AFA document intact`); await context.close(); @@ -235,7 +236,7 @@ try { await page.goto(`${origin}/tests/browser/quest-images.html?api=${encodeURIComponent(fixture.baseUrl)}&quest=${review.quest.id}&surface=decision&theme=light&lang=ja#guild`); await page.locator(".a4-images-gallery img").waitFor(); assert.equal(await page.locator(".a4-images-gallery img").count(), 1, "Explicit selection stays authoritative"); - assert.equal(await page.locator(".a4-decision .a4-images:not(.a4-images-gallery) img").count(), 0, "Incoming images do not duplicate into the reply draft"); + assert.equal(await page.locator(".a4-dec .a4-images:not(.a4-images-gallery) img").count(), 0, "Incoming images do not duplicate into the reply draft"); checks.push("explicit requested attachments stay selected and separate from the response draft"); await page.goto(`${origin}/tests/browser/quest-images.html?api=${encodeURIComponent(fixture.baseUrl)}&quest=${review.quest.id}&context=response`); await page.locator(".a4-images input[type=file]").setInputFiles({ name: "human-correction.png", mimeType: "image/png", buffer: png }); @@ -251,7 +252,7 @@ try { await agentPage.goto(`${origin}/tests/browser/quest-images.html?api=${encodeURIComponent(fixture.baseUrl)}&quest=${source.quest.id}&surface=decision&theme=dark&lang=ja#guild`); await agentPage.locator(".a4-images-gallery img").first().waitFor(); assert.equal(await agentPage.locator(".a4-images-gallery img").count(), 8, "Ordinary Agent reports show references/results and exclude response images"); - assert.equal(await agentPage.locator('.a4-decision input[type="file"]').count(), 0); + assert.equal(await agentPage.locator('.a4-dec input[type="file"]').count(), 0); checks.push("ordinary Agent result cards also display the inline read-only gallery"); await fixture.web(`/v1/quest-images/${images.images[7]!.id}`, "DELETE", { dryRun: false }); await agentPage.evaluate("window.dispatchEvent(new Event('focus'))"); @@ -273,13 +274,13 @@ try { const appContext = await browser.newContext({ viewport: { width: 390, height: 844 }, locale: "ja-JP" }); const app = await appContext.newPage(); app.on("pageerror", error => errors.push(error.message)); await app.goto(`${origin}/interaction-lab/all-for-adhd/?demo`); - await app.locator(".a4-focus-title button").first().click(); - await app.locator('.a4-sheet summary').filter({ hasText: "繰り返し" }).click(); + await app.locator(".a4-focus-title .a4-row-title").first().click(); + await app.locator(".a4-sheet-chips .a4-chip").first().click(); // Repeat lives in the date pane. await app.getByRole("combobox", { name: "繰り返し", exact: true }).selectOption("daily"); await app.locator('.a4-sheet button[form="a4-sheet-form"]').click(); await app.locator(".a4-sheet").waitFor({ state: "detached" }); - await app.locator(".a4-focus-title button").first().click(); - await app.locator('.a4-sheet summary').filter({ hasText: "繰り返し" }).click(); + await app.locator(".a4-focus-title .a4-row-title").first().click(); + await app.locator(".a4-sheet-chips .a4-chip").first().click(); // Repeat lives in the date pane. assert.equal(await app.getByRole("combobox", { name: "繰り返し", exact: true }).inputValue(), "daily"); await app.screenshot({ path: resolve(output, "afa-detail-390.png"), fullPage: true }); checks.push("actual AFA task detail saves and restores the recurring value without losing the sheet");