# QuartzPage 应用适配配置文档

> 适用版本：文档-HTML上传应用协议 2.0

---

## 目录

1. [导入 HTML 应用](#1-导入-html-应用)
2. [运行时环境](#2-运行时环境)
3. [样式接口总览](#3-样式接口总览)
4. [CSS 变量详解](#4-css-变量详解)
5. [三种显示模式的适配](#5-三种显示模式的适配)
6. [主题包下的行为](#6-主题包下的行为)
7. [JavaScript 主题接口](#7-javascript-主题接口)
8. [适配检查清单](#8-适配检查清单)
9. [完整示例：番茄钟](#9-完整示例番茄钟)
10. [示例解析](#10-示例解析)

---

## 1. 导入 HTML 应用

### 1.1 操作步骤

1. 打开 QuartzPage →「设置」→「应用」标签页
2. 在「上传 HTML 应用」区域填写：
   - **应用名称**：显示在桌面/任务栏上的名字
   - **应用图标**：一个图片 URL（可留空）
3. 点「选择文件」，选一个 `.html`
4. 保存后应用会出现在「我的应用」列表，可添加到桌面或任务栏

### 1.2 文件要求

| 项目 | 要求 |
|---|---|
| 扩展名 | `.html`（也接受 `text/html`） |
| 结构 | 完整的 HTML 文档，含 `<html><head><body>` |
| 外部资源 | 可用 HTTPS 链接（CDN 字体、图标库等） |
| `<style>` | 会被提取并注入 |
| `<script>` | 会被重新执行（含 `src`） |
| 大小 | 建议 200 KB 以内（存在 localStorage） |

> ⚠️ **不要**用 `<script type="module">` 之外的动态 `import()` 依赖本地路径，
> 应用代码是直接 inline 进主页面的，没有独立的文件上下文。

---

## 2. 运行时环境

理解这一点是适配成功的关键。

### 2.1 应用是「内联注入」，不是 iframe

QuartzPage 会把你的 HTML 拆开，注入到窗口的内容容器里：

```
你的 HTML                          →  QuartzPage 窗口 DOM
─────────────────────────────────────────────────────────────
<head><style>…</style>             →  <style> 追加到 .window-content
<head><link rel=stylesheet>        →  <link> 追加到 .window-content
<body>…</body>                     →  .window-content 的 innerHTML
<script>…</script>                 →  重新创建并执行
```

宿主元素长这样：

```html
<div class="window">
  <div class="window-header">…标题栏…</div>
  <div class="window-content qzp-napp" data-app-type="custom:xxx">
      <!-- 你的 <style>、<link> 和 body 内容在这里 -->
  </div>
</div>
```

### 2.2 这意味着什么

| 结论 | 说明 |
|---|---|
| ✅ **CSS 变量可直接继承** | 应用在 `.window-content` 内，自然继承 `:root` 上的一切变量 |
| ✅ **可读写 `document` / `localStorage`** | 同源，无沙箱限制 |
| ❌ **没有独立的 `<html>` / `<body>` 滚动上下文** | `html,body{height:100%}` 作用的是 QP 主页面 |
| ⚠️ **全局选择器会污染主界面** | 写 `body{...}` 会改到 QP 自己，必须加作用域前缀 |
| ⚠️ **`document.title` 会改浏览器标签页** | 窗口标题由 QP 管理，不要依赖它 |

### 2.3 作用域隔离的正确做法

因为 `<style>` 是注入到整个文档的，**所有选择器都要加自己的根 class**：

```css
/* ❌ 会改到 QP 自己的 body */
body{ background: #16161a; }

/* ✅ 只作用于本应用 */
.pomo{ ... }
.pomo .btn{ ... }
```

应用最外层包一个唯一 class（如 `.pomo`），是最简单可靠的隔离方式。

---

## 3. 样式接口总览

QuartzPage 对外暴露三层样式接口：

| 层级 | 接口 | 用途 |
|---|---|---|
| **CSS 变量** | `:root` 上的 `--accent` / `--text` / `--glass` 等 | 颜色、模糊，自动跟随主题 |
| **HTML class** | `html.light-mode` / `html.theme-glass` / `html.tp-active` | 按模式写差异化样式 |
| **JS 事件** | `qzp:theme:update` | 需要运行时读取具体色值时 |

**优先用 CSS 变量 + class**，只有在必须拿到具体数值（如画 canvas）时才用 JS 事件。

---

## 4. CSS 变量详解

这些变量定义在 `:root`，会随主题、显示模式、主题包实时变化。
**全部都有默认值**，所以即使脱离 QP 单独打开，应用也不会没颜色。

### 4.1 颜色

| 变量 | 含义 | 默认值 |
|---|---|---|
| `--accent` | 强调色 | `#06B6D4` |
| `--accent-rgb` | 强调色（`R,G,B` 形式） | `6,182,212` |
| `--on-accent` | 强调色底上的前景色 | `#ffffff` |
| `--on-accent-rgb` | 同上，RGB 形式 | `255,255,255` |

**为什么要两个版本**：`--accent` 给实色用，`--accent-rgb` 给半透明用：

```css
.btn-primary{ background: var(--accent); color: var(--on-accent); }
.card-glow{ background: rgba(var(--accent-rgb), .18); }   /* 18% 透明度 */
```

`rgb()` 那种写法拼不出透明度，所以必须单独提供 `--accent-rgb`。

### 4.2 文字

| 变量 | 含义 | 深色默认 | 浅色默认 |
|---|---|---|---|
| `--text` | 主文字 | `#f8fafc` | 跟随浅色模式切换 |
| `--text-dim` | 次要文字 | `rgba(248,250,252,.7)` | — |
| `--text-faint` | 更淡的文字 | `rgba(248,250,252,.45)` | — |

三级文字色是刻意的：用它们代替写死的 `opacity`，才能在大改配色时保持一致。

### 4.3 玻璃与模糊

| 变量 | 含义 | 默认值 |
|---|---|---|
| `--glass` | 玻璃底色 | `rgba(255,255,255,.12)` |
| `--glass-border` | 玻璃边框色 | `rgba(255,255,255,.18)` |
| `--blur` | 界面模糊度 | `20px` |
| `--bg-blur` | 背景模糊度 | `0px` |

`--blur` 和 `--bg-blur` 是用户可在设置里分别调节的两个滑块。

### 4.4 怎么用才不别扭

```css
/* 卡片：半透明玻璃 + 细边框，三种模式下都好看 */
.card{
  background: var(--glass);
  border: 1px solid var(--glass-border);
  color: var(--text);
  backdrop-filter: blur(var(--blur));   /* 拟雾下才明显 */
}
```

> 💡 **不要**给应用根元素设不透明背景。背景应该交给 QP 的 `.window-content` 提供，
> 应用保持 `background: transparent`，否则拟雾动效下会挡住壁纸。

---

## 5. 三种显示模式的适配

### 5.1 模式与 class 的对应关系

| 显示模式 | 中文名 | `<html>` 上的 class |
|---|---|---|
| `dark` | 深色 | （无额外 class） |
| `light` | 浅色 | `light-mode` |
| `glass` | 拟雾动效 | `theme-glass` |

另外还有几个实用 class：

| class | 含义 |
|---|---|
| `on-accent-light` | 当前强调色接近纯白，QP 已自动把前景翻成黑色 |
| `tp-active` | 有主题包生效 |
| `tp-has-font` | 主题包替换了字体 |
| `no-dim` | 壁纸压暗已关闭 |

**浅色和拟雾是独立维度**，可能同时存在：`class="light-mode theme-glass"`。

### 5.2 推荐写法

大多数情况下，**用好 CSS 变量就够了**，不需要写任何模式判断：

```css
.pomo{
  color: var(--text);
  background: var(--glass);
  border: 1px solid var(--glass-border);
}
```

只有在「变量无法表达」时才需要 class：

```css
/* 拟雾下加大模糊，让玻璃感更强 */
html.theme-glass .pomo-panel{ backdrop-filter: blur(10px); }

/* 浅色下阴影要更淡 */
html.light-mode .pomo-panel{ box-shadow: 0 2px 8px rgba(0,0,0,.06); }
```

### 5.3 背景色的处理

| 模式 | `.window-content` 背景 | 应用该怎么做 |
|---|---|---|
| 深色 | `#1a1a1c` 实底 | 保持透明 |
| 浅色 | `#fff` 实底 | 保持透明 |
| 拟雾 | `rgba(0,0,0,.06)`（透出壁纸） | 保持透明 |

所以：

```css
html,body{ background: transparent; }   /* 永远透明 */
```

如果应用需要一块「有底色的区域」（比如输入框），用 `--glass` 而不是写死颜色。

### 5.4 颜色主题（自定义配色）

用户可以在「设置 → 外观」里：

- 从内置主题选（绿 / 蓝 / 紫 / 橙 …）
- 或自定义一个 RGB 颜色

无论哪种，都会写入 `--accent` / `--accent-rgb`。
**所以只要应用用这两个变量，就自动跟随用户配色。**

QP 还会做一件事：当强调色非常接近白色时，自动给 `<html>` 加 `on-accent-light`，
并把 `--on-accent` 改成 `#000000`，避免「白底白字」。

---

## 6. 主题包下的行为

主题包（Theme Pack）生效时，`<html>` 会加上 `tp-active`，并且：

| 变化 | 影响 |
|---|---|
| `:root` 上的变量被主题包的值覆盖 | 应用自动跟随新配色 |
| `--tp-font` 生效，全站字体被替换 | 应用字体也会被替换（除非显式指定） |
| 主题包的 `ui_css` 注入 | 可能影响应用的通用元素（如 `button`） |

### 6.1 如果字体替换导致排版崩了

主题包会把字体强制应用到几乎所有元素。若你的应用依赖特定字体（如等宽、图标字体），
需要显式锁定：

```css
/* 图标字体必须锁死，否则全变方块 */
.pomo .icon{
  font-family: "Material Icons" !important;
}
```

### 6.2 如果主题包的 ui_css 影响了你的按钮

主题包 CSS 通常带 `!important`，会盖过应用样式。优先级要更高：

```css
html.tp-active .pomo .btn{
  border-radius: 12px !important;
}
```

### 6.3 不需要做的事

**应用什么都不用改就能适配主题包**——只要用了 CSS 变量。
这正是推荐用变量而不是写死颜色的原因。

---

## 7. JavaScript 主题接口

需要拿到具体色值（画 canvas、生成图片、计算对比度）时才用。

### 7.1 监听主题变化

QP 会在主题/配色变化时，向 `.window-content.qzp-napp` 派发一个 `CustomEvent`：

```js
document.querySelector('.qzp-napp')?.addEventListener('qzp:theme:update', function(e){
  var t = e.detail;
  console.log(t.accent, t.text, t.glass);
});
```

也可以监听 `message`（兼容旧版广播方式）：

```js
window.addEventListener('message', function(e){
  if(e.data && e.data.type === 'qzp:theme:update'){ /* … */ }
});
```

### 7.2 事件 payload 字段

| 字段 | 类型 | 说明 |
|---|---|---|
| `type` | string | 固定 `'qzp:theme:update'` |
| `accent` | string | 强调色，如 `#06B6D4` |
| `accentRgb` | string | 如 `6,182,212` |
| `onAccent` | string | 强调色上的前景色 |
| `accentLight` | boolean | 强调色是否接近白 |
| `text` | string | 主文字色 |
| `textDim` | string | 次要文字色 |
| `textFaint` | string | 更淡的文字色 |
| `glass` | boolean | 是否处于拟雾动效 |
| `light` | boolean | 是否浅色模式 |
| `glassBg` | string | 建议的窗口背景色 |
| `blur` | number | 背景模糊度（px） |
| `uiBlur` | number | 界面模糊度（px） |
| `mode` | string | 桌面模式：`original`/`column`/`minimal` |

### 7.3 主动询问当前主题

如果初始化时需要立即拿到值，直接读 CSS 变量更简单：

```js
var cs = getComputedStyle(document.documentElement);
var accent = cs.getPropertyValue('--accent').trim();
```

### 7.4 发消息给 QP

QP 目前**只处理一种消息**：请求当前主题。

```js
window.parent.postMessage({ type:'qzp:theme:request' }, '*');
```

QP 收到后会通过 `qzp:theme:update` 事件（或 `message`）把 payload 发回来。
除此之外没有其它可用消息——**没有**弹提示、开应用、读数据之类的接口，
这些能力只对 QP 内置应用开放。

---

## 8. 适配检查清单

导入前对照检查：

- [ ] 所有颜色都用 CSS 变量，没有写死 `#fff` / `#000`
- [ ] 应用根元素 `background: transparent`
- [ ] 所有选择器都有作用域前缀（如 `.pomo`）
- [ ] 没有写全局 `body{...}` / `*{...}`
- [ ] 半透明色用 `rgba(var(--accent-rgb), α)` 而非 `var(--accent)`
- [ ] 文字用 `--text` / `--text-dim` / `--text-faint` 三级
- [ ] 主题包替换字体后，图标字体已用 `!important` 锁定
- [ ] 在深色、浅色、拟雾三种模式下都试过
- [ ] 装一个主题包（如 Racing）后再试一次

---

## 9. 完整示例：番茄钟

文件：`应用示例/番茄钟.html`

```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>番茄钟</title>
<style>
  /* 所有颜色都走 QP 的 CSS 变量，自动适配三种显示模式与任何主题包 */

  :root{
    /* 兜底值：脱离 QP 单独打开也不会没颜色 */
    --pomo-work: #E5484D;
    --pomo-work-rgb: 229,72,77;
    --pomo-break: #14A37A;
    --pomo-break-rgb: 20,163,122;
  }

  *{box-sizing:border-box;margin:0;padding:0}

  /* 关键：应用自身透明，背景交给 .window-content，
     这样拟雾动效下才能透出壁纸的雾化玻璃 */
  html,body{height:100%}
  body{
    background:transparent;
    color:var(--text);
    font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Segoe UI",sans-serif;
    display:flex;align-items:center;justify-content:center;
    user-select:none;
    transition:color .35s ease;
  }

  .pomo{
    width:100%;height:100%;max-width:420px;
    padding:22px 24px 24px;
    display:flex;flex-direction:column;align-items:center;
    gap:18px;
  }

  /* ---------- 模式切换 ---------- */
  .modes{
    display:flex;gap:4px;padding:4px;
    background:var(--glass);
    border:1px solid var(--glass-border);
    border-radius:999px;
    transition:background .35s ease,border-color .35s ease;
  }
  .mode-btn{
    border:0;background:transparent;cursor:pointer;
    font:inherit;font-size:12.5px;
    color:var(--text-dim);
    padding:6px 15px;border-radius:999px;
    transition:background .25s ease,color .25s ease;
  }
  .mode-btn:hover{color:var(--text)}
  .mode-btn[aria-selected="true"]{
    background:rgba(var(--pomo-current-rgb),.9);
    color:#fff;
  }

  /* ---------- 环形进度 ---------- */
  .dial{position:relative;width:100%;max-width:260px;aspect-ratio:1}
  .dial svg{width:100%;height:100%;transform:rotate(-90deg)}
  .dial .track{
    fill:none;
    stroke:rgba(var(--pomo-current-rgb),.16);
    stroke-width:8;
    transition:stroke .4s ease;
  }
  .dial .bar{
    fill:none;
    stroke:rgb(var(--pomo-current-rgb));
    stroke-width:8;
    stroke-linecap:round;
    transition:stroke .4s ease,stroke-dashoffset .35s linear;
  }

  .readout{
    position:absolute;inset:0;
    display:flex;flex-direction:column;
    align-items:center;justify-content:center;gap:2px;
  }
  .time{
    font-size:clamp(38px,13vw,52px);
    font-weight:300;
    letter-spacing:-.02em;
    font-variant-numeric:tabular-nums;
    line-height:1;
  }
  .state{
    font-size:12px;letter-spacing:.14em;
    color:var(--text-faint);
    text-transform:uppercase;
  }

  /* ---------- 按钮 ---------- */
  .actions{display:flex;gap:10px;width:100%;max-width:280px}
  .btn{
    flex:1;height:42px;
    border:1px solid var(--glass-border);
    border-radius:12px;
    background:var(--glass);
    color:var(--text);
    font:inherit;font-size:14px;font-weight:500;
    cursor:pointer;
    transition:transform .18s ease,background .25s ease,border-color .25s ease,opacity .25s;
  }
  .btn:hover{
    background:rgba(var(--pomo-current-rgb),.16);
    border-color:rgba(var(--pomo-current-rgb),.4);
  }
  .btn:active{transform:scale(.97)}
  .btn.primary{
    background:rgba(var(--pomo-current-rgb),.92);
    border-color:transparent;
    color:#fff;
  }
  .btn.primary:hover{background:rgb(var(--pomo-current-rgb))}
  .btn:disabled{opacity:.4;cursor:not-allowed;transform:none}

  /* ---------- 底部 ---------- */
  .foot{
    margin-top:auto;width:100%;max-width:280px;
    display:flex;align-items:center;justify-content:space-between;
    font-size:12px;color:var(--text-faint);
  }
  .dots{display:flex;gap:5px;align-items:center}
  .dot{
    width:7px;height:7px;border-radius:50%;
    background:rgba(var(--pomo-current-rgb),.22);
    transition:background .3s ease;
  }
  .dot.on{background:rgb(var(--pomo-current-rgb))}

  .durations{display:flex;gap:6px}
  .dur{
    border:0;background:transparent;cursor:pointer;
    font:inherit;font-size:12px;color:var(--text-faint);
    padding:4px 8px;border-radius:7px;
    transition:background .25s,color .25s;
  }
  .dur:hover{color:var(--text);background:var(--glass)}
  .dur[aria-selected="true"]{color:var(--text);background:var(--glass)}

  /* ---------- 拟雾动效：玻璃更透 ---------- */
  html.theme-glass .modes,
  html.theme-glass .btn{backdrop-filter:blur(6px)}

  /* ---------- 浅色模式微调 ---------- */
  html.light-mode .btn{backdrop-filter:none}

  @media (prefers-reduced-motion: reduce){
    *{transition:none!important}
  }
</style>
</head>
<body>

<div class="pomo" id="pomo">
  <div class="modes" role="tablist">
    <button class="mode-btn" data-mode="work"  aria-selected="true">专注</button>
    <button class="mode-btn" data-mode="short" aria-selected="false">短休</button>
    <button class="mode-btn" data-mode="long"  aria-selected="false">长休</button>
  </div>

  <div class="dial">
    <svg viewBox="0 0 100 100" aria-hidden="true">
      <circle class="track" cx="50" cy="50" r="44"/>
      <circle class="bar" id="bar" cx="50" cy="50" r="44"
              stroke-dasharray="276.46" stroke-dashoffset="0"/>
    </svg>
    <div class="readout">
      <div class="time" id="time">25:00</div>
      <div class="state" id="state">专注中</div>
    </div>
  </div>

  <div class="actions">
    <button class="btn primary" id="startBtn">开始</button>
    <button class="btn" id="resetBtn">重置</button>
  </div>

  <div class="foot">
    <div class="dots" id="dots"></div>
    <div class="durations" id="durations">
      <button class="dur" data-work="15" aria-selected="false">15</button>
      <button class="dur" data-work="25" aria-selected="true">25</button>
      <button class="dur" data-work="45" aria-selected="false">45</button>
    </div>
  </div>
</div>

<script>
(function(){
  'use strict';

  var MODES = {
    work:  { min:25, label:'专注中', rgb:'229,72,77'  },
    short: { min:5,  label:'短休息', rgb:'20,163,122' },
    long:  { min:15, label:'长休息', rgb:'20,163,122' }
  };
  var DOTS = 4;

  var mode   = 'work';
  var total  = MODES.work.min * 60;
  var left   = total;
  var running= false;
  var ticker = null;
  var done   = 0;

  var $time  = document.getElementById('time');
  var $state = document.getElementById('state');
  var $bar   = document.getElementById('bar');
  var $start = document.getElementById('startBtn');
  var $reset = document.getElementById('resetBtn');
  var $dots  = document.getElementById('dots');

  var CIRC = 2 * Math.PI * 44;

  function fmt(sec){
    var m = Math.floor(Math.max(0,sec) / 60);
    var s = Math.max(0,sec) % 60;
    return (m<10?'0':'') + m + ':' + (s<10?'0':'') + s;
  }

  function paint(){
    $time.textContent  = fmt(left);
    $state.textContent = MODES[mode].label;

    var ratio = total ? (total - left) / total : 0;
    $bar.style.strokeDasharray  = CIRC;
    $bar.style.strokeDashoffset = CIRC * (1 - ratio);

    // 当前色写入局部变量，供所有元件共用
    document.getElementById('pomo').style.setProperty('--pomo-current-rgb', MODES[mode].rgb);

    $dots.innerHTML = '';
    for(var i=0;i<DOTS;i++){
      var d = document.createElement('span');
      d.className = 'dot' + (i < done ? ' on' : '');
      $dots.appendChild(d);
    }

    $start.textContent = running ? '暂停' : (left < total ? '继续' : '开始');

    Array.prototype.forEach.call(document.querySelectorAll('.mode-btn'), function(b){
      b.setAttribute('aria-selected', String(b.dataset.mode === mode));
    });
  }

  function tick(){
    if(!running) return;
    left--;
    if(left <= 0){
      left = 0;
      running = false;
      clearInterval(ticker); ticker = null;
      finish();
    }
    paint();
  }

  function finish(){
    if(mode === 'work'){
      done = (done + 1) % (DOTS + 1);
      var next = (done === 0) ? 'long' : 'short';
      switchTo(next);
      notify('专注结束，休息一下');
    }else{
      switchTo('work');
      notify('休息结束，继续专注');
    }
  }

  function notify(msg){
    /* QP 目前没有接收自定义提示的接口，这里只做应用内反馈：
       让状态文字闪一下，避免误以为「发出去了」。 */
    var $st = document.getElementById('state');
    var orig = $st.textContent;
    $st.textContent = msg;
    setTimeout(function(){ $st.textContent = MODES[mode].label; }, 2200);
  }

  function start(){
    if(running){
      running = false;
      clearInterval(ticker); ticker = null;
    }else{
      running = true;
      ticker = setInterval(tick, 1000);
    }
    paint();
  }

  function reset(){
    running = false;
    if(ticker){clearInterval(ticker); ticker = null;}
    total = MODES[mode].min * 60;
    left  = total;
    paint();
  }

  function switchTo(m){
    mode = m;
    running = false;
    if(ticker){clearInterval(ticker); ticker = null;}
    total = MODES[m].min * 60;
    left  = total;
    paint();
  }

  $start.addEventListener('click', start);
  $reset.addEventListener('click', reset);

  Array.prototype.forEach.call(document.querySelectorAll('.mode-btn'), function(b){
    b.addEventListener('click', function(){ switchTo(b.dataset.mode); });
  });

  Array.prototype.forEach.call(document.querySelectorAll('.dur'), function(b){
    b.addEventListener('click', function(){
      if(mode !== 'work') return;
      Array.prototype.forEach.call(document.querySelectorAll('.dur'), function(x){
        x.setAttribute('aria-selected', String(x === b));
      });
      MODES.work.min = parseInt(b.dataset.work, 10);
      total = left = MODES.work.min * 60;
      running = false;
      if(ticker){clearInterval(ticker); ticker = null;}
      paint();
    });
  });

  document.addEventListener('keydown', function(e){
    if(e.code === 'Space' && e.target === document.body){
      e.preventDefault(); start();
    }
  });

  paint();
})();
</script>
</body>
</html>
```

---

## 10. 示例解析

### 10.1 为什么用双色而不是跟随强调色

```css
--pomo-work: #E5484D;    /* 专注 —— 红 */
--pomo-break: #14A37A;   /* 休息 —— 绿 */
```

番茄钟的语义依赖颜色区分状态（红=专注，绿=休息）。
如果跟随强调色，用户把主题改成红色时，「休息」也会变红，语义就废了。

**这类「有语义的颜色」应该写死**，而**装饰性颜色**才用变量。
本例中 `--text` / `--glass` 走变量，红绿写死——这是正确的取舍。

### 10.2 为什么容器要透明

```css
html,body{ background: transparent; }
.pomo{ /* 自己不设背景 */ }
```

拟雾动效下，`.window-content` 背景是 `rgba(0,0,0,.06)`，透出壁纸。
如果应用根元素设了不透明背景，壁纸就被完全挡住了，拟雾效果消失。

这是**最常见的适配错误**。

### 10.3 局部变量 `--pomo-current-rgb`

```js
document.getElementById('pomo').style.setProperty('--pomo-current-rgb', MODES[mode].rgb);
```

把「当前模式色」存成局部 CSS 变量，好处是：
- 进度环、按钮、圆点、模式标签全部引用同一个变量
- 切换模式时只改一个值，所有元件自动过渡
- 不需要 JS 逐个改样式

这是 CSS 变量的经典用法，值得学。

### 10.4 `stroke-dashoffset` 做进度环

```js
var CIRC = 2 * Math.PI * 44;         // r=44 的周长 = 276.46
$bar.style.strokeDashoffset = CIRC * (1 - ratio);
```

圆环「空多少」由偏移量控制。配合 `transition: stroke-dashoffset .35s linear`，
每秒走一格时动画是平滑的，不会一跳一跳。

### 10.5 为什么作用域要加 `.pomo`

`.btn`、`.dot`、`.time` 这些 class 名非常通用。
如果不加前缀，QP 自己如果有同名 class，就会互相污染。

本例所有组件都写在 `.pomo` 内部，且选择器来自 `.pomo .btn` 这样的嵌套结构
（HTML 结构上就在 `.pomo` 里），所以天然隔离。

### 10.6 深浅色都不额外写规则的原因

本例**没有**为深色/浅色写任何专门规则，只在拟雾下加了个 `backdrop-filter`。

因为它用的是 `--text` / `--text-faint` / `--glass` / `--glass-border`
这些「已经感知模式」的变量。模式切换时 QP 会改变量值，应用自动跟随。

**能不用 class 判断就不用**——变量优先是适配的原则。

### 10.7 空格键为什么判断 `e.target === document.body`

应用是内联注入的，`document` 是共享的。
如果不判断目标，用户在主界面其他窗口按空格也会触发番茄钟。

```js
if(e.code === 'Space' && e.target === document.body){ … }
```

同理，`notify()` 用 `window.parent.postMessage` 而不是直接调 QP 函数，
避免与应用脚本作用域耦合。

---

## 附：常见问题

**Q：应用打开后是灰底方块，没有我的样式？**

`<style>` 没被提取。检查是不是写在了 `<body>` 里——必须放在 `<head>`。
QP 只扫描 `<head>` 里的 `<style>` / `<link>`。

**Q：拟雾模式下看不清文字？**

文字用了写死的深色。改用 `var(--text)`，或者给拟雾单独加深底色：

```css
html.theme-glass .panel{ background: rgba(0,0,0,.35); }
```

**Q：装了主题包后字体全变了？**

主题包会全站替换字体。用 `!important` 锁定图标字体：

```css
.pomo .icon{ font-family:"Material Icons" !important; }
```

**Q：应用数据存哪？**

直接用 `localStorage`，键名加自己应用的前缀避免冲突：

```js
localStorage.setItem('pomo.state', JSON.stringify({…}));
```

---

*文档对应实现：`网页版/index.html` 中的 `currentThemePayload` / `broadcastThemeToApps` /
`openWindow`（自定义应用注入分支）。示例文件：`应用示例/番茄钟.html`。*
