diff --git a/components/Button.tsx b/components/Button.tsx
new file mode 100644
index 0000000..26d601f
--- /dev/null
+++ b/components/Button.tsx
@@ -0,0 +1,32 @@
+import { Button as MuiButton, ButtonProps as MuiButtonProps } from '@mui/material';
+
+export type ButtonProps = MuiButtonProps;
+
+export function Button({ sx = [], ...props }: ButtonProps) {
+ return (
+
+ );
+}
+
+export default Button;
diff --git a/components/StorageCleanerConfirm.tsx b/components/StorageCleanerConfirm.tsx
new file mode 100644
index 0000000..14da382
--- /dev/null
+++ b/components/StorageCleanerConfirm.tsx
@@ -0,0 +1,82 @@
+import {
+ Dialog,
+ DialogTitle,
+ DialogContent,
+ DialogActions,
+ Typography,
+ Box,
+} from '@mui/material';
+import type { StorageCleanerOptions } from '@/types/storage';
+import Button from '@/components/Button';
+
+export interface StorageCleanerConfirmProps {
+ open: boolean;
+ onClose: () => void;
+ onConfirm: () => void;
+ options: StorageCleanerOptions;
+}
+
+export function StorageCleanerConfirm({
+ open,
+ onClose,
+ onConfirm,
+ options,
+}: StorageCleanerConfirmProps) {
+ return (
+
+ );
+}
+
+export default StorageCleanerConfirm;
diff --git a/docs/superpowers/plans/2026-04-05-popup-layout-fix-plan.md b/docs/superpowers/plans/2026-04-05-popup-layout-fix-plan.md
new file mode 100644
index 0000000..a070d99
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-05-popup-layout-fix-plan.md
@@ -0,0 +1,32 @@
+# 实施计划:修复 Popup 布局与滚动条样式
+
+## 1. 目标
+
+按照设计规范实施固定高度布局与极简滚动条,确保扩展弹窗显示稳定且美观。
+
+## 2. 实施步骤
+
+### 2.1 CSS 核心样式更新 (`entrypoints/popup/App.css`)
+
+1. **视口锁定**: 更新 `html`, `body` 样式,固定 `width: 400px`, `height: 600px`。
+2. **根容器改造**:
+ - 将 `.app` 修改为 Flex 容器:`display: flex; flex-direction: column; height: 100%; overflow: hidden;`。
+ - 移除 `margin: 0 auto;` 和 `min-height: 100%;`。
+3. **滚动条变量与全局注入**:
+ - 在 `:root` 中定义 `--sb-` 开头的滚动条样式变量。
+ - 使用 `*::-webkit-scrollbar` 系列伪元素定义全局极简滚动条。
+
+### 2.2 React 结构重构 (`entrypoints/popup/App.tsx`)
+
+1. **注入滚动容器**: 在 `nav-container` 之后,将所有的页面渲染(`TimestampPage`, `StorageCleanerPage`)包裹在一个统一的 `Box` 中。
+2. **设置容器样式**: 给该 `Box` 设置 `sx={{ flex: 1, overflowY: 'auto', scrollbarGutter: 'stable' }}`。
+
+### 2.3 质量保障
+
+1. **Lint 检测**: 运行 `npm run lint` 检查样式变量引用和 JSX 结构。
+2. **类型检查**: 运行 `npm run compile` 验证 MUI 组件属性。
+
+## 3. 验证计划
+
+- 手动切换导航栏,观察窗口尺寸是否维持在 600px。
+- 在“存储清理”页面展开所有选项,观察右侧是否出现极细滚动条,且不会挤压内容。
diff --git a/docs/superpowers/specs/2026-04-05-popup-layout-fix-design.md b/docs/superpowers/specs/2026-04-05-popup-layout-fix-design.md
new file mode 100644
index 0000000..bdadb8b
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-05-popup-layout-fix-design.md
@@ -0,0 +1,48 @@
+# 设计规范:Popup 弹窗固定高度与极简滚动条
+
+## 1. 目标
+
+解决 Chrome 扩展弹窗高度“只增不减”的布局问题,提供稳定的 600px 固定高度体验,并实现符合极简主义设计规范的可复用滚动条样式。
+
+## 2. 核心架构方案 (方案 A)
+
+采用 **Flex 布局 + 视口锁定** 的策略,将弹窗尺寸固定在 `400px * 600px`。
+
+### 2.1 容器层级设计
+
+- **`html`, `body`**: 锁定为 `400px * 600px`,并设置 `overflow: hidden` 防止出现双滚动条。
+- **`.app` (根容器)**:
+ - 设置为 `display: flex; flex-direction: column;`。
+ - 高度撑满父级 (`100%`)。
+ - 锁定 `overflow: hidden`。
+- **`nav-container` (导航栏)**:
+ - 位于顶部,高度固定,不参与 Flex 缩放。
+- **内容展示区 (Page Container)**:
+ - 设置 `flex: 1`,自动填充剩余空间。
+ - 设置 `overflow-y: auto`,启用局部滚动。
+ - 设置 `scrollbar-gutter: stable`,预留滚动条空间,防止布局抖动。
+
+## 3. 极简滚动条设计规范
+
+为了确保在所有页面和组件中表现一致,采用全局 Webkit 伪元素定制方案。
+
+### 3.1 变量定义
+
+在 `:root` 中定义 CSS 变量以增强兼容性和可维护性:
+
+- `--sb-width`: `6px` (极细)
+- `--sb-thumb-color`: `rgba(0, 0, 0, 0.1)` (浅灰色半透明)
+- `--sb-thumb-hover`: `rgba(0, 0, 0, 0.18)` (悬停时微深)
+- `--sb-track-color`: `transparent` (背景完全透明)
+
+### 3.2 样式表现
+
+- **滑块 (Thumb)**: 胶囊状圆角 (`10px`)。
+- **背景剪裁 (Background Clip)**: 使用 `content-box` 结合透明边框来实现滑块与边缘的微距感。
+- **自动应用**: 使用通配符 `*::-webkit-scrollbar` 确保全局所有溢出容器均自动继承此样式。
+
+## 4. 成功准则
+
+- 切换“时间戳”和“存储清理”页面时,浏览器窗口大小保持 `600px` 不变。
+- 当页面内容超过视口时,右侧显示细长、半透明的滚动条。
+- 内容加载或 Accordion 展开时,页面水平方向不发生位移。
diff --git a/entrypoints/popup/App.css b/entrypoints/popup/App.css
index 085ebb5..1390b0e 100644
--- a/entrypoints/popup/App.css
+++ b/entrypoints/popup/App.css
@@ -1,18 +1,4 @@
-/* 隐藏页面滚动条 */
-html,
-body {
- margin: 0;
- padding: 0;
- width: 100%;
- height: 100%;
- scrollbar-gutter: stable;
-}
-
-/* 隐藏body滚动条但允许滚动 */
-body {
- overflow: auto;
-}
-
+/* 极简滚动条变量 */
:root {
/* 统一圆角变量 */
--radius: 8px;
@@ -25,16 +11,54 @@ body {
/* 统一按钮文字颜色变量 */
--btn-text: #333333;
--border: 1px solid #e0e0e0;
+
+ /* 滚动条变量 */
+ --sb-width: 6px;
+ --sb-thumb-color: rgba(0, 0, 0, 0.1);
+ --sb-thumb-hover: rgba(0, 0, 0, 0.2);
+ --sb-track-color: transparent;
+}
+
+/* 强制固定视口尺寸 */
+html,
+body,
+#root {
+ margin: 0;
+ padding: 0;
+ width: 400px;
+ height: 600px;
+ overflow: hidden; /* 禁用外层滚动 */
+}
+
+/* 全局极简滚动条定制 */
+*::-webkit-scrollbar {
+ width: var(--sb-width);
+}
+
+*::-webkit-scrollbar-track {
+ background: var(--sb-track-color);
+}
+
+*::-webkit-scrollbar-thumb {
+ background: var(--sb-thumb-color);
+ border-radius: 10px;
+ background-clip: content-box;
+ border: 1px solid transparent;
+}
+
+*::-webkit-scrollbar-thumb:hover {
+ background: var(--sb-thumb-hover);
}
.app {
- width: 400px;
- min-height: 100%;
- margin: 0 auto;
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ height: 100%;
box-sizing: border-box;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
- overflow-x: hidden;
+ overflow: hidden; /* 内部由 flex 子项控制滚动 */
}
@keyframes fadeIn {
@@ -52,9 +76,12 @@ body {
.nav-container {
display: flex;
justify-content: center;
- margin-top: 16px;
- margin-bottom: 16px;
+ padding-top: 16px;
+ padding-bottom: 16px;
gap: 8px;
+ flex-shrink: 0; /* 禁止导航栏被挤压缩放 */
+ background-color: #fff;
+ border-bottom: 1px solid rgba(0, 0, 0, 0.05);
}
/* 导航按钮 */
diff --git a/entrypoints/popup/App.tsx b/entrypoints/popup/App.tsx
index 3050ccb..c208d03 100644
--- a/entrypoints/popup/App.tsx
+++ b/entrypoints/popup/App.tsx
@@ -93,8 +93,20 @@ function App() {
- {currentPage === 'timestamp' && }
- {currentPage === 'storageCleaner' && }
+
+ {/* 统一滚动容器 */}
+
+ {currentPage === 'timestamp' && }
+ {currentPage === 'storageCleaner' && }
+
);
}
diff --git a/entrypoints/popup/pages/StorageCleanerPage.tsx b/entrypoints/popup/pages/StorageCleanerPage.tsx
index 0c0a477..7e9a448 100644
--- a/entrypoints/popup/pages/StorageCleanerPage.tsx
+++ b/entrypoints/popup/pages/StorageCleanerPage.tsx
@@ -4,7 +4,6 @@ import {
Typography,
Box,
Checkbox,
- Button,
FormControlLabel,
Alert,
Snackbar,
@@ -16,8 +15,14 @@ import {
import RefreshIcon from '@mui/icons-material/Refresh';
import WarningIcon from '@mui/icons-material/Warning';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
+import Button from '@/components/Button';
+import StorageCleanerConfirm from '@/components/StorageCleanerConfirm';
import { storageUtil } from '@/utils/chromeStorage';
-import type { StorageCleanerOptions, CleaningResult, StorageCleanerPreferences } from 'types/storage';
+import type {
+ StorageCleanerOptions,
+ CleaningResult,
+ StorageCleanerPreferences,
+} from 'types/storage';
import {
getCurrentTab,
isRestrictedUrl,
@@ -70,10 +75,7 @@ export default function StorageCleanerPage() {
setDomain(new URL(tab.url).hostname);
// Load user preferences
- const prefs = await storageUtil.get(
- 'storageCleaner/preferences',
- DEFAULT_PREFERENCES,
- );
+ const prefs = await storageUtil.get('storageCleaner/preferences', DEFAULT_PREFERENCES);
setAutoRefresh(prefs?.autoRefresh ?? DEFAULT_PREFERENCES.autoRefresh);
setOptions(prefs?.selectedTypes ?? DEFAULT_PREFERENCES.selectedTypes);
};
@@ -84,10 +86,7 @@ export default function StorageCleanerPage() {
const handleAutoRefreshChange = useCallback(async (checked: boolean) => {
setAutoRefresh(checked);
// Save preference immediately
- const prefs = await storageUtil.get(
- 'storageCleaner/preferences',
- DEFAULT_PREFERENCES,
- );
+ const prefs = await storageUtil.get('storageCleaner/preferences', DEFAULT_PREFERENCES);
await storageUtil.set('storageCleaner/preferences', {
...(prefs || DEFAULT_PREFERENCES),
autoRefresh: checked,
@@ -98,13 +97,12 @@ export default function StorageCleanerPage() {
setOptions((prev) => {
const newOptions = { ...prev, [key]: !prev[key] };
// Save options immediately
- storageUtil.get('storageCleaner/preferences', DEFAULT_PREFERENCES)
- .then(prefs => {
- storageUtil.set('storageCleaner/preferences', {
- ...(prefs || DEFAULT_PREFERENCES),
- selectedTypes: newOptions,
- });
+ storageUtil.get('storageCleaner/preferences', DEFAULT_PREFERENCES).then((prefs) => {
+ storageUtil.set('storageCleaner/preferences', {
+ ...(prefs || DEFAULT_PREFERENCES),
+ selectedTypes: newOptions,
});
+ });
return newOptions;
});
}, []);
@@ -122,12 +120,9 @@ export default function StorageCleanerPage() {
serviceWorkers: checked,
};
setOptions(newOptions);
-
+
// Save options immediately
- const prefs = await storageUtil.get(
- 'storageCleaner/preferences',
- DEFAULT_PREFERENCES,
- );
+ const prefs = await storageUtil.get('storageCleaner/preferences', DEFAULT_PREFERENCES);
await storageUtil.set('storageCleaner/preferences', {
...(prefs || DEFAULT_PREFERENCES),
selectedTypes: newOptions,
@@ -193,9 +188,6 @@ export default function StorageCleanerPage() {
{/* Header */}
-
- 存储清理
-
当前页面: {domain || '加载中...'}
@@ -327,10 +319,8 @@ export default function StorageCleanerPage() {
variant="contained"
onClick={() => setShowConfirm(true)}
sx={{
- py: 1.6, borderRadius: 3, fontSize: '1rem', fontWeight: 600, textTransform: 'none',
- bgcolor: 'primary.main', transition: 'all 0.2s',
- '&:hover': { bgcolor: 'primary.dark', transform: 'translateY(-1px)' },
- '&:active': { transform: 'translateY(0)' }
+ bgcolor: 'primary.main',
+ '&:hover': { bgcolor: 'primary.dark' },
}}
disabled={loading}
fullWidth
@@ -362,60 +352,12 @@ export default function StorageCleanerPage() {
)}
{/* Confirmation Dialog */}
- {showConfirm && (
-
- 确认清理
-
- 将清理以下存储类型:
-
-
- {options.localStorage && (
- - localStorage
- )}
- {options.sessionStorage && (
- - sessionStorage
- )}
- {options.indexedDB && - IndexedDB}
- {options.cookies && - Cookies}
- {options.cacheStorage && (
- - Cache Storage
- )}
- {options.serviceWorkers && (
- - Service Workers
- )}
-
-
- 此操作不可撤销。
-
-
-
-
-
-
- )}
+ setShowConfirm(false)}
+ onConfirm={handleClean}
+ options={options}
+ />
{/* Snackbar */}
{ setMode(m); setError(''); setResult(''); }}
- sx={{
- position: 'relative', zIndex: 1, borderRadius: 3, py: 1, textTransform: 'none',
- fontSize: '0.875rem', fontWeight: 500, transition: 'color 0.2s',
- color: mode === m ? 'text.primary' : 'text.disabled',
- '&:hover': { bgcolor: 'transparent', color: mode === m ? 'text.primary' : 'text.secondary' },
- }}
>
{m === 'ts2dt' ? '时间戳 → 日期' : '日期 → 时间戳'}
@@ -384,12 +378,6 @@ export default function TimestampPage() {