# Project export: Aipen for ChatGPT: Turn Any Answer into Handwritten Notes

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: OpenAI Build Week
- Tagline: Turn any ChatGPT answer into hyper-realistic handwritten, print-ready notes without leaving the chat. Math, science, essays, and more, generated on demand. Major European language supported!
- Devpost: https://devpost.com/software/aipen-for-chatgpt
- GitHub: https://github.com/MunBohora/aipen-chatgpt-app.git
- Demo: https://aipen-mcp-511200400258.us-central1.run.app/mcp
- Video: https://www.youtube.com/embed/SdR5WnF-BUk?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 1 GitHub contributor(s) — Moon Bohora (13 commits)

## Devpost submission (written by the team)

### Overview

In the age of AI, the human touch still matters! Since childhood, I have always learned best through handwritten notes, but writing them out simply takes too much time. Finding a way to automate this is incredibly difficult. Genuine handwriting has natural variations, and no two characters ever look exactly the same. I searched everywhere for a solution. There were no GitHub repositories or paid SaaS tools that could convert digital text into truly natural handwriting. That does not even include the massive challenge of generating handwritten math equations and graphs. The challenge was huge, but I knew people desperately needed a way to create authentic handwritten notes quickly. After a year of relentless hacking and testing to solve this exact problem, the solution is finally here.

### What it does

Aipen for ChatGPT is a native ChatGPT app. Ask it for study notes, a worked math solution, a letter, or a report, and it renders the answer as a highly realistic, handwritten page right inside your conversation. Proper math instead of screenshots using real MathJax rendered by hand. Accurate hand drawn graphs and pie charts. GPT-5.6 computes the data with Python first so they are actually correct. Humanized handwriting. Every repeated letter is drawn a little differently so it reads like a real person wrote it rather than a font. A print ready vector PDF you can download and hand in. One prompt, under a minute, without ever leaving ChatGPT. We built this for study, revision, and accessibility to help people who learn best by writing.

### How we built it

The core engine handles handwritten math, graphs, pagination, and glyph humanization. We built it using Codex and GPT-5.6: An MCP server (OpenAI Apps SDK) exposing a make_handwriting tool. A headless Playwright pipeline that opens the render URL for Aipen, waits for the page to truly finish drawing, and captures per page previews plus a true vector PDF via Chromium's page.pdf(). An inline ChatGPT UI card featuring previews, page thumbnails, style controls, and a download button. Cloud Run and private Google Cloud Storage for durable artifacts, complete with per IP rate limits and spoof proof asset origins. The tool guidance instructs GPT-5.6 to calculate numeric results with Python before rendering. This means graphs and worked answers are based on real values rather than guessed numbers.

### Challenges we ran into

We ran into quite a few walls, but each one taught me something valuable: The renderer draws in the browser, so I built a headless render mode and had to make it signal exactly when the ink was really on the paper. My first captures came back totally blank! Math renders asynchronously. The capture kept firing before MathJax finished, leaving grey placeholders behind. A video embed step in the trailer quietly corrupted the audio timeline. Late narration silently vanished until I logged the exact placement times and moved the mix into ffmpeg. The recurring lesson was simple: measure, do not guess.

### Accomplishments we're proud of

It genuinely works end to end and it is fully deployed. A judge can paste one URL into ChatGPT and be using it in under a minute. Real handwritten math, graphs, and pie charts. It is not just a wrapper around a screenshot. A hardened, public ready service rather than just a demo. It features per IP limits, private artifact storage, and single source rendering. We built the entire integration with Codex, taking it from an empty folder all the way to a live Cloud Run service.

### What we learned

Codex is genuinely good at diagnosing issues, beyond just writing code. It measured the blank page bug frame by frame instead of guessing and caught a stateless transport issue I would have missed for hours. I learned that the truly hard part of an AI app is getting perfect timing, capture, storage, and concurrency. Because it is built on MCP, the same server that runs in ChatGPT can later run in Claude with almost no extra work.

### What's next

for Aipen for ChatGPT Account linking and credits so subscribers can upload and use their own handwriting from all around the world. Major European languages are already supported! More templates for letters, flashcards, lab reports, branded stationery and more.. Adapting the core MCP for other clients like Claude. Making the whole experience feel totally instant.

## README (from the GitHub repository)

# Aipen for ChatGPT

**Turn any ChatGPT answer into hyper realistic handwritten, print-ready notes
without leaving the chat.**

Aipen for ChatGPT is an OpenAI Apps SDK application built on MCP. Ask ChatGPT
for notes, a worked solution, a letter, or a report, and Aipen renders it as a
paginated A4 handwritten document with an inline preview and a downloadable
vector PDF that stays crisp when printed or zoomed in.

This repository intentionally contains only the standalone MCP server and its
inline ChatGPT component. The proprietary Aipen editor and render engine remain
separate. Its humanization layer can select from up to 20 glyph variations per
character, which helps long documents avoid a mechanically repeated look.

<p align="center">
  <img src="docs/image%20demo.jpeg" width="420" alt="Aipen handwritten mathematics notes preview" />
  <img src="docs/graph%20image%20demo.jpeg" width="420" alt="Aipen hand-drawn supply and demand graph preview" />
</p>

The images above are real Aipen renders: handwritten layout, ruled A4 paper,
humanized letterforms, rendered mathematics, and an accurate hand-drawn graph.

## Judge Quick Start: No Rebuild Needed

The live MCP endpoint is:

```text
https://aipen-mcp-511200400258.us-central1.run.app/mcp
```

To test it in ChatGPT Developer Mode:

1. Open ChatGPT and enable Developer Mode.
2. Create or add a custom app/connector.
3. Paste the live endpoint above as the MCP server URL.
4. Start a new chat and ask, for example:

   ```text
   Create two pages of handwritten calculus revision notes with worked examples.
   ```

5. ChatGPT calls `make_handwriting`. Wait for the inline Aipen card, preview
   the handwritten page, change style options if desired, and select
   **Download PDF**.

The demo accepts up to 6,500 characters, renders up to five pages per request,
and allows 40 renders per IP per hour.

### Demo Video

Watch the full 4K trailer and product walkthrough on YouTube:

https://www.youtube.com/watch?v=SdR5WnF-BUk

## Architecture

```text
ChatGPT
  -> MCP server on Cloud Run
  -> Playwright / headless Chromium
  -> private Aipen render engine
  -> per-page JPEG previews + vector PDF
  -> private Google Cloud Storage artifacts
  -> inline Aipen card in ChatGPT
```

Under the hood, the MCP receives structured note text and appearance options,
opens Aipen in a headless browser, waits for Aipen to finish rendering, captures
each Paged.js page as a modest JPEG preview, and produces the final PDF with
Chromium `page.pdf()`. The PDF is not assembled from preview images.

Artifacts are placed in a private Google Cloud Storage bucket and served through
the MCP's `/assets/:id` proxy. The app enforces a one-hour artifact expiry;
bucket lifecycle cleanup removes leftover objects later.

## Features

- Inline ChatGPT UI with A4 paper preview and PDF download
- True vector PDF output from Chromium: text and paths stay crisp at any zoom
- Per-page JPEG thumbnails for fast previews
- Accurate hand-drawn graphs and charts when explicitly requested
- Four Aipen handwriting styles: Classic, Clean, Script, Casual
- Ink, paper, and humanization controls
- Markdown and LaTex-style math support through the Aipen render engine
- Hard demo protections: 6,500 characters, five pages, 40 renders/IP/hour
- Private, temporary artifact storage with no browser-exposed cloud credentials

## Study Materials

Aipen turns AI explanations into printable, annotatable study materials.
Learners can review, highlight, and work through them by hand. It does not claim
that viewing generated handwriting has the same learning effect as personally
writing notes by hand.

## Language Support

Native handwriting currently supports English and languages using the standard
English alphabet, including Afrikaans, Albanian, Catalan, Croatian, Czech,
Danish, Dutch, Estonian, Finnish, French, German, Icelandic, Irish, Italian,
Latvian, Lithuanian, Norwegian, Polish, Portuguese, Romanian, Slovak,
Slovenian, Spanish, Swedish, Turkish, and Welsh. More languages are planned.

## Future Possibilities

Aipen is designed to grow beyond study notes. Planned directions include:

- Letter and email automation with handwritten, print-ready output
- Branded stationery, business documents, and personalized card templates
- Real-estate packs, lab reports, finance notes, and subject-specific layouts

## Local Development

Requirements:

- Node.js 20+
- A Chromium-capable machine
- An Aipen render URL
- Google Cloud Application Default Credentials for local artifact storage

```powershell
npm ci
npx playwright install chromium
gcloud auth application-default login

$env:AIPEN_RENDER_BASE = "https://aipen-render-511200400258.us-central1.run.app"
$env:GCS_ARTIFACT_BUCKET = "aipen-mcp-artifacts"
$env:PORT = "3001"

npm run check
npm run build
node dist/index.js
```

For local Aipen engine development, replace `AIPEN_RENDER_BASE` with your local
render server, such as `http://localhost:3000`.

The local MCP endpoint is `http://localhost:3001/mcp`.

## Deployment Checklist

- Confirm `PUBLIC_BASE_URL` is set to the canonical public Cloud Run URL.
- Confirm production traffic targets only the intended revision.
- After any tagged test revision, remove its tag and verify the traffic output
  has no `tag` or tagged `url` entries before considering the deployment complete.

## Environment Variables

| Variable | Required | Purpose |
| --- | --- | --- |
| `PORT` | No | HTTP port. Defaults to `3000`. |
| `AIPEN_RENDER_BASE` | Yes in production | Base URL for the Aipen render engine. |
| `GCS_ARTIFACT_BUCKET` | No | Private artifact bucket. Defaults to `aipen-mcp-artifacts`. |
| `PUBLIC_BASE_URL` | No | Overrides the externally visible MCP origin for artifact URLs. |
| `MAX_ANONYMOUS_RENDERS_PER_HOUR` | No | Per-IP demo allowance. Defaults to `40`. |

On Cloud Run, the attached service account receives bucket-only
`roles/storage.objectAdmin`; no JSON key, API key, or Secret Manager value is
required for artifact access.

## Built With Codex and GPT-5.6

OpenAI Codex and GPT-5.6 assisted with the MCP server, Apps SDK integration,
Playwright render pipeline, inline ChatGPT UI, Cloud Run containerization,
Google Cloud Storage artifact persistence, tests, and documentation. Aipen
provides the independent handwriting render engine, page design, fonts, math
rendering, paper styles, and glyph humanization.

## License

MIT. See [LICENSE](LICENSE).


## Detected evidence (automated analysis)

Indexed codebase: 3 recognized source files, 56 KB.
- Express (technology) — detected in the code
- TypeScript (language) — detected in the code

## Codebase structure (from repository index)

### Files (11 of 11)

```
.dockerignore
.env.example
.gitignore
Dockerfile
gcs-lifecycle.json
LICENSE
package.json
README.md
src/aipen-widget.ts
src/index.ts
tsconfig.json
```

### Dependencies

- package.json: @google-cloud/storage@^7.21.0, @modelcontextprotocol/ext-apps@^1.7.4, @modelcontextprotocol/sdk@^1.29.0, @types/express@^5.0.6, @types/node@^26.1.1, express@^5.1.0, playwright@^1.61.1, tsx@^4.23.1, typescript@^7.0.2, zod@^4.1.13

### Recent commits (newest first)

- Add demo video and optimize render queue
- Expand handwriting tool guidance
- Document tagged revision cleanup
- Harden MCP security and render limits
- Add graph demo and engine details
- Highlight vector PDF output
- Avoid equations in graph preset
- Add graph preset to inline UI
- Wait for completed math before capture
- Add graph instructions to handwriting tool
- Trim future roadmap
- Add handwritten demo and product roadmap
- Prepare Aipen MCP for hackathon judging

## Key source files (fetched from GitHub, selected and truncated for size)

### Dockerfile

```
FROM node:22-bookworm

WORKDIR /app
COPY package*.json ./
RUN npm ci
RUN npx playwright install --with-deps chromium
COPY tsconfig.json ./
COPY src ./src
RUN npm run build

EXPOSE 8080
CMD ["node", "dist/index.js"]

```

### package.json

```
{
  "name": "aipen-chatgpt-app",
  "version": "1.0.0",
  "description": "Standalone Aipen MCP app for ChatGPT",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "start": "node dist/index.js",
    "build": "tsc",
    "check": "tsc --noEmit"
  },
  "keywords": [],
  "author": "",
  "license": "MIT",
  "engines": {
    "node": ">=20"
  },
  "dependencies": {
    "@google-cloud/storage": "^7.21.0",
    "@modelcontextprotocol/ext-apps": "^1.7.4",
    "@modelcontextprotocol/sdk": "^1.29.0",
    "express": "^5.1.0",
    "playwright": "^1.61.1",
    "zod": "^4.1.13"
  },
  "devDependencies": {
    "@types/express": "^5.0.6",
    "@types/node": "^26.1.1",
    "tsx": "^4.23.1",
    "typescript": "^7.0.2"
  }
}

```

### src/index.ts

```typescript
import { randomUUID } from "node:crypto";
import { Storage } from "@google-cloud/storage";
import express from "express";
import { registerAppResource, registerAppTool } from "@modelcontextprotocol/ext-apps/server";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { chromium } from "playwright";
import { z } from "zod";
import { AIPEN_WIDGET_RESOURCE_URIS, AIPEN_WIDGET_URI, aipenWidgetHtml } from "./aipen-widget.js";

const app = express();
app.use(express.json());

const inputSchema = {
  text: z.string().trim().min(1, "Text cannot be empty.").max(8000, "Keep each request under 8,000 characters.").describe([
    "Markdown notes to render as handwriting. CRITICAL MATH RULE: wrap EVERY",
    "mathematical expression, variable, number-with-unit, and equation in dollar",
    "signs so it renders as real math. NON-NEGOTIABLE DELIMITER RULE:",
    "ACCURACY RULE: whenever the notes contain a calculated number - an answer, a",
    "root, a derivative, a sum, or a unit conversion - compute it with your Python",
    "tool before writing it. Never guess a numeric result. The notes must be factually",
    "correct, not just well formatted.",
    "  - INLINE MATH inside a normal sentence uses exactly ONE dollar sign on each side:",
    "    'The answer is $x = 5$ metres.' Never use $$...$$ inside a paragraph.",
    "  - DISPLAY MATH uses TWO dollar signs on each side, $$...$$, on its OWN separate line:",
    "    '$$x^2 + y^2 = r^2$$'. Use this only when the equation should look like a",
    "    handwritten equation on a separate line.",
    "Examples:",
    "  - Write 'Using $v = u + at$, we solve for $t = (v-u)/a$.'  NOT 'v=u+at'",
    "  - Write '$$v^2 = u^2 + 2as$$'  NOT 'v^2=u^2+2as'",
    "  - Write '$\\sqrt{300} = 17.32$ m/s'  NOT 'sqrt(300)=17.32'",
    "  - Write '$a = 9.8\\,\\text{m/s}^2$'  NOT 'a=9.8 m/s^2'",
    "Never write bare math without dollar signs. STRUCTURE: use # for the title, ##",
    "for sections, and combine related steps onto ONE line (e.g. '$3x+5=20$, so",
    "$3x=15$, so $x=5$') instead of one equation per line, so the page looks like",
    "compact real notes. No bold, italics, emojis, blockquotes, or horizontal rules.",
    "GRAPHS - ONLY WHEN EXPLICITLY REQUESTED (for example: 'draw', 'plot', 'graph of',",
    "'sketch', or 'show a diagram'). Never add a graph on your own initiative. When a",
    "graph IS requested:",
    "  1. FIRST compute the real data with your Python tool - never eyeball values.",
    "  2. Normalize all coordinates to the 0-100 range (0,0 = bottom-left, 100,100 = top-right).",
    "  3. Emit the graph as a fenced code block with language 'graph' containing ONLY",
    "     valid JSON in this exact shape:",
    "     ```graph",
    "     {\"title\":\"...\",\"axisLabels\":{\"x\":\"...\",\"y\":\"...\"},",
    "      \"lines\":[{\"start\":[x,y],\"end\":[x,y],\"label\":\"D\",\"labelPos\":[x,y],\"color\":\"#111\"}],",
    "      \"curves\":[{\"path\":\"M 0 30 Q 40 20 100 90\",\"label\":\"MC\",\"labelPos\":[x,y]}],",
    "      \"dashes\":[[[x,y],[x,y]]],",
    "      \"areas\":[{\"points\":[[x,y],[x,y],[x,y]],\"color\":\"#2ecc71\",\"label\":\"CS\",\"labelPos\":[x,y]}],",
    "      \"texts\":[{\"text\":\"E\",\"pos\":[x,y]}],",
    "      \"nodes\":[],\"edges\":[],\"pies\":[]}",
    "     ```",
    "  4. NEVER draw the axes yourself. Supply \"axisLabels\" and Aipen draws both axes",
    "     automatically. Use \"lines\" ONLY for data (demand/supply curves, trend lines) -",
    "     never for the x or y axis itself.",
    "  5. For a plotted function, sample points with Python and express the curve as a",
    "     series of 'curves' path segments or short 'lines'. Keep graph JSON compact:",
    "     at most about 40 sampled points per curve. Prefer a few smooth Q/C bezier",
    "     segments in a 'curves' path over hundreds of tiny line segments.",
    "  6. The graph format also supports, when explicitly requested:",
    "     - PIE CHARTS: \"pies\":[{\"center\":[50,50],\"radius\":30,\"data\":[{\"value\":40,\"color\":\"#2ecc71\",\"label\":\"Rent\"},{\"value\":60,\"color\":\"#e74c3c\",\"label\":\"Savings\"}]}]. Values are relative sizes; omit axisLabels for pies.",
    "     - FLOWCHARTS / MIND MAPS / NETWORKS: \"nodes\":[{\"id\":\"a\",\"pos\":[20,50],\"radius\":8,\"label\":\"Start\"},{\"id\":\"b\",\"pos\":[70,50],\"radius\":8,\"label\":\"End\"}] with \"edges\":[{\"source\":\"a\",\"target\":\"b\",\"label\":\"next\"}]. Omit axisLabels for diagrams.",
    "     - SHADED REGIONS: \"areas\":[{\"points\":[[x,y],[x,y],[x,y]],\"color\":\"#2ecc71\",\"label\":\"Surplus\",\"labelPos\":[x,y]}]. Use these for consumer surplus or the area under a curve.",
    "     Use a pie chart for proportions/budgets, a node/edge diagram for processes",
    "     or relationships, and lines/curves for plotted data.",
    "  7. Keep the surrounding explanation as normal handwritten notes. For a graph",
    "     request, use plain-language explanation unless the user explicitly asks for",
    "     equations or mathematical derivation.",
  ].join("\n")),
  font: z.string().min(1).optional().default("premium43").describe("Handwriting style: premium43 (Classic), pu104 (Clean), pu95 (Script), pu105 (Casual). Classic for general notes, Clean for technical/maths, Script for letters and personal notes, Casual for informal notes. Default premium43. Infer the best style from the user's wording."),
  ink: z.string().min(1).optional().default("black").describe("Ink colour: black, blue, dark grey, red, green, purple, or a #hex value. Default black. Use blue for a fountain-pen look. Infer this from the user's wording, for example 'blue ink'."),
  paper: z.string().min(1).optional().default("plain").describe("Paper style: plain, lined, grid, or dot. Use lined for study notes, grid for maths/engineering, plain for letters and essays. Default plain. Infer this
[truncated — 19479 more characters]
```

### src/aipen-widget.ts

```typescript
// Changing this URI deliberately invalidates ChatGPT's cached component template.
export const AIPEN_WIDGET_URI = "ui://widget/aipen-preview-v20.html";
export const AIPEN_WIDGET_RESOURCE_URIS = [
  AIPEN_WIDGET_URI,
  "ui://widget/aipen-preview-v19.html",
  "ui://widget/aipen-preview-v18.html",
];

export const aipenWidgetHtml = String.raw`<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Aipen preview</title>
    <style>
      :root { color-scheme: light dark; --green: #10b981; --green-dark: #047857; --bg: #ffffff; --surface: #f7f8f8; --text: #17201c; --muted: #6b756f; --line: #dce4df; --paper: #ffffff; }
      @media (prefers-color-scheme: dark) { :root { --bg: #18201c; --surface: #202a25; --text: #eef7f1; --muted: #a9b8af; --line: #36443c; } }
      * { box-sizing: border-box; }
      body { margin: 0; background: var(--bg); color: var(--text); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
      button, a { font: inherit; }
      .card { width: 100%; max-width: 1120px; margin: 0 auto; padding: 14px; }
      .header, .page-meta, .toolbar, .actions, .controls, .control, .thumbs, .template-list { display: flex; align-items: center; }
      .header { justify-content: space-between; padding: 2px 1px 12px; }
      .brand { color: var(--green); font-size: 15px; font-weight: 750; letter-spacing: 0; }
      .count, .page-meta { color: var(--muted); font-size: 12px; }
      .preview-wrap { display: block; width: 100%; padding: 4px 0; text-align: center; }
      .paper { display: block; width: auto; max-width: min(100%, 520px); max-height: min(62vh, 760px); height: auto; margin: 0 auto; background: var(--paper); border-radius: 4px; box-shadow: 0 4px 24px rgba(0, 0, 0, .14); }
      .page-meta { justify-content: center; min-height: 26px; padding-top: 8px; }
      .thumbs { gap: 8px; overflow-x: auto; padding: 4px 1px 12px; scrollbar-width: thin; }
      .thumb { flex: 0 0 68px; height: 88px; padding: 0; border: 2px solid transparent; border-radius: 4px; overflow: hidden; cursor: pointer; background: var(--surface); }
      .thumb[aria-pressed="true"] { border-color: var(--green); }
      .thumb img { display: block; width: 100%; height: 100%; object-fit: cover; background: var(--paper); }
      .toolbar { gap: 14px; flex-wrap: nowrap; padding: 12px 0 2px; border-top: 1px solid var(--line); }
      .actions { flex: 0 0 auto; gap: 8px; margin-left: auto; padding: 0; }
      .button { display: inline-flex; align-items: center; justify-content: center; min-height: 36px; padding: 8px 12px; border: 1px solid var(--line); border-radius: 6px; color: var(--text); background: transparent; text-decoration: none; font-size: 13px; font-weight: 650; cursor: pointer; }
      .button.primary { border-color: var(--green); background: var(--green); color: #ffffff; }
      .button.download { min-width: 144px; }
      .button.primary:hover { background: var(--green-dark); border-color: var(--green-dark); }
      .controls { flex: 1 1 auto; min-width: 0; gap: 12px; flex-wrap: nowrap; }
      .control { flex: 1 1 0; gap: 7px; min-width: 0; }
      .control-label { margin: 0; color: var(--muted); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; }
      .select { width: 100%; min-height: 34px; padding: 6px 30px 6px 10px; border: 1px solid var(--line); border-radius: 6px; appearance: auto; background: var(--bg); color: var(--text); font: inherit; font-size: 13px; cursor: pointer; }
      .font-picker { position: relative; width: 100%; }
      .font-trigger { display: flex; align-items: center; justify-content: space-between; width: 100%; min-height: 34px; padding: 6px 10px; border: 1px solid var(--line); border-radius: 6px; color: var(--text); background: var(--bg); font: inherit; font-size: 13px; cursor: pointer; }
      .font-trigger::after { content: "v"; color: var(--muted); font-size: 12px; }
      .font-menu { position: absolute; z-index: 5; top: calc(100% + 5px); left: 0; width: min(230px, calc(100vw - 28px)); padding: 5px; border: 1px solid var(--line); border-radius: 7px; background: var(--bg); box-shadow: 0 10px 24px rgba(0, 0, 0, .16); }
      .font-option, .upload-font { display: flex; align-items: center; width: 100%; min-height: 32px; padding: 6px 8px; border: 0; border-radius: 4px; font: inherit; font-size: 12px; text-align: left; }
      .font-option { color: var(--text); background: transparent; cursor: pointer; }
      .font-option:hover, .font-option[aria-pressed="true"] { color: var(--green-dark); background: color-mix(in srgb, var(--green) 10%, var(--surface)); }
      .upload-font { justify-content: center; margin-top: 5px; color: #ffffff; background: var(--green); font-size: 9px; font-weight: 700; white-space: nowrap; cursor: not-allowed; }
      .select:focus-visible, .button:focus-visible, .thumb:focus-visible { outline: 2px solid var(--green); outline-offset: 2px; }
      .select:disabled, .button:disabled { opacity: .55; cursor: wait; }
      .templates { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); }
      .graph-note { margin: 0 0 8px; color: var(--green-dark); font-size: 12px; font-weight: 650; }
      .templates-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
      .templates-title { margin: 0; color: var(--text); font-size: 12px; font-weight: 700; }
      .templates-status { color: var(--muted); font-size: 11px; }
      .template-list { flex-wrap: wrap; gap: 6px; }
      .template { min-height: 28px; padding: 5px 9px; border: 1px solid var(--line); border-radius: 5px; color: var(--text); background: var(--surface); font-size: 12px; line-height: 1.1; cursor: pointer; }
      .template:hover { border-color: var(--green); color: var(--green-dark); background: color-mix(in srgb, var(--green) 8%, var(--sur
[truncated — 18938 more characters]
```