feat: optimize docs pages and add 4 new doc articles (en + zh)

- Rewrote DocsListPage and DocDetailPage with landing.css aesthetic
  (icon cards, skeleton loader, prose styles, CTA box)
- Added docs-specific CSS to landing.css
- Created image-to-latex, copy-to-word, ocr-accuracy, pdf-extraction
  articles in both English and Chinese
- Updated DocsSeoSection guide cards to link to real doc slugs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-26 16:15:22 +08:00
parent dceb775a1b
commit 409bbf742e
14 changed files with 2855 additions and 67 deletions

View File

@@ -0,0 +1,66 @@
---
title: Copy to Word
description: Export recognized formulas directly into Microsoft Word as editable equations
slug: copy-to-word
date: 2026-03-25
tags: [export, Word, DOCX]
order: 4
---
# Copy to Word
TexPixel can export your recognized formulas directly into Microsoft Word as native, editable equations — not images. This means you can continue editing the formula inside Word after export.
## How to Export to Word
1. Upload your formula image and wait for recognition to complete.
2. Click the **Export** button in the result panel.
3. Select **DOCX** from the file export options.
4. Download the file and open it in Microsoft Word.
The downloaded `.docx` file contains your formula as a native Word equation (OMML format), which Word renders using its built-in equation editor.
## Why Use DOCX Export?
| Method | Editable in Word | Renders Correctly | Copy-Paste |
|---|---|---|---|
| Screenshot / image | No | Yes | No |
| LaTeX string | No (without plugin) | No | Yes |
| DOCX export | **Yes** | **Yes** | N/A |
The DOCX format is ideal when you need to:
- Submit homework or reports as Word documents
- Share formulas with colleagues who don't use LaTeX
- Continue editing the formula after export
## Inserting into an Existing Document
If you want to insert a formula into an existing Word document rather than starting fresh:
1. Open the downloaded `.docx` file in Word.
2. Select the equation and copy it (`Ctrl+C` / `Cmd+C`).
3. Paste it into your target document (`Ctrl+V` / `Cmd+V`).
Word preserves the equation formatting during paste.
## Mixed Content (Text + Formulas)
If your upload contains a mix of regular text and formulas (e.g., a textbook page), use DOCX export — it's the only format that handles mixed content correctly. LaTeX and MathML export are only available for pure-formula results.
> **Note:** For mixed-content results, LaTeX/MathML export is disabled. Use DOCX to get a properly formatted document with both text and equations.
## Compatibility
DOCX export is compatible with:
- Microsoft Word 2016 and later (Windows and Mac)
- Google Docs (equations render as images when imported)
- LibreOffice Writer (partial support)
## Tips
- After pasting into Word, double-click the equation to open the equation editor and make changes.
- If the formula looks different from expected, try re-uploading a higher-resolution image for a more accurate recognition result.
---
[Try exporting a formula to Word →](/app)

View File

@@ -0,0 +1,80 @@
---
title: Image to LaTeX
description: How to convert any formula image into clean LaTeX code with TexPixel
slug: image-to-latex
date: 2026-03-25
tags: [LaTeX, tutorial]
order: 2
---
# Image to LaTeX
TexPixel's core feature is converting formula images — from photos, scans, or screenshots — directly into LaTeX code you can paste anywhere.
## How It Works
1. **Upload your image** — Drag and drop a JPG or PNG into the upload zone, or click to browse. You can also paste from your clipboard.
2. **AI processes it** — Our model detects the formula region, runs OCR, and generates structured LaTeX in under a second.
3. **Copy the result** — Click the copy button next to the LaTeX output. Paste directly into Overleaf, VS Code, Word, or any LaTeX editor.
## Input Requirements
| Requirement | Details |
|---|---|
| File formats | JPG, PNG |
| Max file size | 10 MB |
| Recommended DPI | 150 DPI or higher |
| Background | White or light backgrounds work best |
## What Gets Recognized
TexPixel handles a wide range of mathematical content:
- **Algebra** — equations, inequalities, polynomials
- **Calculus** — derivatives, integrals, limits
- **Matrices** — 2×2 up to large arrays
- **Greek letters** — α, β, γ, Σ, Π, and more
- **Subscripts and superscripts** — `x_i^2`, `a_{n+1}`
- **Fractions** — `\frac{a}{b}`, nested fractions
- **Square roots and radicals** — `\sqrt{x}`, `\sqrt[n]{x}`
## Example
Uploading an image of the quadratic formula gives you:
```latex
x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}
```
An image of an integral:
```latex
\int_0^\infty e^{-x^2}\, dx = \frac{\sqrt{\pi}}{2}
```
## Tips for Best Results
- **Use clear images** — avoid blur, shadows, or low contrast
- **Crop tightly** — the less background, the better the focus
- **Dark ink on white paper** — ideal for handwritten formulas
- **Avoid rotated images** — keep the formula horizontal
- **One formula per image** — for complex multi-part work, crop each formula separately
## Limitations
- Extremely faint or pencil-written formulas may have lower accuracy
- Hand-drawn arrows or annotation marks outside the formula may be ignored
- Very large matrices (10×10+) may have reduced accuracy
## Copy Options
After recognition, you can copy output in multiple formats:
- **LaTeX** — raw LaTeX string
- **MathML** — for web embedding
- **Markdown** — `$...$` inline or `$$...$$` block
- **Plain text** — Unicode approximation
---
Ready to try it? [Upload a formula image now →](/app)

View File

@@ -0,0 +1,79 @@
---
title: OCR Accuracy
description: Understanding TexPixel recognition accuracy and how to get the best results
slug: ocr-accuracy
date: 2026-03-25
tags: [accuracy, tips]
order: 5
---
# OCR Accuracy
TexPixel achieves industry-leading accuracy on mathematical formula recognition — but accuracy isn't uniform across all input types. This guide explains what affects accuracy and how to maximize it.
## Accuracy by Formula Type
| Formula Type | Typical Accuracy |
|---|---|
| Printed formulas (textbooks, papers) | 9599% |
| Clean handwritten formulas | 8895% |
| Scanned documents (300 DPI+) | 9398% |
| Photos of whiteboards | 8292% |
| Low-resolution images (< 72 DPI) | 6080% |
These are approximate ranges. Individual results depend heavily on image quality.
## Factors That Affect Accuracy
### Image Quality
The single biggest factor. A blurry, low-resolution, or poorly lit image will always produce worse results than a clean scan.
- **Resolution** — 150 DPI or higher is recommended. 300 DPI is ideal for documents.
- **Contrast** — dark ink on a white background gives the clearest signal to the model.
- **Sharpness** — avoid motion blur or out-of-focus shots.
### Formula Complexity
Simple single-line equations are recognized with near-perfect accuracy. More complex structures may have occasional errors:
- Multi-line equation systems
- Large matrices (6×6 or larger)
- Heavily nested fractions (3+ levels deep)
- Non-standard notation or custom symbols
### Handwriting Style
Printed (typed) formulas outperform handwritten ones, but TexPixel handles handwriting well when:
- Letters are clearly formed and not connected (print style, not cursive)
- Variables are written in distinct sizes (clearly different x and × for example)
- Spacing between symbols is consistent
### What Reduces Accuracy
- **Rotated images** — formulas at an angle are harder to parse
- **Overlapping elements** — crossed-out work, annotations, or arrows near symbols
- **Pencil on paper** — low contrast; try increasing image brightness/contrast before uploading
- **Multiple formulas in one image** — crop to the specific formula you need
- **Decorative fonts** — calligraphic or stylized mathematical writing
## Improving Results
If you're getting errors, try these steps in order:
1. **Increase image resolution** — scan at 300 DPI instead of 150 DPI
2. **Improve contrast** — use a photo editor to increase brightness and contrast
3. **Crop tightly** — remove surrounding text and whitespace
4. **Straighten the image** — correct rotation before uploading
5. **Re-photograph** — better lighting, closer distance, sharper focus
## Reporting Errors
Found a formula type that TexPixel consistently gets wrong? Let us know — accuracy feedback directly improves the model over time.
Contact us at: [support@texpixel.com](mailto:support@texpixel.com)
---
[Upload a formula and test accuracy →](/app)

View File

@@ -0,0 +1,75 @@
---
title: PDF Extraction
description: Extract and convert formulas from PDF documents automatically with TexPixel
slug: pdf-extraction
date: 2026-03-25
tags: [PDF, extraction]
order: 6
---
# PDF Extraction
TexPixel can process entire PDF documents and extract every formula from every page — automatically. This is useful for textbooks, research papers, or any multi-page document with mathematical content.
## How to Extract from a PDF
1. Click the upload zone or drag and drop your PDF file.
2. TexPixel detects all pages and identifies formula regions.
3. Each recognized formula is listed in the result panel.
4. Copy individual formulas or export the entire document as DOCX.
## What Gets Extracted
TexPixel identifies formulas in PDFs regardless of whether they were:
- Typeset in LaTeX (rendered as vector math)
- Embedded as images (scanned pages)
- A mix of both
For vector PDFs (generated from LaTeX or Word), recognition accuracy is typically 95%+. For scanned/image PDFs, accuracy follows the same image quality guidelines as regular image uploads.
## Supported PDF Types
| Type | Description | Accuracy |
|---|---|---|
| Vector PDF | Created from LaTeX, Word, or typesetting tools | 9599% |
| Scanned PDF (high quality) | 300 DPI scan of printed text | 9097% |
| Scanned PDF (low quality) | < 150 DPI or poor contrast | 6080% |
| Photo PDF | Photographed pages embedded as images | 7590% |
## File Limits
- **Max file size:** 20 MB
- **Max pages:** 50 pages per upload (Pro plan: unlimited)
- **Processing time:** ~25 seconds per page
For documents exceeding these limits, split the PDF into smaller chunks before uploading.
## Exporting PDF Results
After extraction, you can export in several ways:
- **Copy individual formula** — click any recognized formula to copy its LaTeX
- **DOCX export** — download the full document with formulas as native Word equations
- **Batch copy** — copy all formulas as a list (Pro feature)
## Tips for Better PDF Results
- **Use the original PDF**, not a re-scanned copy — vector PDFs give the best results
- **Avoid password-protected PDFs** — these cannot be processed
- **Crop pages** if a PDF has wide margins with no content — smaller pages process faster
- **Split by chapter** for very large documents to stay within page limits
## Common Issues
**"No formulas found"**
The PDF may be encrypted, have formulas stored as complex vector paths, or use non-standard encoding. Try converting the page to a PNG image and uploading that instead.
**Formulas recognized but garbled**
This often happens with very low DPI scans. Try using a PDF scanner app to rescan at 300 DPI before uploading.
**Processing is slow**
Large PDFs with many pages can take 3060 seconds. This is normal. The result will appear when processing is complete.
---
[Upload a PDF and extract formulas →](/app)

View File

@@ -0,0 +1,66 @@
---
title: 导出到 Word
description: 将识别的公式直接导出到 Microsoft Word 中作为可编辑方程
slug: copy-to-word
date: 2026-03-25
tags: [导出, Word, DOCX]
order: 4
---
# 导出到 Word
TexPixel 可以将识别的公式直接导出到 Microsoft Word 中作为原生可编辑方程——而不是图片。这意味着导出后你可以在 Word 中继续编辑公式。
## 如何导出到 Word
1. 上传公式图片并等待识别完成。
2. 点击结果面板中的**导出**按钮。
3. 从文件导出选项中选择 **DOCX**
4. 下载文件并在 Microsoft Word 中打开。
下载的 `.docx` 文件包含以原生 Word 方程OMML 格式表示的公式Word 使用内置方程编辑器渲染。
## 为什么使用 DOCX 导出?
| 方式 | Word 中可编辑 | 正确渲染 | 复制粘贴 |
|---|---|---|---|
| 截图/图片 | 否 | 是 | 否 |
| LaTeX 字符串 | 否(无插件) | 否 | 是 |
| DOCX 导出 | **是** | **是** | N/A |
DOCX 格式非常适合以下情况:
- 提交 Word 格式的作业或报告
- 与不使用 LaTeX 的同事共享公式
- 导出后继续编辑公式
## 插入到现有文档
如果你想将公式插入现有 Word 文档而不是新建文档:
1. 在 Word 中打开下载的 `.docx` 文件。
2. 选中方程并复制(`Ctrl+C` / `Cmd+C`)。
3. 粘贴到目标文档(`Ctrl+V` / `Cmd+V`)。
Word 在粘贴时保留方程格式。
## 混合内容(文字 + 公式)
如果上传内容包含普通文字和公式的混合(例如教材页面),请使用 DOCX 导出——这是唯一能正确处理混合内容的格式。LaTeX 和 MathML 导出仅适用于纯公式结果。
> **注意:** 对于混合内容结果LaTeX/MathML 导出不可用。请使用 DOCX 获取包含文字和方程的格式正确文档。
## 兼容性
DOCX 导出与以下软件兼容:
- Microsoft Word 2016 及更高版本Windows 和 Mac
- Google 文档(导入时方程渲染为图片)
- LibreOffice Writer部分支持
## 提示
- 粘贴到 Word 后,双击方程打开方程编辑器进行修改。
- 如果公式与预期不同,请尝试上传更高分辨率的图片以获得更准确的识别结果。
---
[尝试将公式导出到 Word →](/app)

View File

@@ -0,0 +1,80 @@
---
title: 图片转 LaTeX
description: 如何使用 TexPixel 将任意公式图片转换为干净的 LaTeX 代码
slug: image-to-latex
date: 2026-03-25
tags: [LaTeX, 教程]
order: 2
---
# 图片转 LaTeX
TexPixel 的核心功能是将公式图片——来自照片、扫描件或截图——直接转换为可以粘贴到任何地方的 LaTeX 代码。
## 使用方法
1. **上传图片** — 将 JPG 或 PNG 拖拽到上传区域,或点击浏览文件。也可以直接从剪贴板粘贴。
2. **AI 处理** — 模型检测公式区域,运行 OCR在不到一秒内生成结构化 LaTeX。
3. **复制结果** — 点击 LaTeX 输出旁的复制按钮,直接粘贴到 Overleaf、VS Code、Word 或任意 LaTeX 编辑器。
## 输入要求
| 要求 | 详情 |
|---|---|
| 文件格式 | JPG、PNG |
| 最大文件大小 | 10 MB |
| 推荐分辨率 | 150 DPI 或更高 |
| 背景 | 白色或浅色背景效果最佳 |
## 支持识别的内容
TexPixel 可处理多种数学内容:
- **代数** — 方程、不等式、多项式
- **微积分** — 导数、积分、极限
- **矩阵** — 2×2 到大型数组
- **希腊字母** — α、β、γ、Σ、Π 等
- **上下标** — `x_i^2``a_{n+1}`
- **分数** — `\frac{a}{b}`、嵌套分数
- **根号** — `\sqrt{x}``\sqrt[n]{x}`
## 示例
上传二次公式图片,输出:
```latex
x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}
```
上传积分图片:
```latex
\int_0^\infty e^{-x^2}\, dx = \frac{\sqrt{\pi}}{2}
```
## 获得最佳结果的技巧
- **使用清晰图片** — 避免模糊、阴影或低对比度
- **紧密裁剪** — 背景越少,焦点越准确
- **白纸深色墨水** — 手写公式的理想条件
- **避免旋转图片** — 保持公式水平
- **每张图片一个公式** — 对于复杂的多部分作业,分别裁剪每个公式
## 局限性
- 非常淡或铅笔书写的公式准确率可能较低
- 公式外的手绘箭头或注释标记可能被忽略
- 非常大的矩阵10×10 以上)可能准确率降低
## 复制选项
识别完成后,可以多种格式复制输出:
- **LaTeX** — 原始 LaTeX 字符串
- **MathML** — 用于网页嵌入
- **Markdown** — 行内 `$...$` 或块级 `$$...$$`
- **纯文本** — Unicode 近似表示
---
准备好了吗?[立即上传公式图片 →](/app)

View File

@@ -0,0 +1,79 @@
---
title: 识别准确率
description: 了解 TexPixel 识别准确率及如何获得最佳效果
slug: ocr-accuracy
date: 2026-03-25
tags: [准确率, 技巧]
order: 5
---
# 识别准确率
TexPixel 在数学公式识别方面达到行业领先的准确率——但准确率在不同输入类型之间并不统一。本指南解释影响准确率的因素以及如何最大化识别效果。
## 按公式类型的准确率
| 公式类型 | 典型准确率 |
|---|---|
| 印刷体公式(教材、论文) | 9599% |
| 清晰手写公式 | 8895% |
| 扫描文档300 DPI+ | 9398% |
| 白板照片 | 8292% |
| 低分辨率图片(< 72 DPI | 6080% |
这些是大致范围,实际结果在很大程度上取决于图片质量。
## 影响准确率的因素
### 图片质量
这是最重要的单一因素。模糊、低分辨率或光线不佳的图片效果始终不如清晰扫描件。
- **分辨率** — 建议 150 DPI 或更高,文档理想为 300 DPI
- **对比度** — 白色背景上的深色墨水为模型提供最清晰的信号
- **清晰度** — 避免运动模糊或对焦不准
### 公式复杂度
简单的单行方程识别准确率接近完美。更复杂的结构可能偶有错误:
- 多行方程组
- 大矩阵6×6 或更大)
- 深度嵌套分数3 层以上)
- 非标准符号或自定义符号
### 手写风格
印刷体打字公式优于手写体但当以下条件满足时TexPixel 能很好地处理手写:
- 字母清晰成形且不连笔(印刷体,而非草书)
- 变量写成明显不同的大小(例如 x 和 × 清晰区分)
- 符号间距一致
### 降低准确率的因素
- **旋转图片** — 倾斜的公式更难解析
- **重叠元素** — 划掉的内容、注释或符号附近的箭头
- **纸上铅笔** — 对比度低;上传前可尝试增加图片亮度/对比度
- **一张图片多个公式** — 裁剪到你需要的具体公式
- **装饰字体** — 花体或风格化数学书写
## 提高识别效果
如果识别出错,按以下顺序尝试:
1. **提高图片分辨率** — 用 300 DPI 扫描代替 150 DPI
2. **改善对比度** — 使用图片编辑器提高亮度和对比度
3. **紧密裁剪** — 去除周围文字和空白
4. **矫正图片** — 上传前纠正旋转
5. **重新拍摄** — 更好的光线、更近的距离、更清晰的对焦
## 反馈错误
发现 TexPixel 持续识别错误的公式类型?请告知我们——准确率反馈直接改进模型。
联系我们:[support@texpixel.com](mailto:support@texpixel.com)
---
[上传公式测试识别准确率 →](/app)

View File

@@ -0,0 +1,75 @@
---
title: PDF 公式提取
description: 使用 TexPixel 自动从 PDF 文档中提取并转换公式
slug: pdf-extraction
date: 2026-03-25
tags: [PDF, 提取]
order: 6
---
# PDF 公式提取
TexPixel 可以处理完整的 PDF 文档,自动从每一页提取所有公式。这对教材、研究论文或任何包含数学内容的多页文档非常有用。
## 如何从 PDF 提取
1. 点击上传区域或将 PDF 文件拖拽到其中。
2. TexPixel 检测所有页面并识别公式区域。
3. 每个识别的公式列在结果面板中。
4. 复制单个公式或将整个文档导出为 DOCX。
## 提取内容
无论 PDF 中的公式是如何生成的TexPixel 都能识别:
- 用 LaTeX 排版(渲染为矢量数学)
- 嵌入为图片(扫描页面)
- 两种混合
对于矢量 PDF由 LaTeX 或 Word 生成),识别准确率通常为 95% 以上。对于扫描/图片 PDF准确率遵循与普通图片上传相同的图片质量准则。
## 支持的 PDF 类型
| 类型 | 描述 | 准确率 |
|---|---|---|
| 矢量 PDF | 由 LaTeX、Word 或排版工具创建 | 9599% |
| 扫描 PDF高质量 | 印刷文字的 300 DPI 扫描 | 9097% |
| 扫描 PDF低质量 | < 150 DPI 或对比度差 | 6080% |
| 照片 PDF | 嵌入为图片的拍照页面 | 7590% |
## 文件限制
- **最大文件大小:** 20 MB
- **最大页数:** 每次上传 50 页(专业版:无限制)
- **处理时间:** 每页约 25 秒
对于超出限制的文档,上传前将 PDF 分割成较小的部分。
## 导出 PDF 识别结果
提取后,可以多种方式导出:
- **复制单个公式** — 点击任意识别的公式复制其 LaTeX
- **DOCX 导出** — 下载包含原生 Word 方程的完整文档
- **批量复制** — 将所有公式复制为列表(专业版功能)
## 提高 PDF 识别效果的技巧
- **使用原始 PDF**,而非重新扫描的副本——矢量 PDF 效果最佳
- **避免密码保护的 PDF**——这类文件无法处理
- 如果 PDF 有很宽的空白边距,**裁剪页面**——较小的页面处理更快
- 对于非常大的文档,**按章节分割**以保持在页数限制内
## 常见问题
**"未找到公式"**
PDF 可能已加密,公式可能以复杂矢量路径存储,或使用了非标准编码。尝试将页面转换为 PNG 图片后再上传。
**公式已识别但内容乱码**
这通常发生在非常低 DPI 的扫描件上。尝试在上传前使用 PDF 扫描应用以 300 DPI 重新扫描。
**处理速度慢**
包含多页的大型 PDF 可能需要 3060 秒。这是正常的,处理完成后结果会显示。
---
[上传 PDF 提取公式 →](/app)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,129 @@
# Landing Page Refactor — Design Spec
**Date:** 2026-03-26
**Status:** Approved
## Goal
Replace all existing marketing home components with content and styles from `texpixel-landing.html`. The UI/UX must exactly match the reference file. The home page is a marketing/broadcast page; all CTAs navigate to `/app`.
---
## CSS Strategy
- Extract the full `<style>` block (lines 101593) from `texpixel-landing.html` into `src/styles/landing.css`.
- **Scope all rules** under a `.marketing-page` wrapper class to prevent bleed into the `/app` workspace.
- Body-level rules (`body { background }`, `body::before` grid overlay) are converted to `.marketing-page` and `.marketing-page::before` respectively.
- `:root` CSS variable declarations are kept as-is since landing variables use different names (`--primary`, `--bg`, etc.) from existing workspace variables (`--color-primary`, `--color-bg`). No conflict — they coexist.
- **Do NOT import in `main.tsx`** — import directly in `MarketingLayout.tsx` via `import '../styles/landing.css'` so it only applies to marketing routes.
- `MarketingLayout.tsx` wrapper div gets `className="marketing-page"`.
- `index.css` Tailwind layer remains untouched.
- Add Google Fonts to `index.html` `<head>`: Lora (serif, weights 400/600/700) and JetBrains Mono (monospace, weights 400/500). DM Sans already present.
---
## Component Mapping
| Reference section | Target file | Action |
|---|---|---|
| `<nav>` | `src/components/layout/MarketingNavbar.tsx` | Replace |
| `.hero` | `src/components/home/HeroSection.tsx` | Replace |
| `.product-suite` | `src/components/home/ProductSuiteSection.tsx` | New |
| `.core-features` | `src/components/home/FeaturesSection.tsx` | Replace |
| `.showcase` | `src/components/home/ShowcaseSection.tsx` | New |
| `.user-love` | `src/components/home/TestimonialsSection.tsx` | New |
| `.pricing` | `src/components/home/PricingSection.tsx` | Replace |
| `.docs-seo` | `src/components/home/DocsSeoSection.tsx` | New |
| `<footer>` | `src/components/layout/Footer.tsx` | Replace |
### Delete (no reference equivalent)
- `src/components/home/HowItWorksSection.tsx`
- `src/components/home/ContactSection.tsx`
---
## MarketingLayout.tsx
- Wrap outlet in `<div className="marketing-page">` — applies scoped landing CSS
- Render three `.glow-blob` divs (`.glow-blob-1`, `.glow-blob-2`, `.glow-blob-3`) as direct children of the `.marketing-page` wrapper — these are `position: fixed` ambient background elements visible across all marketing pages.
---
## HomePage.tsx
Update to render sections in order:
```
HeroSection
<div className="section-divider" />
ProductSuiteSection
FeaturesSection
ShowcaseSection
<div className="section-divider" />
TestimonialsSection
<div className="section-divider" />
PricingSection
<div className="section-divider" />
DocsSeoSection
```
Section dividers are plain `<div className="section-divider" />` JSX inlined in `HomePage.tsx` — no abstraction needed.
---
## Navbar (MarketingNavbar.tsx)
- Sticky, height 72px, backdrop blur on scroll (existing scroll state logic kept)
- Logo: SVG icon (lines symbol) + "TexPixel" text — **remove `font-display` Tailwind class** from logo `<span>`, replace with `style={{ fontFamily: "'Lora', serif" }}` to avoid Plus Jakarta Sans conflict
- Nav links: Home `/`, Docs `/docs`, Blog `/blog`, Pricing `#pricing` (anchor on home only), **no Contact link** — also remove the existing `anchorLinks` `#contact` entry
- Right side:
- Lang switch button (existing `useLanguage` toggle)
- User avatar/menu using `const { user, signOut } = useAuth()` — show avatar dropdown when `user !== null`; show "登录/Login" CTA button when `user === null`
- Avatar dropdown items: "启动应用" → `/app`, then logout (calls `signOut()`). **No profile settings link** (route does not exist — omitted)
- "Try Free" CTA button → `/app`
- i18n: `useLanguage` for all labels
- Remove unused `t.marketing.nav.contact` references from this component
---
## JS Behaviors → React hooks/useEffect
### Scroll Reveal Hook
- **Create `src/hooks/` directory** (does not exist yet)
- Create `src/hooks/useScrollReveal.ts` — sets up a single `IntersectionObserver` targeting all `.reveal` elements, adds `.visible` class on intersection
- Called once in `HomePage.tsx` via `useScrollReveal()`
### Nav Active on Scroll
- `useEffect` in `MarketingNavbar.tsx` — watches `window.scroll`, adds `.active` class to nav link matching current section `id`
### Testimonial Carousel (TestimonialsSection.tsx)
- React state: `currentPage` (0-indexed), 6 cards, 3 visible, 4 pages
- `useEffect` auto-advances every 5s, resets on manual navigation
- Prev/Next buttons + rendered dots
- Window resize recalcs slide offset
### Typing Effect (HeroSection.tsx)
- `useRef` on `.output-code` element
- `useEffect` cycles through 3 LaTeX strings every 3500ms via `innerHTML` + cursor span
---
## CTA Links
- "Try TexPixel", "Try Free", "Get Started" (Free/Monthly/Quarterly plans) → `<Link to="/app">`
- "Buy Desktop" → `<Link to="/app">` (placeholder, no separate purchase flow)
- Doc card links → `/docs`
- Footer blog link → `/blog`
- Pricing anchor `#pricing``<a href="#pricing">`
---
## Content / i18n
- All text from the reference HTML is hardcoded in components (Chinese/English bilingual where reference already has it)
- Existing `useLanguage` / `t` translations are used where keys already exist
- New section text is **hardcoded** (not added to `translations.ts`) — the reference HTML content is the source of truth; full i18n for new sections is out of scope for this refactor
---
## Cleanup
- Remove `contact` key from `marketing.nav` in `src/lib/translations.ts` (both `en` and `zh` blocks) — becomes dead code after ContactSection and `#contact` link are removed.
---
## What Does NOT Change
- `src/App.tsx`, routing (`AppRouter.tsx`), auth system, workspace (`WorkspacePage`)
- `index.css` Tailwind layer
- Docs/Blog pages
- `SEOHead` component usage in `HomePage.tsx`
- `tailwind.config.js`

View File

@@ -3,6 +3,7 @@ import { useLanguage } from '../../contexts/LanguageContext';
const GUIDES = [
{
slug: 'image-to-latex',
svgPaths: (
<>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
@@ -15,6 +16,7 @@ const GUIDES = [
metaZh: '5 分钟 · 最受欢迎',
},
{
slug: 'copy-to-word',
svgPaths: (
<>
<rect x="4" y="4" width="16" height="16" rx="2"/>
@@ -27,6 +29,7 @@ const GUIDES = [
metaZh: '4 分钟 · 扩展用户',
},
{
slug: 'ocr-accuracy',
svgPaths: (
<>
<circle cx="11" cy="11" r="8"/>
@@ -39,6 +42,7 @@ const GUIDES = [
metaZh: '6 分钟 · 进阶用户',
},
{
slug: 'pdf-extraction',
svgPaths: (
<>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
@@ -71,7 +75,7 @@ export default function DocsSeoSection() {
<div className="doc-cards reveal">
{GUIDES.map((g, i) => (
<Link key={i} to="/docs" className="doc-card">
<Link key={i} to={`/docs/${g.slug}`} className="doc-card">
<div className="doc-card-left">
<div className="doc-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">

View File

@@ -1,35 +1,78 @@
import { useEffect, useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { ArrowLeft } from 'lucide-react';
import SEOHead from '../components/seo/SEOHead';
import { useLanguage } from '../contexts/LanguageContext';
import { loadContent, type ContentItem } from '../lib/content';
function estimateReadTime(html: string): number {
const text = html.replace(/<[^>]+>/g, '');
const words = text.split(/\s+/).filter(Boolean).length;
return Math.max(2, Math.round(words / 200));
}
function formatDate(dateStr: string, lang: string): string {
const d = new Date(dateStr);
if (isNaN(d.getTime())) return dateStr;
return d.toLocaleDateString(lang === 'zh' ? 'zh-CN' : 'en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
export default function DocDetailPage() {
const { slug } = useParams<{ slug: string }>();
const { language } = useLanguage();
const [content, setContent] = useState<ContentItem | null>(null);
const [notFound, setNotFound] = useState(false);
useEffect(() => {
setContent(null);
setNotFound(false);
if (slug) {
loadContent('docs', language, slug)
.then(setContent)
.catch(() => setContent(null));
.catch(() => setNotFound(true));
}
}, [slug, language]);
if (!content) {
const zh = language === 'zh';
if (notFound) {
return (
<div className="max-w-3xl mx-auto py-20 px-6">
<div className="animate-pulse space-y-4">
<div className="h-4 bg-cream-200 rounded w-24" />
<div className="h-8 bg-cream-200 rounded w-3/4" />
<div className="h-4 bg-cream-200 rounded w-full" />
<div className="docs-detail">
<Link to="/docs" className="docs-back-link">
<svg viewBox="0 0 24 24"><path d="M15 18l-6-6 6-6" /></svg>
{zh ? '所有文档' : 'All docs'}
</Link>
<div style={{ textAlign: 'center', padding: '64px 0', color: 'var(--text-muted)' }}>
<p style={{ fontSize: '18px', marginBottom: '8px' }}>
{zh ? '文档未找到' : 'Doc not found'}
</p>
<Link to="/docs" style={{ color: 'var(--primary)', fontSize: '14px' }}>
{zh ? '返回文档中心 →' : 'Back to docs →'}
</Link>
</div>
</div>
);
}
if (!content) {
return (
<div className="docs-skeleton-wrap">
<div className="skeleton-line" style={{ width: '80px', height: '14px', marginBottom: '44px' }} />
<div className="skeleton-line" style={{ width: '60%', height: '44px', marginBottom: '16px' }} />
<div className="skeleton-line" style={{ width: '40%', height: '16px', marginBottom: '48px' }} />
<div className="skeleton-line" style={{ width: '100%', height: '16px' }} />
<div className="skeleton-line" style={{ width: '92%', height: '16px' }} />
<div className="skeleton-line" style={{ width: '96%', height: '16px' }} />
<div className="skeleton-line" style={{ width: '80%', height: '16px' }} />
</div>
);
}
const readTime = estimateReadTime(content.html);
return (
<>
<SEOHead
@@ -37,29 +80,54 @@ export default function DocDetailPage() {
description={content.meta.description}
path={`/docs/${slug}`}
/>
<div className="max-w-3xl mx-auto py-16 lg:py-20 px-6">
{/* Back link */}
<Link
to="/docs"
className="inline-flex items-center gap-1.5 text-sm text-ink-muted hover:text-ink transition-colors mb-8"
>
<ArrowLeft size={14} />
{language === 'en' ? 'All docs' : '所有文档'}
<div className="docs-detail">
{/* Back */}
<Link to="/docs" className="docs-back-link">
<svg viewBox="0 0 24 24"><path d="M15 18l-6-6 6-6" /></svg>
{zh ? '所有文档' : 'All docs'}
</Link>
{/* Doc body */}
<article
className="prose prose-lg prose-warm max-w-none
prose-headings:font-display prose-headings:tracking-tight
prose-h1:text-3xl prose-h1:font-bold
prose-h2:text-2xl prose-h2:font-semibold prose-h2:mt-10
prose-a:text-sage-700 prose-a:no-underline hover:prose-a:underline
prose-code:text-sage-700 prose-code:bg-sage-50 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded-md prose-code:text-sm
prose-pre:bg-ink prose-pre:text-cream-100 prose-pre:rounded-xl
prose-img:rounded-xl
prose-blockquote:border-sage-300 prose-blockquote:bg-sage-50/30 prose-blockquote:rounded-r-xl prose-blockquote:py-1"
{/* Article header */}
<div className="docs-article-header">
{content.meta.tags.length > 0 && (
<div className="docs-article-tags">
{content.meta.tags.map(tag => (
<span key={tag} className="docs-tag">{tag}</span>
))}
</div>
)}
<h1 className="docs-article-h1">{content.meta.title}</h1>
<div className="docs-meta-row">
<span>{formatDate(content.meta.date, language)}</span>
<span className="docs-meta-sep">·</span>
<span>{readTime} {zh ? '分钟阅读' : 'min read'}</span>
</div>
</div>
{/* Article body */}
<div
className="docs-prose"
dangerouslySetInnerHTML={{ __html: content.html }}
/>
{/* CTA */}
<div className="docs-cta-box">
<div className="docs-cta-title">
{zh ? '准备好试试了吗?' : 'Ready to try it yourself?'}
</div>
<p className="docs-cta-desc">
{zh
? '上传一张公式图片,秒级获得 LaTeX 输出——无需注册。'
: 'Upload a formula image and get LaTeX output in under a second — no sign-up needed.'}
</p>
<Link to="/app" className="btn btn-primary" style={{ display: 'inline-flex' }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2">
<path d="M5 3l14 9-14 9V3z" />
</svg>
{zh ? '免费试用 TexPixel' : 'Try TexPixel Free'}
</Link>
</div>
</div>
</>
);

View File

@@ -1,10 +1,69 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { ArrowRight, BookOpen, FileText } from 'lucide-react';
import SEOHead from '../components/seo/SEOHead';
import { useLanguage } from '../contexts/LanguageContext';
import { loadManifest, type ContentMeta } from '../lib/content';
function estimateReadTime(desc: string): number {
return Math.max(2, Math.round(desc.split(/\s+/).length / 3));
}
const DOC_ICONS: Record<string, JSX.Element> = {
'getting-started': (
<>
<path d="M5 3l14 9-14 9V3z" />
</>
),
'image-to-latex': (
<>
<rect x="3" y="3" width="18" height="14" rx="2" />
<path d="M3 9h18" />
<path d="M9 21l3-3 3 3" />
</>
),
'supported-formats': (
<>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<path d="M8 13h8M8 17h5" />
</>
),
'copy-to-word': (
<>
<rect x="4" y="4" width="16" height="16" rx="2" />
<path d="M8 9h8M8 12h6M8 15h4" />
</>
),
'ocr-accuracy': (
<>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</>
),
'pdf-extraction': (
<>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<path d="M14 2v6h6M10 12l2 2 4-4" />
</>
),
faq: (
<>
<circle cx="12" cy="12" r="10" />
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</>
),
};
function DocIcon({ slug }: { slug: string }) {
const paths = DOC_ICONS[slug] ?? DOC_ICONS['supported-formats'];
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
{paths}
</svg>
);
}
export default function DocsListPage() {
const { language } = useLanguage();
const [docs, setDocs] = useState<ContentMeta[]>([]);
@@ -15,57 +74,63 @@ export default function DocsListPage() {
});
}, [language]);
const zh = language === 'zh';
return (
<>
<SEOHead title="Documentation" description="TexPixel documentation and guides" path="/docs" />
<SEOHead
title={zh ? 'TexPixel 文档中心' : 'TexPixel Documentation'}
description={zh ? '公式识别入门指南、格式说明与常见问题。' : 'Guides, format references, and answers for getting the most out of TexPixel.'}
path="/docs"
/>
<div className="max-w-5xl mx-auto py-16 lg:py-20 px-6">
<div className="docs-page">
{/* Header */}
<div className="mb-12">
<div className="inline-flex items-center gap-2 px-3 py-1.5 bg-sage-50 border border-sage-100 rounded-full text-xs font-medium text-sage-700 mb-6">
<BookOpen size={13} />
{language === 'en' ? 'Documentation' : '文档中心'}
</div>
<h1 className="font-display text-4xl lg:text-5xl font-bold text-ink tracking-tight mb-4">
{language === 'en' ? 'Learn TexPixel' : '了解 TexPixel'}
<div className="docs-page-header">
<div className="eyebrow">{zh ? '文档中心' : 'Documentation'}</div>
<h1 className="docs-page-title">
{zh ? '学习 TexPixel' : 'Learn TexPixel'}
</h1>
<p className="text-ink-secondary text-lg max-w-xl">
{language === 'en'
? 'Everything you need to get started with formula recognition.'
: '公式识别入门所需的一切。'}
<p className="docs-page-subtitle">
{zh
? '公式识别入门所需的一切——上传技巧、格式说明与常见问题。'
: 'Everything you need to get the most out of formula recognition — upload tips, format guides, and FAQs.'}
</p>
</div>
{/* Docs grid */}
<div className="space-y-4">
{/* List */}
<div className="docs-list">
{docs.map((doc) => (
<Link
key={doc.slug}
to={`/docs/${doc.slug}`}
className="card p-6 flex items-start gap-5 group hover:border-sage-200 block"
>
<div className="w-11 h-11 bg-sage-50 rounded-xl flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform">
<FileText size={20} className="text-sage-600" />
<Link key={doc.slug} to={`/docs/${doc.slug}`} className="docs-article-card">
<div className="docs-article-card-inner">
<div className="docs-article-icon">
<DocIcon slug={doc.slug} />
</div>
<div className="docs-article-body">
<div className="docs-article-title">{doc.title}</div>
<div className="docs-article-desc">{doc.description}</div>
<div className="docs-article-meta">
{doc.tags.slice(0, 2).map(tag => (
<span key={tag} className="docs-tag">{tag}</span>
))}
<span className="docs-read-time">
{estimateReadTime(doc.description)} {zh ? '分钟阅读' : 'min read'}
</span>
</div>
</div>
<svg className="docs-article-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 18l6-6-6-6" />
</svg>
</div>
<div className="flex-1 min-w-0">
<h2 className="font-display text-lg font-semibold text-ink mb-1 group-hover:text-sage-700 transition-colors">
{doc.title}
</h2>
<p className="text-ink-secondary text-sm leading-relaxed">{doc.description}</p>
</div>
<ArrowRight size={16} className="text-ink-muted group-hover:text-sage-600 transition-colors mt-1 flex-shrink-0" />
</Link>
))}
</div>
{/* Empty state */}
{docs.length === 0 && (
<div className="card p-12 text-center">
<p className="text-ink-muted text-sm">
{language === 'en' ? 'Documentation coming soon.' : '文档即将发布。'}
</p>
</div>
)}
{docs.length === 0 && (
<div className="docs-empty">
{zh ? '文档即将发布。' : 'Documentation coming soon.'}
</div>
)}
</div>
</div>
</>
);

View File

@@ -1571,3 +1571,402 @@
.reveal-delay-3 { transition-delay: 0.30s; }
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0} }
/* ═══════════════════════════════════════════════════════
DOCS PAGES
═══════════════════════════════════════════════════════ */
/* ── Docs list page ── */
.docs-page {
max-width: 820px;
margin: 0 auto;
padding: 64px 24px 96px;
}
.docs-page-header {
margin-bottom: 52px;
}
.docs-page-header .eyebrow {
margin-bottom: 14px;
}
.docs-page-title {
font-family: 'Lora', serif;
font-size: 44px;
font-weight: 700;
line-height: 1.1;
letter-spacing: -0.02em;
color: var(--text-strong);
margin-bottom: 14px;
}
.docs-page-subtitle {
font-size: 17px;
color: var(--text-body);
line-height: 1.65;
max-width: 480px;
}
.docs-list {
display: flex;
flex-direction: column;
gap: 14px;
}
.docs-article-card {
background: var(--elevated);
border: 1.5px solid var(--border);
border-radius: var(--r-l);
padding: 28px 32px;
text-decoration: none;
display: block;
transition: all 0.2s ease;
box-shadow: var(--shadow-soft);
}
.docs-article-card:hover {
transform: translateY(-2px);
border-color: var(--primary-light);
box-shadow: var(--shadow-float);
}
.docs-article-card-inner {
display: flex;
align-items: flex-start;
gap: 20px;
}
.docs-article-icon {
width: 46px;
height: 46px;
background: var(--warm-wash);
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.docs-article-icon svg {
width: 22px;
height: 22px;
stroke: var(--primary);
fill: none;
stroke-width: 1.8;
}
.docs-article-body {
flex: 1;
min-width: 0;
}
.docs-article-title {
font-family: 'Lora', serif;
font-size: 19px;
font-weight: 700;
color: var(--text-strong);
margin-bottom: 6px;
letter-spacing: -0.01em;
transition: color 0.15s;
}
.docs-article-card:hover .docs-article-title {
color: var(--primary);
}
.docs-article-desc {
font-size: 14px;
color: var(--text-body);
line-height: 1.6;
margin-bottom: 12px;
}
.docs-article-meta {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.docs-tag {
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 10px;
border-radius: 6px;
background: var(--warm-wash);
color: var(--primary);
font-size: 11px;
font-weight: 600;
text-transform: lowercase;
}
.docs-read-time {
font-size: 12px;
color: var(--text-muted);
}
.docs-article-arrow {
color: var(--text-muted);
flex-shrink: 0;
margin-top: 2px;
transition: color 0.15s, transform 0.15s;
}
.docs-article-card:hover .docs-article-arrow {
color: var(--primary);
transform: translateX(3px);
}
.docs-empty {
background: var(--elevated);
border: 1.5px solid var(--border);
border-radius: var(--r-l);
padding: 48px;
text-align: center;
color: var(--text-muted);
font-size: 15px;
}
/* ── Docs detail page ── */
.docs-detail {
max-width: 760px;
margin: 0 auto;
padding: 48px 24px 96px;
}
.docs-back-link {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: var(--text-muted);
text-decoration: none;
margin-bottom: 44px;
transition: color 0.15s;
font-weight: 500;
}
.docs-back-link:hover { color: var(--primary); }
.docs-back-link svg {
width: 15px;
height: 15px;
stroke: currentColor;
fill: none;
stroke-width: 2;
}
.docs-article-header {
margin-bottom: 44px;
padding-bottom: 36px;
border-bottom: 1px solid var(--border);
}
.docs-article-tags {
display: flex;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.docs-article-h1 {
font-family: 'Lora', serif;
font-size: 40px;
font-weight: 700;
line-height: 1.15;
letter-spacing: -0.02em;
color: var(--text-strong);
margin-bottom: 18px;
}
.docs-meta-row {
display: flex;
align-items: center;
gap: 16px;
font-size: 13px;
color: var(--text-muted);
flex-wrap: wrap;
}
.docs-meta-sep { opacity: 0.4; }
/* ── Docs prose body ── */
.docs-prose {
font-family: 'DM Sans', sans-serif;
font-size: 16px;
line-height: 1.8;
color: var(--text-body);
}
.docs-prose > h1:first-child { display: none; }
.docs-prose h2 {
font-family: 'Lora', serif;
font-size: 26px;
font-weight: 700;
color: var(--text-strong);
margin: 52px 0 16px;
letter-spacing: -0.01em;
padding-bottom: 10px;
border-bottom: 1px solid var(--border);
}
.docs-prose h3 {
font-family: 'Lora', serif;
font-size: 20px;
font-weight: 600;
color: var(--text-strong);
margin: 32px 0 10px;
}
.docs-prose h4 {
font-size: 16px;
font-weight: 700;
color: var(--text-strong);
margin: 24px 0 8px;
}
.docs-prose p { margin-bottom: 20px; }
.docs-prose ul,
.docs-prose ol {
padding-left: 24px;
margin-bottom: 20px;
}
.docs-prose li { margin-bottom: 8px; }
.docs-prose strong {
color: var(--text-strong);
font-weight: 600;
}
.docs-prose a {
color: var(--primary);
text-decoration: underline;
text-decoration-color: var(--primary-light);
text-underline-offset: 3px;
}
.docs-prose a:hover { text-decoration-color: var(--primary); }
.docs-prose code {
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
background: var(--warm-wash);
color: var(--primary);
padding: 2px 7px;
border-radius: 6px;
word-break: break-all;
}
.docs-prose pre {
background: var(--text-strong);
color: #f1e6d8;
border-radius: var(--r-m);
padding: 20px 24px;
overflow-x: auto;
margin: 28px 0;
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
line-height: 1.7;
}
.docs-prose pre code {
background: none;
color: inherit;
padding: 0;
font-size: inherit;
word-break: normal;
}
.docs-prose blockquote {
border-left: 3px solid var(--primary-light);
background: var(--warm-wash);
padding: 16px 20px;
border-radius: 0 var(--r-s) var(--r-s) 0;
margin: 28px 0;
color: var(--text-body);
font-style: italic;
}
.docs-prose table {
width: 100%;
border-collapse: collapse;
margin: 28px 0;
font-size: 14px;
}
.docs-prose th {
background: var(--bg);
color: var(--text-strong);
font-weight: 700;
padding: 10px 16px;
text-align: left;
border: 1px solid var(--border);
font-size: 13px;
}
.docs-prose td {
padding: 10px 16px;
border: 1px solid var(--border);
color: var(--text-body);
vertical-align: top;
}
.docs-prose tr:nth-child(even) td { background: var(--bg); }
.docs-prose .katex-display {
margin: 32px 0;
overflow-x: auto;
padding: 20px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--r-m);
}
/* ── Docs CTA box ── */
.docs-cta-box {
margin-top: 64px;
padding: 44px 48px;
background: linear-gradient(135deg, var(--warm-wash) 0%, var(--bg) 100%);
border: 1.5px solid var(--border);
border-radius: var(--r-xl);
text-align: center;
}
.docs-cta-title {
font-family: 'Lora', serif;
font-size: 26px;
font-weight: 700;
color: var(--text-strong);
margin-bottom: 10px;
}
.docs-cta-desc {
font-size: 15px;
color: var(--text-body);
margin-bottom: 28px;
line-height: 1.65;
}
/* ── Skeleton loader ── */
.docs-skeleton-wrap {
max-width: 760px;
margin: 0 auto;
padding: 48px 24px;
}
.skeleton-line {
background: var(--border);
border-radius: 6px;
margin-bottom: 14px;
animation: skeleton-pulse 1.6s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 0.7; }
50% { opacity: 0.35; }
}