feat(FN-3027): add AgentLogViewer, NewAgentDialog improvements, and AgentDe
This merge adds research settings to the settings modal and dashboard (FN-2839, FN-3029), implements a dashboard font scale setting with persistence and documentation (FN-3027), refactors AgentDetailView with a new AgentLogViewer CSS module (FN-2839), and improves NewAgentDialog with custom model dr Fusion-Task-Id: FN-3027
This commit is contained in:
@@ -172,7 +172,7 @@ function AppInner() {
|
||||
const effectiveProjects = isRemote && remoteData.projects.length > 0 ? remoteData.projects : projects;
|
||||
|
||||
// Theme management - required before useViewState
|
||||
const { themeMode, colorTheme, setThemeMode, setColorTheme } = useTheme();
|
||||
const { themeMode, colorTheme, dashboardFontScalePct, setThemeMode, setColorTheme, setDashboardFontScalePct } = useTheme();
|
||||
|
||||
// Background AI sessions - required before useModalManager
|
||||
const { sessions: bgSessions, generating: bgGenerating, needsInput: bgNeedsInput, planningSessions: bgPlanningSessions, dismissSession: bgDismiss } = useBackgroundSessions(currentProject?.id);
|
||||
@@ -1046,7 +1046,7 @@ function AppInner() {
|
||||
}}
|
||||
taskOperations={{ moveTask, deleteTask, mergeTask, retryTask, resetTask, duplicateTask }}
|
||||
deepLink={{ handleDetailClose }}
|
||||
settings={{ prAuthAvailable, themeMode, colorTheme, setThemeMode, setColorTheme }}
|
||||
settings={{ prAuthAvailable, themeMode, colorTheme, dashboardFontScalePct, setThemeMode, setColorTheme, setDashboardFontScalePct }}
|
||||
onSettingsClose={() => {
|
||||
modalManager.closeSettings();
|
||||
setResearchReadinessVersion((current) => current + 1);
|
||||
|
||||
@@ -66,8 +66,10 @@ interface AppModalsProps {
|
||||
prAuthAvailable: boolean;
|
||||
themeMode: ThemeMode;
|
||||
colorTheme: ColorTheme;
|
||||
dashboardFontScalePct: number;
|
||||
setThemeMode: (mode: ThemeMode) => void;
|
||||
setColorTheme: (theme: ColorTheme) => void;
|
||||
setDashboardFontScalePct: (scalePct: number) => void;
|
||||
};
|
||||
/** Optional override for the settings modal close handler. When provided, this is called instead of modalManager.closeSettings. */
|
||||
onSettingsClose?: () => void;
|
||||
@@ -187,6 +189,8 @@ export function AppModals({
|
||||
colorTheme={settings.colorTheme}
|
||||
onThemeModeChange={settings.setThemeMode}
|
||||
onColorThemeChange={settings.setColorTheme}
|
||||
dashboardFontScalePct={settings.dashboardFontScalePct}
|
||||
onDashboardFontScaleChange={settings.setDashboardFontScalePct}
|
||||
onReopenOnboarding={onReopenOnboarding}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
@@ -618,6 +618,40 @@ html .column.drag-over * {
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.theme-font-size-toggle {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.theme-font-size-btn {
|
||||
align-items: center;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
justify-content: center;
|
||||
min-height: calc(var(--space-2xl) + var(--space-sm));
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.theme-font-size-btn:hover {
|
||||
border-color: var(--todo);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.theme-font-size-btn.active {
|
||||
background: color-mix(in srgb, var(--todo) 14%, transparent);
|
||||
border-color: var(--todo);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.theme-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
@@ -1276,4 +1310,8 @@ html .column.drag-over * {
|
||||
.theme-selector {
|
||||
padding: 0 14px 14px;
|
||||
}
|
||||
|
||||
.theme-font-size-toggle {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,26 @@
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* Always-mounted invisible input. Focused inside the FAB click gesture
|
||||
to claim the iOS soft keyboard before the real composer input renders.
|
||||
Must be focusable (no `display: none`, no `visibility: hidden`) and big
|
||||
enough that iOS treats focus as a real intent — so we tuck it offscreen
|
||||
instead of hiding it. `font-size: 16px` defeats the iOS focus-zoom. */
|
||||
.quick-chat-stealth-input {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
font-size: 16px;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
/* Position set via inline style from useDraggable */
|
||||
.quick-chat-panel {
|
||||
--quick-chat-min-width: 280px;
|
||||
@@ -210,6 +230,13 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Icon-only fallback when the model name is too long for the header. */
|
||||
.quick-chat-model-tag--icon {
|
||||
max-width: none;
|
||||
padding: var(--space-xs);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.quick-chat-panel-header-actions {
|
||||
--quick-chat-header-control-size: calc(var(--space-lg) + var(--space-sm) + var(--space-xs));
|
||||
|
||||
@@ -610,13 +637,19 @@
|
||||
/* === Quick Chat Mobile (FN: full-screen) =================================== */
|
||||
@media (max-width: 768px) {
|
||||
.quick-chat-panel {
|
||||
/* Full-screen sheet that ignores drag position on mobile. The inline
|
||||
style from useDraggable supplies left/top, but our overrides win via
|
||||
!important so the user sees a proper modal-style sheet. */
|
||||
/* Full-screen sheet that ignores drag position on mobile. The
|
||||
JSX-level style only emits right/bottom on desktop now, but we
|
||||
still pin every edge with !important here so any stray inline
|
||||
value (or a future regression) cannot pull the panel off-screen.
|
||||
`top: 0` is intentional — when the iOS keyboard opens we just
|
||||
shrink height (via --vv-height), the panel does not translate
|
||||
so the header stays in place. */
|
||||
position: fixed !important;
|
||||
inset: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
top: 0 !important;
|
||||
bottom: 0 !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
height: 100dvh !important;
|
||||
|
||||
@@ -3,10 +3,10 @@ import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
@@ -16,12 +16,14 @@ import type { Components } from "react-markdown";
|
||||
import { Eye, EyeOff, MessageSquare, Paperclip, Plus, Send, Square, Wrench, X } from "lucide-react";
|
||||
import { fetchModels, type Agent, type ModelInfo } from "../api";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import { AgentMentionPopup } from "./AgentMentionPopup";
|
||||
import { FN_AGENT_ID, useQuickChat, type ChatMessageInfo, type ToolCallInfo } from "../hooks/useQuickChat";
|
||||
import { useAgents } from "../hooks/useAgents";
|
||||
import { FileMentionPopup } from "./FileMentionPopup";
|
||||
import { useFileMention } from "../hooks/useFileMention";
|
||||
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||||
import { useViewportMode } from "../hooks/useViewportMode";
|
||||
|
||||
interface PendingAttachment {
|
||||
file: File;
|
||||
@@ -800,15 +802,13 @@ export function QuickChatFAB({
|
||||
}
|
||||
: setInternalOpen;
|
||||
|
||||
const { keyboardOverlap, viewportHeight, keyboardOpen } = useMobileKeyboard({ enabled: isOpen });
|
||||
|
||||
const keyboardPanelStyle: CSSProperties =
|
||||
keyboardOpen
|
||||
? ({
|
||||
"--keyboard-overlap": `${keyboardOverlap}px`,
|
||||
...(viewportHeight !== null ? { "--vv-height": `${viewportHeight}px` } : {}),
|
||||
} as CSSProperties)
|
||||
: {};
|
||||
// We still consume keyboardOpen for layout decisions outside the panel,
|
||||
// but the high-frequency --vv-offset-top / --vv-height tracking is set
|
||||
// directly on the panel DOM in a layout effect below — going through
|
||||
// React state introduces a per-event reconciliation lag that the human
|
||||
// eye reads as jank while the iOS keyboard is animating in.
|
||||
useMobileKeyboard({ enabled: isOpen });
|
||||
const viewportMode = useViewportMode();
|
||||
|
||||
const [chatMode, setChatMode] = useState<"agent" | "model">("agent");
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string>("");
|
||||
@@ -894,6 +894,43 @@ export function QuickChatFAB({
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const pendingAttachmentsRef = useRef<PendingAttachment[]>([]);
|
||||
const shouldAutoFocusComposerRef = useRef(false);
|
||||
// Always-mounted offscreen input used to claim the iOS soft keyboard
|
||||
// synchronously inside the FAB click gesture, before the real composer
|
||||
// input has rendered (or while it is still `disabled` waiting for the
|
||||
// session). Focus is transferred to the real input once it is enabled —
|
||||
// iOS keeps the keyboard up across that transfer.
|
||||
const stealthInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// Mirror visualViewport.height onto the panel as --vv-height directly,
|
||||
// bypassing React state. The panel just shrinks when the iOS keyboard
|
||||
// opens — top stays at 0 so the header remains visible.
|
||||
useLayoutEffect(() => {
|
||||
if (!isOpen) return;
|
||||
if (typeof window === "undefined" || !window.visualViewport) return;
|
||||
const panel = panelRef.current;
|
||||
if (!panel) return;
|
||||
|
||||
const vv = window.visualViewport;
|
||||
let frame = 0;
|
||||
const apply = () => {
|
||||
frame = 0;
|
||||
panel.style.setProperty("--vv-height", `${vv.height}px`);
|
||||
panel.style.setProperty("--vv-offset-top", `${vv.offsetTop || 0}px`);
|
||||
};
|
||||
const schedule = () => {
|
||||
if (frame) return;
|
||||
frame = requestAnimationFrame(apply);
|
||||
};
|
||||
|
||||
apply();
|
||||
vv.addEventListener("resize", schedule);
|
||||
vv.addEventListener("scroll", schedule);
|
||||
return () => {
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
vv.removeEventListener("resize", schedule);
|
||||
vv.removeEventListener("scroll", schedule);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const resolvedModelSelection = selectedModel || configuredDefaultModelSelection;
|
||||
|
||||
@@ -1082,8 +1119,18 @@ export function QuickChatFAB({
|
||||
const activeElement = document.activeElement;
|
||||
const panelContainsFocus = activeElement ? panelRef.current?.contains(activeElement) : false;
|
||||
const isBodyFocused = activeElement === document.body;
|
||||
const stealthIsFocused = activeElement === stealthInputRef.current;
|
||||
|
||||
if (!panelContainsFocus && !isBodyFocused) {
|
||||
if (!panelContainsFocus && !isBodyFocused && !stealthIsFocused) {
|
||||
shouldAutoFocusComposerRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// When the stealth input is currently holding the iOS keyboard, transfer
|
||||
// focus synchronously — going through requestAnimationFrame breaks the
|
||||
// keyboard handoff on Safari and the keyboard dismisses.
|
||||
if (stealthIsFocused) {
|
||||
input.focus({ preventScroll: true });
|
||||
shouldAutoFocusComposerRef.current = false;
|
||||
return;
|
||||
}
|
||||
@@ -1522,11 +1569,32 @@ export function QuickChatFAB({
|
||||
didDragRef.current = false;
|
||||
return;
|
||||
}
|
||||
setIsOpen((prev) => !prev);
|
||||
}, [setIsOpen]);
|
||||
if (isOpen) {
|
||||
setIsOpen(false);
|
||||
return;
|
||||
}
|
||||
// iOS only opens the soft keyboard from a focus() that runs while
|
||||
// the originating user-gesture is still active, AND the focused
|
||||
// element must not be `disabled`. The real composer input renders
|
||||
// disabled until the chat session is created, so we focus an
|
||||
// always-mounted stealth input here to claim the keyboard now; the
|
||||
// auto-focus effect below transfers focus to the real input once
|
||||
// it is enabled, which keeps the keyboard up.
|
||||
if (typeof window !== "undefined" && window.innerWidth <= QUICK_CHAT_DESKTOP_BREAKPOINT) {
|
||||
stealthInputRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
setIsOpen(true);
|
||||
}, [isOpen, setIsOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={stealthInputRef}
|
||||
type="text"
|
||||
className="quick-chat-stealth-input"
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
{showFAB && (
|
||||
<button
|
||||
ref={fabRef}
|
||||
@@ -1551,10 +1619,14 @@ export function QuickChatFAB({
|
||||
ref={panelRef}
|
||||
data-testid="quick-chat-panel"
|
||||
style={{
|
||||
right: position.x + anchorOffset.right,
|
||||
bottom: panelY + anchorOffset.bottom,
|
||||
...(shouldApplyDesktopPanelSize ? { width: panelSize.width, height: panelSize.height } : {}),
|
||||
...keyboardPanelStyle,
|
||||
...(shouldApplyDesktopPanelSize
|
||||
? {
|
||||
right: position.x + anchorOffset.right,
|
||||
bottom: panelY + anchorOffset.bottom,
|
||||
width: panelSize.width,
|
||||
height: panelSize.height,
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
{shouldApplyDesktopPanelSize && (
|
||||
@@ -1635,11 +1707,31 @@ export function QuickChatFAB({
|
||||
<div className="quick-chat-panel-header">
|
||||
<div className="quick-chat-panel-title-wrap">
|
||||
<h3>Quick Chat</h3>
|
||||
{chatMode === "model" && selectedModelTag && (
|
||||
<span className="quick-chat-model-tag" data-testid="quick-chat-model-tag" title={selectedModelTag}>
|
||||
{selectedModelTag}
|
||||
</span>
|
||||
)}
|
||||
{chatMode === "model" && selectedModelTag && (() => {
|
||||
const provider =
|
||||
selectedModelInfo?.provider ?? parsedModelSelection?.modelProvider ?? "";
|
||||
// On mobile the header pill is squeezed by mode toggle + new-chat
|
||||
// + close buttons, so swap a long model name for the provider
|
||||
// icon to keep the title row tidy.
|
||||
const tagTooLong = viewportMode === "mobile" && selectedModelTag.length > 12;
|
||||
if (tagTooLong && provider) {
|
||||
return (
|
||||
<span
|
||||
className="quick-chat-model-tag quick-chat-model-tag--icon"
|
||||
data-testid="quick-chat-model-tag"
|
||||
title={selectedModelTag}
|
||||
aria-label={selectedModelTag}
|
||||
>
|
||||
<ProviderIcon provider={provider} size="sm" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="quick-chat-model-tag" data-testid="quick-chat-model-tag" title={selectedModelTag}>
|
||||
{selectedModelTag}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div className="quick-chat-panel-header-actions">
|
||||
{agents.length > 0 && (
|
||||
|
||||
@@ -305,6 +305,10 @@ interface SettingsModalProps {
|
||||
onThemeModeChange?: (mode: ThemeMode) => void;
|
||||
/** Called when color theme changes */
|
||||
onColorThemeChange?: (theme: ColorTheme) => void;
|
||||
/** Current dashboard font scale percentage */
|
||||
dashboardFontScalePct?: number;
|
||||
/** Called when dashboard font scale changes */
|
||||
onDashboardFontScaleChange?: (scalePct: number) => void;
|
||||
/** Optional callback when user wants to reopen the onboarding guide */
|
||||
onReopenOnboarding?: () => void;
|
||||
}
|
||||
@@ -318,6 +322,8 @@ export function SettingsModal({
|
||||
colorTheme = "default",
|
||||
onThemeModeChange,
|
||||
onColorThemeChange,
|
||||
dashboardFontScalePct = 100,
|
||||
onDashboardFontScaleChange,
|
||||
onReopenOnboarding,
|
||||
}: SettingsModalProps) {
|
||||
const { confirm } = useConfirm();
|
||||
@@ -2539,6 +2545,7 @@ export function SettingsModal({
|
||||
<ThemeSelector
|
||||
themeMode={themeMode}
|
||||
colorTheme={colorTheme}
|
||||
dashboardFontScalePct={dashboardFontScalePct}
|
||||
onThemeModeChange={(mode) => {
|
||||
setForm((f) => ({ ...f, themeMode: mode }));
|
||||
onThemeModeChange?.(mode);
|
||||
@@ -2547,6 +2554,10 @@ export function SettingsModal({
|
||||
setForm((f) => ({ ...f, colorTheme: theme }));
|
||||
onColorThemeChange?.(theme);
|
||||
}}
|
||||
onDashboardFontScaleChange={(scalePct) => {
|
||||
setForm((f) => ({ ...f, dashboardFontScalePct: scalePct }));
|
||||
onDashboardFontScaleChange?.(scalePct);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -140,6 +140,34 @@ function getEndToEndDurationMs(task: Task, nowMs: number): number | null {
|
||||
return Math.max(0, endMs - startedMs);
|
||||
}
|
||||
|
||||
function getInReviewCompletionMs(task: Task): number | null {
|
||||
return task.column === "done" ? getDoneCompletionMs(task) : null;
|
||||
}
|
||||
|
||||
function getMergeElapsedMs(task: Task, nowMs: number): number | null {
|
||||
const mergeStartedMs = parseTimestampToMs(task.updatedAt);
|
||||
if (mergeStartedMs == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.max(0, nowMs - mergeStartedMs);
|
||||
}
|
||||
|
||||
function getActiveMergeTotalMs(task: Task, nowMs: number): number | null {
|
||||
const endToEndMs = getEndToEndDurationMs(task, nowMs);
|
||||
if (endToEndMs != null) {
|
||||
return endToEndMs;
|
||||
}
|
||||
|
||||
const mergeElapsedMs = getMergeElapsedMs(task, nowMs);
|
||||
const instrumentedMs = getInstrumentedDurationMs(task, nowMs);
|
||||
if (instrumentedMs != null) {
|
||||
return instrumentedMs + (mergeElapsedMs ?? 0);
|
||||
}
|
||||
|
||||
return mergeElapsedMs;
|
||||
}
|
||||
|
||||
// Mirrors summarizeWorkflowTiming in TaskTokenStatsPanel: completed steps use
|
||||
// completedAt-startedAt; in-progress steps contribute live elapsed (now-startedAt).
|
||||
function getWorkflowRuntimeMs(task: Task, nowMs: number): number | null {
|
||||
@@ -711,7 +739,7 @@ function TaskCardComponent({
|
||||
|
||||
const merging = task.status != null && ACTIVE_MERGE_STATUSES.has(task.status);
|
||||
|
||||
if (!merging && task.column === "in-progress") {
|
||||
if (task.column === "in-progress") {
|
||||
const endToEndMs = getEndToEndDurationMs(task, Date.now());
|
||||
const elapsedMs = getInProgressElapsedMs(task, Date.now());
|
||||
const instrumentedMs = getInstrumentedDurationMs(task, Date.now());
|
||||
@@ -741,20 +769,23 @@ function TaskCardComponent({
|
||||
return null;
|
||||
}
|
||||
|
||||
// While a merge is actively running, the per-step instrumented duration
|
||||
// is frozen (the merge phase isn't tracked as a workflow step). Show
|
||||
// live elapsed since `updatedAt` — which the merger sets when it flips
|
||||
// status to "merging" — so stuck merges don't appear stuck at "3m".
|
||||
// While a merge is actively running, continue showing live end-to-end
|
||||
// execution time. For legacy tasks without executionStartedAt, fall back
|
||||
// to instrumented runtime plus live merge-phase elapsed since `updatedAt`.
|
||||
if (task.status != null && ACTIVE_MERGE_STATUSES.has(task.status)) {
|
||||
const startedMs = parseTimestampToMs(task.updatedAt);
|
||||
if (startedMs != null) {
|
||||
const elapsedMs = Math.max(0, timeIndicatorNowMs - startedMs);
|
||||
const elapsedLabel = formatElapsedDuration(elapsedMs);
|
||||
const totalMs = getActiveMergeTotalMs(task, timeIndicatorNowMs);
|
||||
if (totalMs != null) {
|
||||
const elapsedLabel = formatElapsedDurationDone(totalMs);
|
||||
if (elapsedLabel) {
|
||||
const mergeElapsedMs = getMergeElapsedMs(task, timeIndicatorNowMs);
|
||||
const mergeLabel = mergeElapsedMs == null ? null : formatElapsedDuration(mergeElapsedMs);
|
||||
const title = mergeLabel
|
||||
? `Execution time ${elapsedLabel}. Merge phase ${mergeLabel}`
|
||||
: `Execution time ${elapsedLabel}. Merging`;
|
||||
return {
|
||||
label: elapsedLabel,
|
||||
title: `Merging ${elapsedLabel}`,
|
||||
ariaLabel: `Merging ${elapsedLabel}`,
|
||||
title,
|
||||
ariaLabel: title,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -798,7 +829,7 @@ function TaskCardComponent({
|
||||
return null;
|
||||
}
|
||||
|
||||
const completionMs = getDoneCompletionMs(task);
|
||||
const completionMs = getInReviewCompletionMs(task);
|
||||
if (completionMs == null) {
|
||||
return {
|
||||
label: elapsedLabel,
|
||||
|
||||
@@ -5,8 +5,10 @@ import type { ThemeMode, ColorTheme } from "@fusion/core";
|
||||
interface ThemeSelectorProps {
|
||||
themeMode: ThemeMode;
|
||||
colorTheme: ColorTheme;
|
||||
dashboardFontScalePct?: number;
|
||||
onThemeModeChange: (mode: ThemeMode) => void;
|
||||
onColorThemeChange: (theme: ColorTheme) => void;
|
||||
onDashboardFontScaleChange?: (scalePct: number) => void;
|
||||
}
|
||||
|
||||
const THEME_MODES: { value: ThemeMode; label: string; icon: typeof Sun }[] = [
|
||||
@@ -15,6 +17,13 @@ const THEME_MODES: { value: ThemeMode; label: string; icon: typeof Sun }[] = [
|
||||
{ value: "system", label: "System", icon: Monitor },
|
||||
];
|
||||
|
||||
const FONT_SCALE_OPTIONS = [
|
||||
{ value: 90, label: "Small" },
|
||||
{ value: 100, label: "Default" },
|
||||
{ value: 110, label: "Large" },
|
||||
{ value: 120, label: "Largest" },
|
||||
] as const;
|
||||
|
||||
const COLOR_THEMES: { value: ColorTheme; label: string; className: string }[] = [
|
||||
{ value: "default", label: "Default", className: "theme-swatch-default" },
|
||||
{ value: "ocean", label: "Ocean", className: "theme-swatch-ocean" },
|
||||
@@ -78,13 +87,16 @@ const COLOR_THEMES: { value: ColorTheme; label: string; className: string }[] =
|
||||
export function ThemeSelector({
|
||||
themeMode,
|
||||
colorTheme,
|
||||
dashboardFontScalePct = 100,
|
||||
onThemeModeChange,
|
||||
onColorThemeChange,
|
||||
onDashboardFontScaleChange = () => {},
|
||||
}: ThemeSelectorProps) {
|
||||
const handleReset = useCallback(() => {
|
||||
onThemeModeChange("dark");
|
||||
onColorThemeChange("default");
|
||||
}, [onThemeModeChange, onColorThemeChange]);
|
||||
onDashboardFontScaleChange(100);
|
||||
}, [onThemeModeChange, onColorThemeChange, onDashboardFontScaleChange]);
|
||||
|
||||
return (
|
||||
<div className="theme-selector">
|
||||
@@ -126,6 +138,20 @@ export function ThemeSelector({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="theme-section-title">Font Size</div>
|
||||
<div className="theme-font-size-toggle" role="radiogroup" aria-label="Dashboard font size">
|
||||
{FONT_SCALE_OPTIONS.map(({ value, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
className={`theme-font-size-btn${dashboardFontScalePct === value ? " active" : ""}`}
|
||||
onClick={() => onDashboardFontScaleChange(value)}
|
||||
aria-pressed={dashboardFontScalePct === value}
|
||||
>
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Color Theme Grid */}
|
||||
<div className="theme-section-title">Color Theme</div>
|
||||
<div className="theme-grid" role="radiogroup" aria-label="Color theme">
|
||||
|
||||
@@ -2053,7 +2053,7 @@ describe("QuickChatFAB", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("sets keyboard overlap CSS variable when mobile viewport shrinks", async () => {
|
||||
it("mirrors --vv-height onto the panel when the mobile viewport shrinks", async () => {
|
||||
const { listeners, mockVV } = mockMobileVisualViewport({
|
||||
innerHeight: 844,
|
||||
vvHeight: 844,
|
||||
@@ -2062,7 +2062,9 @@ describe("QuickChatFAB", () => {
|
||||
render(<QuickChatFAB addToast={addToast} open={true} onOpenChange={vi.fn()} />);
|
||||
|
||||
const panel = await screen.findByTestId("quick-chat-panel");
|
||||
expect(panel.style.getPropertyValue("--keyboard-overlap")).toBe("");
|
||||
// Panel layout effect runs on first render even with no keyboard, so
|
||||
// --vv-height starts mirroring the current visual viewport.
|
||||
expect(panel.style.getPropertyValue("--vv-height")).toBe("844px");
|
||||
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
value: 560,
|
||||
@@ -2080,12 +2082,11 @@ describe("QuickChatFAB", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(panel.style.getPropertyValue("--keyboard-overlap")).toBe("284px");
|
||||
expect(panel.style.getPropertyValue("--vv-height")).toBe("560px");
|
||||
});
|
||||
});
|
||||
|
||||
it("applies --vv-height when keyboard opens with zero overlap (iOS last-resort signal)", async () => {
|
||||
it("applies --vv-height for the iOS last-resort signal (offsetTop nonzero, height shrunk)", async () => {
|
||||
const { listeners, mockVV } = mockMobileVisualViewport({
|
||||
innerHeight: 800,
|
||||
vvHeight: 800,
|
||||
@@ -2094,17 +2095,8 @@ describe("QuickChatFAB", () => {
|
||||
render(<QuickChatFAB addToast={addToast} open={true} onOpenChange={vi.fn()} />);
|
||||
|
||||
const panel = await screen.findByTestId("quick-chat-panel");
|
||||
expect(panel.style.getPropertyValue("--vv-height")).toBe("");
|
||||
expect(panel.style.getPropertyValue("--vv-height")).toBe("800px");
|
||||
|
||||
// Focus the input so the hook treats the active element as
|
||||
// keyboard-focusable — this is what unlocks the iOS last-resort branch
|
||||
// where viewport shrinks but offsetTop+height closes the gap.
|
||||
const input = await screen.findByTestId("quick-chat-input") as HTMLTextAreaElement;
|
||||
input.focus();
|
||||
|
||||
// chromeOverlap and gap both collapse to 0 (offsetTop + height ==
|
||||
// innerHeight == baseline), but baselineHeight - vv.height = 16 trips
|
||||
// the "viewport shrank" branch with overlap=0, keyboardOpen=true.
|
||||
Object.defineProperty(mockVV, "height", { value: 784, writable: true, configurable: true });
|
||||
Object.defineProperty(mockVV, "offsetTop", { value: 16, writable: true, configurable: true });
|
||||
|
||||
@@ -2112,15 +2104,12 @@ describe("QuickChatFAB", () => {
|
||||
for (const cb of listeners.resize) cb();
|
||||
});
|
||||
|
||||
// Panel style is gated on keyboardOpen, not keyboardOverlap > 0.
|
||||
// Regression guard for the gating change in QuickChatFAB.tsx:805.
|
||||
await waitFor(() => {
|
||||
expect(panel.style.getPropertyValue("--keyboard-overlap")).toBe("0px");
|
||||
expect(panel.style.getPropertyValue("--vv-height")).toBe("784px");
|
||||
});
|
||||
});
|
||||
|
||||
it("clears keyboard overlap CSS variable when keyboard closes", async () => {
|
||||
it("updates --vv-height when the keyboard collapses back to baseline", async () => {
|
||||
const { listeners, mockVV } = mockMobileVisualViewport({
|
||||
innerHeight: 800,
|
||||
vvHeight: 600,
|
||||
@@ -2131,7 +2120,7 @@ describe("QuickChatFAB", () => {
|
||||
const panel = await screen.findByTestId("quick-chat-panel");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(panel.style.getPropertyValue("--keyboard-overlap")).toBe("200px");
|
||||
expect(panel.style.getPropertyValue("--vv-height")).toBe("600px");
|
||||
});
|
||||
|
||||
Object.defineProperty(mockVV, "height", {
|
||||
@@ -2145,8 +2134,7 @@ describe("QuickChatFAB", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(panel.style.getPropertyValue("--keyboard-overlap")).toBe("");
|
||||
expect(panel.style.getPropertyValue("--vv-height")).toBe("");
|
||||
expect(panel.style.getPropertyValue("--vv-height")).toBe("800px");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -410,6 +410,38 @@ describe("SettingsModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Appearance", () => {
|
||||
it("renders dashboard font size options with saved value", async () => {
|
||||
const onDashboardFontScaleChange = vi.fn();
|
||||
renderModal({ dashboardFontScalePct: 110, onDashboardFontScaleChange });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /Appearance/ }));
|
||||
|
||||
const largeButton = screen.getByRole("button", { name: "Large" });
|
||||
expect(largeButton).toHaveAttribute("aria-pressed", "true");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Small" }));
|
||||
expect(onDashboardFontScaleChange).toHaveBeenCalledWith(90);
|
||||
});
|
||||
|
||||
it("saves dashboard font scale to global settings", async () => {
|
||||
renderModal({ dashboardFontScalePct: 100 });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /Appearance/ }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Largest" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateGlobalSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const payload = mockUpdateGlobalSettings.mock.calls[0][0];
|
||||
expect(payload).toEqual(expect.objectContaining({ dashboardFontScalePct: 120 }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Project Models", () => {
|
||||
it("renders a project-scoped default model lane", async () => {
|
||||
mockFetchSettings.mockResolvedValue({
|
||||
|
||||
@@ -736,6 +736,37 @@ describe("TaskCard", () => {
|
||||
expect(timer).not.toBeNull();
|
||||
expect(timer?.textContent).toContain("12m");
|
||||
expect(timer?.getAttribute("title")).toContain("Execution time 12m");
|
||||
expect(timer?.getAttribute("title")).not.toContain("Completed");
|
||||
});
|
||||
|
||||
it("keeps the in-review timer live from executionStartedAt when present", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-04-25T12:30:00.000Z"));
|
||||
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
task={makeTask({
|
||||
column: "in-review",
|
||||
executionStartedAt: "2026-04-25T12:00:00.000Z",
|
||||
columnMovedAt: "2026-04-25T12:12:00.000Z",
|
||||
updatedAt: "2026-04-25T12:30:00.000Z",
|
||||
})}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const timer = container.querySelector(".card-time-indicator");
|
||||
expect(timer).not.toBeNull();
|
||||
expect(timer?.textContent).toContain("30m");
|
||||
expect(timer?.getAttribute("title")).toBe("Execution time 30m");
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5 * 60_000);
|
||||
});
|
||||
|
||||
expect(container.querySelector(".card-time-indicator")?.textContent).toContain("35m");
|
||||
expect(container.querySelector(".card-time-indicator")?.getAttribute("title")).toBe("Execution time 35m");
|
||||
});
|
||||
|
||||
it("shows live merge elapsed in timer chip while task.status is merging", () => {
|
||||
@@ -748,7 +779,8 @@ describe("TaskCard", () => {
|
||||
task={makeTask({
|
||||
column: "in-review",
|
||||
status: "merging",
|
||||
updatedAt: "2026-04-25T13:00:00.000Z",
|
||||
executionStartedAt: "2026-04-25T13:00:00.000Z",
|
||||
updatedAt: "2026-04-25T13:44:30.000Z",
|
||||
workflowStepResults: [
|
||||
{
|
||||
workflowStepId: "step-1",
|
||||
@@ -768,7 +800,7 @@ describe("TaskCard", () => {
|
||||
const timer = container.querySelector(".card-time-indicator");
|
||||
expect(timer).not.toBeNull();
|
||||
expect(timer?.textContent).toContain("45m");
|
||||
expect(timer?.getAttribute("title")).toBe("Merging 45m");
|
||||
expect(timer?.getAttribute("title")).toBe("Execution time 45m. Merge phase <1m");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ vi.mock("../../api", () => ({
|
||||
|
||||
const THEME_MODE_STORAGE_KEY = "kb-dashboard-theme-mode";
|
||||
const COLOR_THEME_STORAGE_KEY = "kb-dashboard-color-theme";
|
||||
const FONT_SCALE_STORAGE_KEY = "kb-dashboard-font-scale-pct";
|
||||
|
||||
const mockFetchGlobalSettings = vi.mocked(fetchGlobalSettings);
|
||||
const mockUpdateGlobalSettings = vi.mocked(updateGlobalSettings);
|
||||
@@ -77,6 +78,7 @@ describe("useTheme", () => {
|
||||
// Clear document attributes
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
document.documentElement.removeAttribute("data-color-theme");
|
||||
document.documentElement.style.fontSize = "";
|
||||
|
||||
// Clear any theme-data stylesheet links from previous tests
|
||||
document.querySelectorAll('link[id="theme-data"]').forEach((link) => link.remove());
|
||||
@@ -133,6 +135,18 @@ describe("useTheme", () => {
|
||||
expect(localStorageMock[COLOR_THEME_STORAGE_KEY]).toBe("ocean");
|
||||
});
|
||||
|
||||
it("hydrates dashboard font scale from backend on mount", async () => {
|
||||
mockFetchGlobalSettings.mockResolvedValue({ dashboardFontScalePct: 110 });
|
||||
|
||||
const { result } = renderHook(() => useTheme());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.dashboardFontScalePct).toBe(110);
|
||||
});
|
||||
expect(localStorageMock[FONT_SCALE_STORAGE_KEY]).toBe("110");
|
||||
expect(document.documentElement.style.fontSize).toBe("110%");
|
||||
});
|
||||
|
||||
it("prefers backend over localStorage on hydration", async () => {
|
||||
localStorageMock[THEME_MODE_STORAGE_KEY] = "light";
|
||||
mockFetchGlobalSettings.mockResolvedValue({ themeMode: "dark" });
|
||||
@@ -279,6 +293,19 @@ describe("useTheme", () => {
|
||||
resolveUpdate!({} as Settings);
|
||||
});
|
||||
|
||||
it("write-through persists dashboard font scale updates", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
|
||||
act(() => {
|
||||
result.current.setDashboardFontScalePct(120);
|
||||
});
|
||||
|
||||
expect(result.current.dashboardFontScalePct).toBe(120);
|
||||
expect(localStorageMock[FONT_SCALE_STORAGE_KEY]).toBe("120");
|
||||
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({ dashboardFontScalePct: 120 });
|
||||
expect(document.documentElement.style.fontSize).toBe("120%");
|
||||
});
|
||||
|
||||
it("backend hydration failure falls back to localStorage", async () => {
|
||||
localStorageMock[THEME_MODE_STORAGE_KEY] = "light";
|
||||
mockFetchGlobalSettings.mockRejectedValue(new Error("network unavailable"));
|
||||
@@ -499,6 +526,15 @@ describe("useTheme", () => {
|
||||
expect(result.current.colorTheme).toBe("default");
|
||||
});
|
||||
|
||||
it("clamps invalid dashboard font scale values from localStorage", () => {
|
||||
localStorageMock[FONT_SCALE_STORAGE_KEY] = "400";
|
||||
|
||||
const { result } = renderHook(() => useTheme());
|
||||
|
||||
expect(result.current.dashboardFontScalePct).toBe(125);
|
||||
expect(document.documentElement.style.fontSize).toBe("125%");
|
||||
});
|
||||
|
||||
it("falls back to defaults when localStorage throws", () => {
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: () => {
|
||||
@@ -1136,6 +1172,7 @@ describe("getThemeInitScript", () => {
|
||||
expect(script).toContain("localStorage");
|
||||
expect(script).toContain("data-theme");
|
||||
expect(script).toContain("data-color-theme");
|
||||
expect(script).toContain("style.fontSize");
|
||||
});
|
||||
|
||||
it("includes the correct localStorage keys", () => {
|
||||
@@ -1143,6 +1180,7 @@ describe("getThemeInitScript", () => {
|
||||
|
||||
expect(script).toContain(THEME_MODE_STORAGE_KEY);
|
||||
expect(script).toContain(COLOR_THEME_STORAGE_KEY);
|
||||
expect(script).toContain(FONT_SCALE_STORAGE_KEY);
|
||||
});
|
||||
|
||||
it("includes every supported theme in the validated theme list", () => {
|
||||
|
||||
@@ -43,13 +43,21 @@ function isKeyboardFocusableElement(el: Element | null): boolean {
|
||||
return el instanceof HTMLElement && el.isContentEditable;
|
||||
}
|
||||
|
||||
function getKeyboardMetrics(): { overlap: number; open: boolean; vvHeight: number | null } {
|
||||
interface KeyboardMetrics {
|
||||
overlap: number;
|
||||
open: boolean;
|
||||
vvHeight: number | null;
|
||||
vvOffsetTop: number;
|
||||
}
|
||||
|
||||
function getKeyboardMetrics(): KeyboardMetrics {
|
||||
if (typeof window === "undefined" || !window.visualViewport) {
|
||||
return { overlap: 0, open: false, vvHeight: null };
|
||||
return { overlap: 0, open: false, vvHeight: null, vvOffsetTop: 0 };
|
||||
}
|
||||
|
||||
const vv = window.visualViewport;
|
||||
const focused = isKeyboardFocusableElement(document.activeElement);
|
||||
const offsetTop = vv.offsetTop;
|
||||
|
||||
// Only refresh baseline while keyboard is likely closed.
|
||||
if (!focused) {
|
||||
@@ -59,7 +67,7 @@ function getKeyboardMetrics(): { overlap: number; open: boolean; vvHeight: numbe
|
||||
// Android/Chrome style overlap.
|
||||
const chromeOverlap = Math.max(0, window.innerHeight - vv.offsetTop - vv.height);
|
||||
if (chromeOverlap > 0) {
|
||||
return { overlap: chromeOverlap, open: true, vvHeight: vv.height };
|
||||
return { overlap: chromeOverlap, open: true, vvHeight: vv.height, vvOffsetTop: offsetTop };
|
||||
}
|
||||
|
||||
// iOS fallback (window.innerHeight shrinks with keyboard).
|
||||
@@ -67,20 +75,20 @@ function getKeyboardMetrics(): { overlap: number; open: boolean; vvHeight: numbe
|
||||
const gap = Math.max(0, baselineHeight - vv.offsetTop - vv.height);
|
||||
|
||||
if (gap >= IOS_FALLBACK_MIN_GAP_PX) {
|
||||
return { overlap: gap, open: true, vvHeight: vv.height };
|
||||
return { overlap: gap, open: true, vvHeight: vv.height, vvOffsetTop: offsetTop };
|
||||
}
|
||||
|
||||
if (gap >= IOS_FALLBACK_MIN_FOCUSED_GAP_PX && focused) {
|
||||
return { overlap: gap, open: true, vvHeight: vv.height };
|
||||
return { overlap: gap, open: true, vvHeight: vv.height, vvOffsetTop: offsetTop };
|
||||
}
|
||||
|
||||
// Last-resort signal: focused input + meaningful viewport shrink.
|
||||
const viewportShrink = Math.max(0, baselineHeight - vv.height);
|
||||
if (focused && viewportShrink >= IOS_VIEWPORT_SHRINK_MIN_PX) {
|
||||
return { overlap: 0, open: true, vvHeight: vv.height };
|
||||
return { overlap: 0, open: true, vvHeight: vv.height, vvOffsetTop: offsetTop };
|
||||
}
|
||||
|
||||
return { overlap: 0, open: false, vvHeight: null };
|
||||
return { overlap: 0, open: false, vvHeight: null, vvOffsetTop: 0 };
|
||||
}
|
||||
|
||||
/** Reset cached viewport baseline. Exported for tests only. */
|
||||
@@ -94,15 +102,17 @@ interface UseMobileKeyboardOptions {
|
||||
|
||||
export function useMobileKeyboard(
|
||||
{ enabled = true }: UseMobileKeyboardOptions = {},
|
||||
): { keyboardOverlap: number; viewportHeight: number | null; keyboardOpen: boolean } {
|
||||
): { keyboardOverlap: number; viewportHeight: number | null; viewportOffsetTop: number; keyboardOpen: boolean } {
|
||||
const [keyboardOverlap, setKeyboardOverlap] = useState(0);
|
||||
const [viewportHeight, setViewportHeight] = useState<number | null>(null);
|
||||
const [viewportOffsetTop, setViewportOffsetTop] = useState(0);
|
||||
const [keyboardOpen, setKeyboardOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !isMobileDevice()) {
|
||||
setKeyboardOverlap(0);
|
||||
setViewportHeight(null);
|
||||
setViewportOffsetTop(0);
|
||||
setKeyboardOpen(false);
|
||||
return;
|
||||
}
|
||||
@@ -111,6 +121,7 @@ export function useMobileKeyboard(
|
||||
if (!vv) {
|
||||
setKeyboardOverlap(0);
|
||||
setViewportHeight(null);
|
||||
setViewportOffsetTop(0);
|
||||
setKeyboardOpen(false);
|
||||
return;
|
||||
}
|
||||
@@ -119,6 +130,7 @@ export function useMobileKeyboard(
|
||||
const metrics = getKeyboardMetrics();
|
||||
setKeyboardOverlap(metrics.overlap);
|
||||
setViewportHeight(metrics.vvHeight);
|
||||
setViewportOffsetTop(metrics.vvOffsetTop);
|
||||
setKeyboardOpen(metrics.open);
|
||||
};
|
||||
|
||||
@@ -135,9 +147,10 @@ export function useMobileKeyboard(
|
||||
document.removeEventListener("focusout", update);
|
||||
setKeyboardOverlap(0);
|
||||
setViewportHeight(null);
|
||||
setViewportOffsetTop(0);
|
||||
setKeyboardOpen(false);
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
return { keyboardOverlap, viewportHeight, keyboardOpen };
|
||||
return { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen };
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@ import { fetchGlobalSettings, updateGlobalSettings } from "../api";
|
||||
|
||||
const THEME_MODE_STORAGE_KEY = "kb-dashboard-theme-mode";
|
||||
const COLOR_THEME_STORAGE_KEY = "kb-dashboard-color-theme";
|
||||
const FONT_SCALE_STORAGE_KEY = "kb-dashboard-font-scale-pct";
|
||||
const DEFAULT_FONT_SCALE_PCT = 100;
|
||||
const MIN_FONT_SCALE_PCT = 85;
|
||||
const MAX_FONT_SCALE_PCT = 125;
|
||||
const VALID_COLOR_THEMES = [...COLOR_THEMES] satisfies ColorTheme[];
|
||||
const THEME_DATA_ID = "theme-data";
|
||||
const THEME_DATA_FILENAME = "theme-data.css";
|
||||
@@ -51,8 +55,10 @@ const useIsomorphicLayoutEffect = isBrowser ? useLayoutEffect : useEffect;
|
||||
interface UseThemeReturn {
|
||||
themeMode: ThemeMode;
|
||||
colorTheme: ColorTheme;
|
||||
dashboardFontScalePct: number;
|
||||
setThemeMode: (mode: ThemeMode) => void;
|
||||
setColorTheme: (theme: ColorTheme) => void;
|
||||
setDashboardFontScalePct: (scalePct: number) => void;
|
||||
isSystemDark: boolean;
|
||||
}
|
||||
|
||||
@@ -104,6 +110,32 @@ function writeCachedColorTheme(theme: ColorTheme): void {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeFontScalePct(value: unknown): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return DEFAULT_FONT_SCALE_PCT;
|
||||
}
|
||||
return Math.min(MAX_FONT_SCALE_PCT, Math.max(MIN_FONT_SCALE_PCT, Math.round(value)));
|
||||
}
|
||||
|
||||
function readCachedDashboardFontScalePct(): number {
|
||||
if (!isBrowser) return DEFAULT_FONT_SCALE_PCT;
|
||||
try {
|
||||
const saved = Number(localStorage.getItem(FONT_SCALE_STORAGE_KEY));
|
||||
return normalizeFontScalePct(saved);
|
||||
} catch {
|
||||
return DEFAULT_FONT_SCALE_PCT;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCachedDashboardFontScalePct(scalePct: number): void {
|
||||
if (!isBrowser) return;
|
||||
try {
|
||||
localStorage.setItem(FONT_SCALE_STORAGE_KEY, String(normalizeFontScalePct(scalePct)));
|
||||
} catch {
|
||||
// localStorage not available, skip cache write
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective theme mode (resolves "system" to actual dark/light value)
|
||||
*/
|
||||
@@ -118,12 +150,18 @@ function getEffectiveThemeMode(mode: ThemeMode, systemIsDark: boolean): "dark" |
|
||||
* Apply theme attributes to document.documentElement
|
||||
* Call this immediately to prevent flash of wrong theme
|
||||
*/
|
||||
function applyThemeAttributes(themeMode: ThemeMode, colorTheme: ColorTheme, systemIsDark: boolean): void {
|
||||
function applyThemeAttributes(
|
||||
themeMode: ThemeMode,
|
||||
colorTheme: ColorTheme,
|
||||
dashboardFontScalePct: number,
|
||||
systemIsDark: boolean,
|
||||
): void {
|
||||
if (!isBrowser) return;
|
||||
|
||||
const effectiveMode = getEffectiveThemeMode(themeMode, systemIsDark);
|
||||
document.documentElement.setAttribute("data-theme", effectiveMode);
|
||||
document.documentElement.setAttribute("data-color-theme", colorTheme);
|
||||
document.documentElement.style.fontSize = `${normalizeFontScalePct(dashboardFontScalePct)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -193,6 +231,7 @@ export function useTheme(): UseThemeReturn {
|
||||
// Initialize from localStorage cache or defaults to avoid flash before hydration.
|
||||
const [themeMode, setThemeModeState] = useState<ThemeMode>(() => readCachedThemeMode());
|
||||
const [colorTheme, setColorThemeState] = useState<ColorTheme>(() => readCachedColorTheme());
|
||||
const [dashboardFontScalePct, setDashboardFontScalePctState] = useState<number>(() => readCachedDashboardFontScalePct());
|
||||
const [isHydrating, setIsHydrating] = useState(true);
|
||||
|
||||
// Track system color scheme preference
|
||||
@@ -203,8 +242,10 @@ export function useTheme(): UseThemeReturn {
|
||||
|
||||
const themeModeRef = useRef(themeMode);
|
||||
const colorThemeRef = useRef(colorTheme);
|
||||
const dashboardFontScalePctRef = useRef(dashboardFontScalePct);
|
||||
const userSetThemeModeRef = useRef(false);
|
||||
const userSetColorThemeRef = useRef(false);
|
||||
const userSetDashboardFontScalePctRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
themeModeRef.current = themeMode;
|
||||
@@ -214,6 +255,10 @@ export function useTheme(): UseThemeReturn {
|
||||
colorThemeRef.current = colorTheme;
|
||||
}, [colorTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
dashboardFontScalePctRef.current = dashboardFontScalePct;
|
||||
}, [dashboardFontScalePct]);
|
||||
|
||||
// Hydrate canonical theme values from backend global settings.
|
||||
useEffect(() => {
|
||||
if (!isBrowser || !isHydrating) return;
|
||||
@@ -249,6 +294,17 @@ export function useTheme(): UseThemeReturn {
|
||||
writeCachedColorTheme(globalSettings.colorTheme);
|
||||
}
|
||||
}
|
||||
|
||||
if (!userSetDashboardFontScalePctRef.current) {
|
||||
const hydratedScalePct = normalizeFontScalePct(globalSettings.dashboardFontScalePct);
|
||||
if (dashboardFontScalePctRef.current !== hydratedScalePct) {
|
||||
dashboardFontScalePctRef.current = hydratedScalePct;
|
||||
setDashboardFontScalePctState(hydratedScalePct);
|
||||
}
|
||||
if (readCachedDashboardFontScalePct() !== hydratedScalePct) {
|
||||
writeCachedDashboardFontScalePct(hydratedScalePct);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("[useTheme] Failed to hydrate theme from global settings", error);
|
||||
@@ -279,8 +335,8 @@ export function useTheme(): UseThemeReturn {
|
||||
|
||||
// Apply theme immediately on mount and when theme changes
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
applyThemeAttributes(themeMode, colorTheme, isSystemDark);
|
||||
}, [themeMode, colorTheme, isSystemDark]);
|
||||
applyThemeAttributes(themeMode, colorTheme, dashboardFontScalePct, isSystemDark);
|
||||
}, [themeMode, colorTheme, dashboardFontScalePct, isSystemDark]);
|
||||
|
||||
// Ensure theme-data.css is loaded/unloaded based on colorTheme.
|
||||
// This handles both initial hydration from backend and runtime theme changes.
|
||||
@@ -325,11 +381,25 @@ export function useTheme(): UseThemeReturn {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setDashboardFontScalePct = useCallback((scalePct: number) => {
|
||||
const normalizedScalePct = normalizeFontScalePct(scalePct);
|
||||
userSetDashboardFontScalePctRef.current = true;
|
||||
dashboardFontScalePctRef.current = normalizedScalePct;
|
||||
setDashboardFontScalePctState(normalizedScalePct);
|
||||
writeCachedDashboardFontScalePct(normalizedScalePct);
|
||||
|
||||
void updateGlobalSettings({ dashboardFontScalePct: normalizedScalePct }).catch((error) => {
|
||||
console.warn("[useTheme] Failed to persist dashboardFontScalePct to global settings", error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
themeMode,
|
||||
colorTheme,
|
||||
dashboardFontScalePct,
|
||||
setThemeMode,
|
||||
setColorTheme,
|
||||
setDashboardFontScalePct,
|
||||
isSystemDark,
|
||||
};
|
||||
}
|
||||
@@ -350,13 +420,20 @@ export function getThemeInitScript(): string {
|
||||
if (!validThemes.includes(colorTheme)) {
|
||||
colorTheme = 'default';
|
||||
}
|
||||
var fontScale = Number(localStorage.getItem('${FONT_SCALE_STORAGE_KEY}') || '${DEFAULT_FONT_SCALE_PCT}');
|
||||
if (!Number.isFinite(fontScale)) {
|
||||
fontScale = ${DEFAULT_FONT_SCALE_PCT};
|
||||
}
|
||||
fontScale = Math.min(${MAX_FONT_SCALE_PCT}, Math.max(${MIN_FONT_SCALE_PCT}, Math.round(fontScale)));
|
||||
var systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
var effectiveMode = mode === 'system' ? (systemDark ? 'dark' : 'light') : mode;
|
||||
document.documentElement.setAttribute('data-theme', effectiveMode);
|
||||
document.documentElement.setAttribute('data-color-theme', colorTheme);
|
||||
document.documentElement.style.fontSize = fontScale + '%';
|
||||
} catch (e) {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
document.documentElement.setAttribute('data-color-theme', 'default');
|
||||
document.documentElement.style.fontSize = '${DEFAULT_FONT_SCALE_PCT}%';
|
||||
}
|
||||
})();
|
||||
`;
|
||||
|
||||
@@ -21,10 +21,16 @@
|
||||
if (!validThemes.includes(colorTheme)) {
|
||||
colorTheme = 'default';
|
||||
}
|
||||
var fontScale = Number(localStorage.getItem('kb-dashboard-font-scale-pct') || '100');
|
||||
if (!Number.isFinite(fontScale)) {
|
||||
fontScale = 100;
|
||||
}
|
||||
fontScale = Math.min(125, Math.max(85, Math.round(fontScale)));
|
||||
var systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
var effectiveMode = mode === 'system' ? (systemDark ? 'dark' : 'light') : mode;
|
||||
document.documentElement.setAttribute('data-theme', effectiveMode);
|
||||
document.documentElement.setAttribute('data-color-theme', colorTheme);
|
||||
document.documentElement.style.fontSize = fontScale + '%';
|
||||
// Load theme-data.css for non-default themes to prevent flash
|
||||
// Use path-safe resolution that works in both HTTP and file:// contexts
|
||||
if (colorTheme !== 'default') {
|
||||
@@ -75,6 +81,7 @@
|
||||
} catch (e) {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
document.documentElement.setAttribute('data-color-theme', 'default');
|
||||
document.documentElement.style.fontSize = '100%';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user