Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/references/design-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,20 @@ function Page() {
}
```

### Viewport height & on-screen keyboard

Page shells are height-locked to the viewport (`TopBar + scroll container + ActionBar`), so they must use the
**dynamic** viewport units — `h-dvh` / `min-h-dvh`, never `h-screen` / `100vh`. `vh` resolves against the
*large* viewport (browser UI retracted), so on Android — Firefox, Edge, Kiwi alike — a `100vh` shell is taller
than the visible area while the toolbar is showing, and because the shell scrolls internally the document
itself can't scroll, leaving `BottomTabBar` / ActionBar unreachable below the fold (issue #1555).

The full-page entries (`options` / `install` / `import` / `batchupdate` / `confirm`) additionally declare
`interactive-widget=resizes-content` in their `<meta name="viewport">`: the default `resizes-visual` shrinks
only the visual viewport when the on-screen keyboard opens, leaving a `dvh` shell (and the editor toolbar under
it) hidden behind the keyboard. `resizes-content` shrinks the layout viewport instead, so the shell reflows
above the keyboard. `popup.html` is excluded — it is a fixed-size panel that scrolls as a whole.

### Desktop ↔ mobile transforms

| Desktop (≥768px) | Mobile (<768px) |
Expand Down
2 changes: 1 addition & 1 deletion src/pages/batchupdate.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content" />
<script src="common.js"></script>
<title>ScriptCat</title>
<style>
Expand Down
2 changes: 1 addition & 1 deletion src/pages/batchupdate/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -444,7 +444,7 @@ export function DesktopView({ view }: { view: BatchUpdateViewProps }) {
const { t } = useTranslation();
const empty = view.updates.length === 0 && view.ignored.length === 0;
return (
<div className="flex h-screen flex-col bg-background text-foreground">
<div className="flex h-dvh flex-col bg-background text-foreground">
<header className="flex h-[60px] shrink-0 items-center justify-between border-b border-border bg-card px-6">
<div className="flex min-w-0 items-center gap-3">
<PackageCheck className="size-[22px] shrink-0 text-primary" />
Expand Down
2 changes: 1 addition & 1 deletion src/pages/batchupdate/mobile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ export function MobileView({ view }: { view: BatchUpdateViewProps }) {
.join(" · ");

return (
<div className="flex h-screen flex-col bg-background text-foreground">
<div className="flex h-dvh flex-col bg-background text-foreground">
<header className="flex h-[62px] shrink-0 items-center gap-3 border-b border-border bg-card px-4">
<PackageCheck className="size-[22px] shrink-0 text-primary" />
<div className="flex min-w-0 flex-col">
Expand Down
2 changes: 1 addition & 1 deletion src/pages/confirm.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content" />
<script src="common.js"></script>
<title>ScriptCat</title>
<style>
Expand Down
2 changes: 1 addition & 1 deletion src/pages/confirm/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ function PageShell({ children }: { children: React.ReactNode }) {
return (
<div
data-testid="confirm-shell"
className="flex min-h-screen flex-col items-center justify-center gap-6 bg-background px-4 py-10"
className="flex min-h-dvh flex-col items-center justify-center gap-6 bg-background px-4 py-10"
>
<BrandMark />
{children}
Expand Down
2 changes: 1 addition & 1 deletion src/pages/import.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content" />
<script src="common.js"></script>
<title>ScriptCat</title>
<style>
Expand Down
2 changes: 1 addition & 1 deletion src/pages/import/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ export function ImportLayout({
children: ReactNode;
}) {
return (
<div data-testid="import-layout" className="flex h-screen flex-col bg-background text-foreground">
<div data-testid="import-layout" className="flex h-dvh flex-col bg-background text-foreground">
<header
data-testid="top-bar"
className="sticky top-0 z-10 flex h-[52px] shrink-0 items-center gap-3 border-b border-border bg-card/95 px-6 backdrop-blur"
Expand Down
4 changes: 2 additions & 2 deletions src/pages/import/mobile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ function MobileHeader({ onClose }: { onClose?: () => void }) {
/** 移动端状态屏外壳:头部 + 居中正文(复用桌面的居中状态组件) */
function MobileStateShell({ onClose, children }: { onClose?: () => void; children: ReactNode }) {
return (
<div className="flex h-screen flex-col bg-background text-foreground">
<div className="flex h-dvh flex-col bg-background text-foreground">
<MobileHeader onClose={onClose} />
<div className="flex flex-1 items-center justify-center overflow-auto px-5">{children}</div>
</div>
Expand Down Expand Up @@ -367,7 +367,7 @@ export function MobileView({ view }: { view: ImportView }) {

const importing = view.phase === "importing";
return (
<div className="flex h-screen flex-col bg-background text-foreground">
<div className="flex h-dvh flex-col bg-background text-foreground">
<MobileHeader onClose={importing ? undefined : view.onClose} />
{importing && <TopProgressBar done={view.doneCount} total={view.totalCount} />}
{importing ? <MobileImportingBar view={view} /> : <MobileToolbar view={view} />}
Expand Down
2 changes: 1 addition & 1 deletion src/pages/install.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content" />
<script src="common.js"></script>
<title>ScriptCat</title>
<style>
Expand Down
2 changes: 1 addition & 1 deletion src/pages/install/components/InstallLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export interface InstallLayoutProps {

export function InstallLayout({ title, titleIcon, titleTone = "default", actions, children }: InstallLayoutProps) {
return (
<div data-testid="install-layout" className="flex h-screen flex-col bg-background">
<div data-testid="install-layout" className="flex h-dvh flex-col bg-background">
<InstallTopBar title={title} titleIcon={titleIcon} titleTone={titleTone} />

<main data-testid="content-area" className="min-h-0 flex-1 overflow-y-auto px-6 py-7">
Expand Down
2 changes: 1 addition & 1 deletion src/pages/install/components/InstallStates.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { InstallTopBar } from "./InstallTopBar";
function StateShell({ children }: { children: React.ReactNode }) {
const { t } = useTranslation(["install", "common"]);
return (
<div className="flex h-screen flex-col bg-background">
<div className="flex h-dvh flex-col bg-background">
<InstallTopBar title={t("install:context_install")} titleIcon={Download} />
<main className="flex min-h-0 flex-1 flex-col items-center justify-center gap-5 px-6 text-center">
{children}
Expand Down
2 changes: 1 addition & 1 deletion src/pages/options.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content" />
<script src="common.js"></script>
<title>ScriptCat</title>
</head>
Expand Down
13 changes: 13 additions & 0 deletions src/pages/options/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,17 @@ describe("Layout 外壳响应式", () => {
expect(container.querySelector("aside")).not.toBeNull();
expect(queryByTestId("bottom-tab-bar")).toBeNull();
});

// 100vh 在带可收起工具栏/软键盘的移动浏览器上等于「大视口」,外壳会比当前可见区域更高,
// 底部 Tab 栏被挤到可视区外且文档本身不可滚动(issue #1555);动态视口单位才跟随可见高度。
it.each([
["移动端", true],
["桌面端", false],
])("%s 外壳高度使用动态视口单位而非 100vh", (_label, isMobile) => {
mockedUseIsMobile.mockReturnValue(isMobile);
const { container } = renderLayout();
const shell = container.querySelector("div")!;
expect(shell.className).toContain("h-dvh");
expect(shell.className).not.toContain("h-screen");
});
});
6 changes: 3 additions & 3 deletions src/pages/options/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,13 @@ export function Layout() {
if (isMobile) {
if (isFullscreen) {
return (
<div className="flex flex-col h-screen bg-background text-foreground">
<div className="flex flex-col h-dvh bg-background text-foreground">
<Outlet />
</div>
);
}
return (
<div className="flex flex-col h-screen bg-background text-foreground">
<div className="flex flex-col h-dvh bg-background text-foreground">
<MobileHeader />
<main className="flex-1 min-w-0 overflow-auto scrollbar-custom">
<Outlet />
Expand All @@ -50,7 +50,7 @@ export function Layout() {
}
return (
<>
<div className="flex h-screen bg-background text-foreground">
<div className="flex h-dvh bg-background text-foreground">
<Sidebar />
<main className="flex-1 min-w-0 overflow-auto scrollbar-custom">
<Outlet />
Expand Down
5 changes: 3 additions & 2 deletions src/pages/options/components/SettingRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ export function SettingRow({
children: React.ReactNode;
}) {
return (
<div className="flex items-center justify-between gap-4">
// 移动端(<768px)竖排:控件宽度固定,横排会把文案挤成每行一两个字(issue #1555)
<div className="flex flex-col items-stretch gap-2 md:flex-row md:items-center md:justify-between md:gap-4">
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-[13px] font-medium text-foreground">{label}</span>
{description && <span className="text-xs text-muted-foreground">{description}</span>}
</div>
<div className="flex items-center gap-2 shrink-0">{children}</div>
<div className="flex items-center gap-2 md:shrink-0">{children}</div>
</div>
);
}
2 changes: 1 addition & 1 deletion src/pages/options/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export default function Sidebar() {

return (
<aside
className={`flex flex-col h-screen bg-sidebar border-r border-sidebar-border transition-[width] duration-200 ${collapsed ? "w-14" : "w-[200px]"}`}
className={`flex flex-col h-dvh bg-sidebar border-r border-sidebar-border transition-[width] duration-200 ${collapsed ? "w-14" : "w-[200px]"}`}
>
{/* Logo(点击回到首页) */}
<NavLink
Expand Down
9 changes: 9 additions & 0 deletions src/pages/options/routes/Logger/LoggerMobile.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,15 @@ describe("日志页面 - 移动端", () => {
expect(msg.className).not.toContain("truncate");
});

it("移动端范围/统计条竖向堆叠,统计与自动清理各自独占整行", () => {
renderPage();
const bar = screen.getByTestId("logger-stats-bar");
expect(bar.className).toContain("flex-col");
// 两组必须撑满整行:否则长文本会溢出自身盒子压到相邻分组上(窄屏文字重叠)
expect(screen.getByTestId("logger-stats-summary").className).toContain("w-full");
expect(screen.getByTestId("logger-clean-schedule").className).toContain("w-full");
});

it("仍渲染全部日志且筛选交互可用", () => {
renderPage();
expect(screen.getByText("retry timeout reached")).toBeTruthy();
Expand Down
8 changes: 8 additions & 0 deletions src/pages/options/routes/Logger/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,14 @@ describe("日志页面", () => {
expect(screen.getByTestId("log-detail-1")).toBeTruthy();
});

it("范围/统计条允许换行,窄桌面宽度下自动清理设置整行下移而非被统计文字压字", () => {
renderPage();
const bar = screen.getByTestId("logger-stats-bar");
// 统计文字是 shrink-0,单行放不下时必然溢出压到相邻分组上(768~900px 带「实时」徽标即命中),
// 允许换行才能让自动清理分组整行下移
expect(bar.className).toContain("md:flex-wrap");
});

it("点击清空日志先弹出确认,确认前不调用 clearLogs", async () => {
renderPage();
expect(mockLoggerData.clearLogs).not.toHaveBeenCalled();
Expand Down
21 changes: 15 additions & 6 deletions src/pages/options/routes/Logger/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -216,28 +216,37 @@ export default function Logger() {
</div>

{/* 范围 / 统计条 */}
<div className="flex items-center justify-between gap-2 min-h-[34px] px-4 py-1 md:py-0 shrink-0 border-b border-border bg-muted/40 text-xs">
<div className="flex items-center gap-2 min-w-0 text-fg-secondary">
<div
data-testid="logger-stats-bar"
className="flex flex-col gap-1 min-h-[34px] px-4 py-1.5 shrink-0 border-b border-border bg-muted/40 text-xs md:flex-row md:flex-wrap md:items-center md:justify-between md:gap-x-2 md:gap-y-1 md:py-1"
>
<div
data-testid="logger-stats-summary"
className="flex w-full items-center gap-2 min-w-0 text-fg-secondary md:w-auto"
>
<span className="truncate font-mono">
{`${formatUnixTime(range.start / 1000)} → ${formatUnixTime(range.end / 1000)}`}
</span>
{isNow && (
<span className="rounded-full bg-primary-light px-1.5 py-0.5 text-[11px] font-medium text-primary">
<span className="shrink-0 whitespace-nowrap rounded-full bg-primary-light px-1.5 py-0.5 text-[11px] font-medium text-primary">
{t("logs:now")}
</span>
)}
{refreshInterval !== "off" && (
<span className="inline-flex items-center gap-1.5 rounded-full bg-success-bg px-1.5 py-0.5 text-[11px] font-medium text-success-fg">
<span className="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full bg-success-bg px-1.5 py-0.5 text-[11px] font-medium text-success-fg">
<span className="h-1.5 w-1.5 rounded-full bg-success" />
{`${t("logs:live")} · ${getIntervalLabel(refreshInterval, t)}`}
</span>
)}
<span className="text-muted-foreground">{"·"}</span>
<span className="shrink-0">
{`${t("logs:total_count", { count: logs.length })}${t("logs:filtered_count", { count: filtered.length })}`}
{`${t("logs:total_count", { count: logs.length })} · ${t("logs:filtered_count", { count: filtered.length })}`}
</span>
</div>
<div className="flex items-center gap-1.5 shrink-0 text-muted-foreground">
<div
data-testid="logger-clean-schedule"
className="flex w-full flex-wrap items-center gap-1.5 text-muted-foreground md:w-auto md:flex-nowrap md:shrink-0"
>
<span>{t("logs:clean_schedule")}</span>
<input
type="number"
Expand Down
Loading
Loading