23 Commits

Author SHA1 Message Date
lashman 5b732a2175 tweaked template descriptions 2026-03-13 16:23:30 +02:00
lashman c97a44a56a added accessibility section to readme 2026-02-19 00:12:20 +02:00
lashman ff9a73819f added error boundary and reduced motion support 2026-02-18 23:52:06 +02:00
lashman 09bd5767b4 added contrast validation and line-height floor 2026-02-18 23:49:03 +02:00
lashman 71b2ef418f added docx metadata and table headers 2026-02-18 23:46:32 +02:00
lashman bb10a14a56 added landmarks and semantic structure 2026-02-18 23:43:48 +02:00
lashman a7759b5bc2 added aria live regions 2026-02-18 23:35:52 +02:00
lashman 7cf45f56c9 fixed keyboard access and aria labels 2026-02-18 23:32:21 +02:00
lashman c14dde64a5 converted modals to native dialog 2026-02-18 23:27:55 +02:00
lashman 759f52274b fixed css contrast, overflow, focus rings 2026-02-18 23:22:04 +02:00
lashman 4c806d40b8 updated style count in readme 2026-02-09 23:00:24 +02:00
lashman aff9b7301b formatting pass 2026-02-09 22:59:03 +02:00
lashman 68498a2374 switched license to cc0 2026-02-09 22:55:59 +02:00
lashman 6a7e167051 updated readme 2026-02-09 18:10:57 +00:00
lashman 9ac6a4712a v1.0.0 release 2026-02-01 19:17:10 +02:00
lashman 46316c7efb fixed typescript errors 2026-02-01 19:05:00 +02:00
lashman 3ca018ba5a removed unused files 2026-02-01 19:03:37 +02:00
lashman b18bbb3d77 renamed some templates 2026-02-01 18:57:57 +02:00
lashman 74f033d5e8 ported all templates to json 2026-02-01 18:51:43 +02:00
lashman ae3a339db6 made the app fully portable 2026-01-29 18:36:48 +02:00
lashman a3b566528a converted to tauri desktop app 2026-01-29 18:28:35 +02:00
lashman cc51c5a236 rewrote the readme 2026-01-29 18:14:41 +02:00
lashman 6d35d5e95e initial commit 2026-01-29 18:10:07 +02:00
27 changed files with 901 additions and 404 deletions
-26
View File
@@ -1,26 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
_TRASH/
+116
View File
@@ -0,0 +1,116 @@
CC0 1.0 Universal
Statement of Purpose
The laws of most jurisdictions throughout the world automatically confer
exclusive Copyright and Related Rights (defined below) upon the creator and
subsequent owner(s) (each and all, an "owner") of an original work of
authorship and/or a database (each, a "Work").
Certain owners wish to permanently relinquish those rights to a Work for the
purpose of contributing to a commons of creative, cultural and scientific
works ("Commons") that the public can reliably and without fear of later
claims of infringement build upon, modify, incorporate in other works, reuse
and redistribute as freely as possible in any form whatsoever and for any
purposes, including without limitation commercial purposes. These owners may
contribute to the Commons to promote the ideal of a free culture and the
further production of creative, cultural and scientific works, or to gain
reputation or greater distribution for their Work in part through the use and
efforts of others.
For these and/or other purposes and motivations, and without any expectation
of additional consideration or compensation, the person associating CC0 with a
Work (the "Affirmer"), to the extent that he or she is an owner of Copyright
and Related Rights in the Work, voluntarily elects to apply CC0 to the Work
and publicly distribute the Work under its terms, with knowledge of his or her
Copyright and Related Rights in the Work and the meaning and intended legal
effect of CC0 on those rights.
1. Copyright and Related Rights. A Work made available under CC0 may be
protected by copyright and related or neighboring rights ("Copyright and
Related Rights"). Copyright and Related Rights include, but are not limited
to, the following:
i. the right to reproduce, adapt, distribute, perform, display, communicate,
and translate a Work;
ii. moral rights retained by the original author(s) and/or performer(s);
iii. publicity and privacy rights pertaining to a person's image or likeness
depicted in a Work;
iv. rights protecting against unfair competition in regards to a Work,
subject to the limitations in paragraph 4(a), below;
v. rights protecting the extraction, dissemination, use and reuse of data in
a Work;
vi. database rights (such as those arising under Directive 96/9/EC of the
European Parliament and of the Council of 11 March 1996 on the legal
protection of databases, and under any national implementation thereof,
including any amended or successor version of such directive); and
vii. other similar, equivalent or corresponding rights throughout the world
based on applicable law or treaty, and any national implementations thereof.
2. Waiver. To the greatest extent permitted by, but not in contravention of,
applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and
unconditionally waives, abandons, and surrenders all of Affirmer's Copyright
and Related Rights and associated claims and causes of action, whether now
known or unknown (including existing as well as future claims and causes of
action), in the Work (i) in all territories worldwide, (ii) for the maximum
duration provided by applicable law or treaty (including future time
extensions), (iii) in any current or future medium and for any number of
copies, and (iv) for any purpose whatsoever, including without limitation
commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes
the Waiver for the benefit of each member of the public at large and to the
detriment of Affirmer's heirs and successors, fully intending that such Waiver
shall not be subject to revocation, rescission, cancellation, termination, or
any other legal or equitable action to disrupt the quiet enjoyment of the Work
by the public as contemplated by Affirmer's express Statement of Purpose.
3. Public License Fallback. Should any part of the Waiver for any reason be
judged legally invalid or ineffective under applicable law, then the Waiver
shall be preserved to the maximum extent permitted taking into account
Affirmer's express Statement of Purpose. In addition, to the extent the Waiver
is so judged Affirmer hereby grants to each affected person a royalty-free,
non transferable, non sublicensable, non exclusive, irrevocable and
unconditional license to exercise Affirmer's Copyright and Related Rights in
the Work (i) in all territories worldwide, (ii) for the maximum duration
provided by applicable law or treaty (including future time extensions), (iii)
in any current or future medium and for any number of copies, and (iv) for any
purpose whatsoever, including without limitation commercial, advertising or
promotional purposes (the "License"). The License shall be deemed effective as
of the date CC0 was applied by Affirmer to the Work. Should any part of the
License for any reason be judged legally invalid or ineffective under
applicable law, such partial invalidity or ineffectiveness shall not invalidate
the remainder of the License, and in such case Affirmer hereby affirms that he
or she will not (i) exercise any of his or her remaining Copyright and Related
Rights in the Work or (ii) assert any associated claims and causes of action
with respect to the Work, in either case contrary to Affirmer's express
Statement of Purpose.
4. Limitations and Disclaimers.
a. No trademark or patent rights held by Affirmer are waived, abandoned,
surrendered, licensed or otherwise affected by this document.
b. Affirmer offers the Work as-is and makes no representations or warranties
of any kind concerning the Work, express, implied, statutory or otherwise,
including without limitation warranties of title, merchantability, fitness
for a particular purpose, non infringement, or the absence of latent or
other defects, accuracy, or the present or absence of errors, whether or not
discoverable, all to the greatest extent permissible under applicable law.
c. Affirmer disclaims responsibility for clearing rights of other persons
that may apply to the Work or any use thereof, including without limitation
any person's Copyright and Related Rights in the Work. Further, Affirmer
disclaims responsibility for obtaining any necessary consents, permissions or
other rights required for any use of the Work.
d. Affirmer understands and acknowledges that Creative Commons is not a
party to this document and has no duty or obligation with respect to this CC0
or use of the Work.
For more information, please see
<http://creativecommons.org/publicdomain/zero/1.0/>
+162 -101
View File
@@ -17,7 +17,7 @@
<img src="https://img.shields.io/badge/React-19-61DAFB?style=for-the-badge&logo=react&logoColor=white" alt="React" /> <img src="https://img.shields.io/badge/React-19-61DAFB?style=for-the-badge&logo=react&logoColor=white" alt="React" />
<img src="https://img.shields.io/badge/TypeScript-5.8-3178C6?style=for-the-badge&logo=typescript&logoColor=white" alt="TypeScript" /> <img src="https://img.shields.io/badge/TypeScript-5.8-3178C6?style=for-the-badge&logo=typescript&logoColor=white" alt="TypeScript" />
<img src="https://img.shields.io/badge/Vite-6-646CFF?style=for-the-badge&logo=vite&logoColor=white" alt="Vite" /> <img src="https://img.shields.io/badge/Vite-6-646CFF?style=for-the-badge&logo=vite&logoColor=white" alt="Vite" />
<img src="https://img.shields.io/badge/Tailwind-3-06B6D4?style=for-the-badge&logo=tailwindcss&logoColor=white" alt="Tailwind" /> <img src="https://img.shields.io/badge/Tailwind-4-06B6D4?style=for-the-badge&logo=tailwindcss&logoColor=white" alt="Tailwind" />
<img src="https://img.shields.io/badge/Tauri-2-24C8D8?style=for-the-badge&logo=tauri&logoColor=white" alt="Tauri" /> <img src="https://img.shields.io/badge/Tauri-2-24C8D8?style=for-the-badge&logo=tauri&logoColor=white" alt="Tauri" />
</p> </p>
@@ -31,7 +31,8 @@
</p> </p>
<p> <p>
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="License" /> <img src="https://img.shields.io/badge/WCAG-2.2_AAA-green?style=flat-square" alt="WCAG 2.2 AAA" />
<img src="https://img.shields.io/badge/license-CC0--1.0-green?style=flat-square" alt="License" />
<img src="https://img.shields.io/badge/PRs-welcome-brightgreen?style=flat-square" alt="PRs Welcome" /> <img src="https://img.shields.io/badge/PRs-welcome-brightgreen?style=flat-square" alt="PRs Welcome" />
<img src="https://img.shields.io/badge/community-owned-ff69b4?style=flat-square" alt="Community" /> <img src="https://img.shields.io/badge/community-owned-ff69b4?style=flat-square" alt="Community" />
</p> </p>
@@ -41,6 +42,7 @@
<!-- Quick Links --> <!-- Quick Links -->
<p> <p>
<a href="#-features">✨ Features</a> • <a href="#-features">✨ Features</a> •
<a href="#-accessibility">♿ Accessibility</a> •
<a href="#-demo">🎨 Demo</a> • <a href="#-demo">🎨 Demo</a> •
<a href="#-quick-start">🚀 Quick Start</a> • <a href="#-quick-start">🚀 Quick Start</a> •
<a href="#-documentation">📚 Docs</a> • <a href="#-documentation">📚 Docs</a> •
@@ -55,17 +57,17 @@
## 📖 Manifesto ## 📖 Manifesto
> **TypoGenie** is a free, open-source typesetting engine that democratizes beautiful document creation. No corporate subscriptions. No proprietary lock-in. No gatekeeping. Just you, your words, and 40+ professionally crafted styles ready to transform your Markdown into stunning Microsoft Word documents. > **TypoGenie** is a free, open-source typesetting engine that democratizes beautiful document creation. No corporate subscriptions. No proprietary lock-in. No gatekeeping. Just you, your words, and 165+ professionally crafted styles ready to transform your Markdown into stunning Microsoft Word documents.
In a world where document formatting tools are increasingly locked behind paywalls and cloud dependencies, **TypoGenie stands as a testament to collective utility** software built for the community, by the community, existing solely to serve the common good of anyone who needs to make their words look beautiful. In a world where document formatting tools are increasingly locked behind paywalls and cloud dependencies, **TypoGenie stands as a testament to collective utility** - software built for the community, by the community, existing solely to serve the common good of anyone who needs to make their words look beautiful.
<br/> <br/>
<div align="center"> <div align="center">
| 🌟 **Free Forever** | 🔒 **Privacy First** | 🎨 **150+ Styles** | 🤝 **Open Source** | | 🌟 **Free Forever** | 🔒 **Privacy First** | 🎨 **165+ Styles** | 🤝 **Open Source** |
|:---:|:---:|:---:|:---:| |:---:|:---:|:---:|:---:|
| No fees, no trials, no catches | All processing happens locally on your machine | From minimalist to ornate, academic to artistic | MIT licensed — use it, fork it, improve it together | | No fees, no trials, no catches | All processing happens locally on your machine | From minimalist to ornate, academic to artistic | CC0 public domain - use it, fork it, improve it together |
</div> </div>
@@ -77,16 +79,17 @@ In a world where document formatting tools are increasingly locked behind paywal
### 🎯 Core Capabilities ### 🎯 Core Capabilities
- **📄 Universal Markdown Support** Drop in any `.md`, `.txt`, or `.markdown` file - **📄 Universal Markdown Support** - Drop in any `.md`, `.txt`, or `.markdown` file
- **🎨 150+ Typography Styles** Curated across 8 aesthetic categories - **🎨 165+ Typography Styles** - Curated across 8 aesthetic categories
- **📐 Multiple Paper Sizes** A4 and Letter formats supported - **📐 Multiple Paper Sizes** - A4 and Letter formats supported
- **💾 Local Processing** Your documents never leave your machine - **💾 Local Processing** - Your documents never leave your machine
- **🖥️ Native Desktop App** Built with Tauri for Windows (Portable EXE) - **🖥️ Native Desktop App** - Built with Tauri for Windows (Portable EXE)
- **📥 One-Click Export** Clean `.docx` files ready for Microsoft Word - **📥 One-Click Export** - Clean `.docx` files ready for Microsoft Word
- **♿ WCAG 2.2 AAA** - Full keyboard access, screen reader support, runtime contrast validation, reduced motion support
### 🏛️ Style Categories ### 🏛️ Style Categories
Each of the 163+ styles is a labor of love, crafted with attention to typographic detail: Each of the 165+ styles is a labor of love, crafted with attention to typographic detail:
| Category | Description | Example Styles | | Category | Description | Example Styles |
|----------|-------------|----------------| |----------|-------------|----------------|
@@ -103,13 +106,82 @@ Each of the 163+ styles is a labor of love, crafted with attention to typographi
Every style includes meticulously configured: Every style includes meticulously configured:
-**Google Fonts Integration** Beautiful web fonts that translate to Word -**Google Fonts Integration** - Beautiful web fonts that translate to Word
-**Hierarchical Headings** H1-H6 with distinct visual weight -**Hierarchical Headings** - H1-H6 with distinct visual weight
-**Body Text Optimization** Optimal line height, spacing, and readability -**Body Text Optimization** - Optimal line height, spacing, and readability
-**List Formatting** Bulleted and numbered lists that work -**List Formatting** - Bulleted and numbered lists that work
-**Blockquote Styling** Pull quotes with accent colors -**Blockquote Styling** - Pull quotes with accent colors
-**Table Support** Data tables with proper borders and alignment -**Table Support** - Data tables with proper borders and alignment
-**Bold & Italic** Emphasis that carries through to the final document -**Bold & Italic** - Emphasis that carries through to the final document
<br/>
---
## ♿ Accessibility
TypoGenie targets **WCAG 2.2 AAA** conformance across the entire application - both the app interface and the documents it generates. Accessibility is enforced at runtime, not bolted on after the fact.
### Visual & Perception
| Feature | Detail |
|---------|--------|
| **Color Contrast** | All text meets 7:1 contrast ratio (AAA) against zinc-950 backgrounds. Template colors are auto-corrected at render time via `ensureContrast()` - large text (18pt+/14pt+ bold) enforces 4.5:1, body text enforces 7:1 |
| **Focus Indicators** | 2px indigo focus ring at 80% opacity, visible on all dark backgrounds. Meets 3:1 contrast requirement for non-text UI |
| **Reduced Motion** | Global CSS `prefers-reduced-motion` disables all animations and transitions. Framer Motion animations individually gated via `useReducedMotion()` hook |
| **Forced Colors** | Windows High Contrast Mode supported via `@media (forced-colors: active)` rules |
| **Zoom Support** | Root font size set to `100%` (not `px`) so browser zoom and text scaling work correctly. `overflow-x: hidden` (not `overflow: hidden`) allows content access at 200%+ zoom |
| **Text Spacing** | Line-height enforced at minimum 1.5 for body text, 1.0 for headings. Justified text overridden to left-aligned at render time (WCAG 1.4.8) |
### Keyboard Navigation
| Feature | Detail |
|---------|--------|
| **Full Keyboard Access** | Every interactive element is reachable and operable via keyboard. Logo uses `<button>`, not `<div>` |
| **Skip Navigation** | "Skip to main content" link in index.html, visible on focus |
| **Style Card Listbox** | Cards implement ARIA listbox pattern (`role="listbox"`/`role="option"`) with arrow key navigation, Enter/Space selection, and `aria-selected` state |
| **Modal Focus Trapping** | All three modals use native `<dialog>` element - focus trap, Escape to close, and focus restoration handled natively by the browser |
| **Shortcut Scoping** | Single-character shortcuts (`?`/`/`) are scoped to non-input contexts so they don't intercept typing in search or other fields |
| **Target Sizes** | All interactive controls meet 44x44px minimum touch/click target (WCAG 2.5.5 AAA) |
| **Iframe Keyboard Safety** | Preview iframes set to `tabIndex={-1}` so keyboard focus doesn't get trapped in non-interactive content |
### Screen Reader Support
| Feature | Detail |
|---------|--------|
| **Landmarks** | `<main id="main-content">`, `<nav aria-label="Style filters">`, `<nav aria-label="Progress">`, `<section aria-label="Style list">`, `<section aria-label="Style preview">` |
| **Live Regions** | Screen readers announce state transitions (upload/configure/generate/preview), search result counts, loading states, export success/failure |
| **Semantic Markup** | Progress stepper uses `<nav><ol>` with `aria-current="step"`. Keyboard shortcuts list uses `<dl>/<dt>/<dd>`. Decorative elements marked `aria-hidden="true"` |
| **Modal Accessibility** | All dialogs have `aria-labelledby` pointing to their heading. Export options use `<fieldset>/<legend>` for radio groups |
| **Dynamic Labels** | Favorite button announces "Add/Remove [style name] from favorites" with `aria-pressed` state. Category and paper size filters expose `aria-pressed` toggle state |
| **Accessible Names** | Every icon-only button has an `aria-label`. All decorative icons carry `aria-hidden="true"` |
| **Error Handling** | Errors use `role="alert"` (no conflicting `aria-live`). File upload errors linked to dropzone via `aria-describedby`. No auto-dismissing error messages |
| **Error Boundary** | Application-level `ErrorBoundary` component catches crashes and renders an accessible `role="alert"` error screen with a reload option |
### Accessible Document Output
The `.docx` files TypoGenie generates are also built with accessibility in mind:
| Feature | Detail |
|---------|--------|
| **Document Metadata** | Every exported DOCX includes `title`, `description`, and `creator` properties for assistive technology |
| **Heading Structure** | Semantic `HeadingLevel` preserved in both rendering modes (semantic and high-fidelity table layout) |
| **Table Headers** | Rows containing `<th>` elements are marked with `tableHeader: true` so screen readers can announce column/row headers |
| **Image Placeholders** | `<img>` elements in Markdown are converted to italic `[Image: alt text]` placeholders rather than silently dropped |
| **Contrast-Safe Colors** | All template text colors are validated and auto-corrected against their backgrounds before being applied to the document |
### Runtime Contrast Validation
Template colors are never trusted blindly. At CSS generation time, every text/background color pair passes through `ensureContrast()`:
```
Template says text color = #555555 on background #1a1a1a
→ contrastRatio("#555555", "#1a1a1a") = 2.8:1 (fails 7:1 AAA)
→ ensureContrast() lightens to #9a9a9a = 7.1:1 (passes)
→ Rendered color = #9a9a9a
```
This means all 165+ styles automatically meet WCAG AAA contrast requirements regardless of their original color definitions. The original aesthetic is preserved as closely as possible while guaranteeing readability.
<br/> <br/>
@@ -153,10 +225,10 @@ Every style includes meticulously configured:
### Option 1: Portable Download (Recommended) 🎒 ### Option 1: Portable Download (Recommended) 🎒
**TypoGenie is fully portable** no installation, no registry entries, no files scattered across your system. **TypoGenie is fully portable** - no installation, no registry entries, no files scattered across your system.
Just download and run: Just download and run:
- 🪟 **Windows**: `TypoGenie.exe` Single executable, runs immediately - 🪟 **Windows**: `TypoGenie.exe` - Single executable, runs immediately
**How it works:** **How it works:**
``` ```
@@ -166,11 +238,11 @@ Just download and run:
└── 📂 TypoGenie-Data/ ← Auto-created on first run (settings/cache) └── 📂 TypoGenie-Data/ ← Auto-created on first run (settings/cache)
``` ```
**No installer** Just double-click the EXE **No installer** - Just double-click the EXE
**No registry** Windows registry untouched **No registry** - Windows registry untouched
**No AppData** Everything stays in the same folder **No AppData** - Everything stays in the same folder
**USB-friendly** Run from a thumb drive anywhere **USB-friendly** - Run from a thumb drive anywhere
**Easy backup** Copy the whole folder, done **Easy backup** - Copy the whole folder, done
### Option 2: Build from Source ### Option 2: Build from Source
@@ -196,7 +268,7 @@ npm install
npm run dev npm run dev
``` ```
Your browser will open to `http://localhost:3000` and you're ready to create! Your browser will open to `http://localhost:3000` - and you're ready to create!
#### Desktop App Development #### Desktop App Development
@@ -212,7 +284,7 @@ npm run desktop:build
| Platform | Output Location | Result | | Platform | Output Location | Result |
|----------|----------------|--------| |----------|----------------|--------|
| **Windows** | `src-tauri/target/release/TypoGenie.exe` | ⚡ **Portable EXE** Run immediately, no install | | **Windows** | `src-tauri/target/release/TypoGenie.exe` | ⚡ **Portable EXE** - Run immediately, no install |
**For the truly portable Windows experience:** **For the truly portable Windows experience:**
1. Build the release (`npm run tauri build`). 1. Build the release (`npm run tauri build`).
@@ -222,11 +294,11 @@ npm run desktop:build
### Usage ### Usage
1. **📤 Upload** Drag and drop your Markdown file (or click to browse) 1. **📤 Upload** - Drag and drop your Markdown file (or click to browse)
2. **🎨 Select Style** Browse the gallery and click any style for live preview 2. **🎨 Select Style** - Browse the gallery and click any style for live preview
3. **📐 Choose Paper Size** A4 or Letter, depending on your needs 3. **📐 Choose Paper Size** - A4 or Letter, depending on your needs
4. **✨ Generate** Watch the magic happen (with a satisfying loading animation) 4. **✨ Generate** - Watch the magic happen (with a satisfying loading animation)
5. **📥 Download** Grab your `.docx` file, ready for Word 5. **📥 Download** - Grab your `.docx` file, ready for Word
<br/> <br/>
@@ -238,26 +310,26 @@ npm run desktop:build
``` ```
┌─────────────────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────────────────┐
│ TYPOGENIE ARCHITECTURE │ TYPOGENIE ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ UPLOAD │───→│ CONFIG │───→│ PREVIEW │ │ │ │ UPLOAD │───→│ CONFIG │───→│ PREVIEW │ │
│ │ (File) │ │ (Style) │ │ (Export) │ │ │ │ (File) │ │ (Style) │ │ (Export) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │ │ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │ │ │ │ │ │ │ │ │
│ ▼ ▼ ▼ │ │ ▼ ▼ ▼ │
│ ┌────────────────────────────────────────────────────────────┐ │ │ ┌────────────────────────────────────────────────────────────┐
│ │ STATE MACHINE │ │ │ │ STATE MACHINE │
│ │ • UPLOAD → CONFIG → GENERATING → PREVIEW → (reset) │ │ │ │ • UPLOAD → CONFIG → GENERATING → PREVIEW → (reset) │
│ └────────────────────────────────────────────────────────────┘ │ │ └────────────────────────────────────────────────────────────┘
│ │ │ │ │ │ │ │ │ │
│ ▼ ▼ ▼ │ │ ▼ ▼ ▼ │
│ ┌────────────────────────────────────────────────────────────┐ │ │ ┌────────────────────────────────────────────────────────────┐
│ │ PROCESSING PIPELINE │ │ │ │ PROCESSING PIPELINE │
│ │ Markdown → marked parser → HTML → docx library → .docx │ │ │ │ Markdown → marked parser → HTML → docx library → .docx │
│ └────────────────────────────────────────────────────────────┘ │ │ └────────────────────────────────────────────────────────────┘
│ │
└─────────────────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────────────────┘
``` ```
@@ -295,8 +367,8 @@ Styles in TypoGenie are defined entirely in **JSON**, making them easy to edit a
**The Dual Rendering Architecture:** **The Dual Rendering Architecture:**
TypoGenie maintains two parallel rendering paths: TypoGenie maintains two parallel rendering paths:
1. **Web Preview** Dynamically generates CSS from your JSON config on the fly. 1. **Web Preview** - Dynamically generates CSS from your JSON config on the fly.
2. **DOCX Export** Programmatically builds Word styles using the exact same JSON config. 2. **DOCX Export** - Programmatically builds Word styles using the exact same JSON config.
This ensures WYSIWYG fidelity: what you see in the preview is what you get in Word. This ensures WYSIWYG fidelity: what you see in the preview is what you get in Word.
@@ -306,7 +378,7 @@ Want to contribute a new style?
1. **Create a JSON file** in a folder inside `templates/` (e.g., `templates/my-category/my-style.json`). 1. **Create a JSON file** in a folder inside `templates/` (e.g., `templates/my-category/my-style.json`).
2. **Define your style** using the schema above. 2. **Define your style** using the schema above.
3. **Restart the app** (or click refresh) TypoGenie automatically discovers all `.json` files in the `templates/` directory. 3. **Restart the app** (or click refresh) - TypoGenie automatically discovers all `.json` files in the `templates/` directory.
### Configuration Options ### Configuration Options
@@ -343,13 +415,21 @@ npm run tauri # Access Tauri CLI directly
typogenie/ typogenie/
├── 📁 src/ # Frontend source code (React) ├── 📁 src/ # Frontend source code (React)
│ ├── 📁 components/ # UI components │ ├── 📁 components/ # UI components
│ │ ├── FileUpload.tsx # Drag-and-drop upload zone │ │ ├── ErrorBoundary.tsx # Accessible crash recovery screen
│ │ ├── StyleSelector.tsx # Style gallery │ │ ├── ExportOptionsModal.tsx # DOCX export options (native dialog)
│ │ ── Preview.tsx # Final preview & export │ │ ── FileUpload.tsx # Drag-and-drop upload zone
│ ├── 📁 services/ # Logic │ ├── Preview.tsx # Final preview & export
│ │ ├── templateLoader.ts # Loads JSON templates via Rust │ │ ├── StylePreviewModal.tsx # Full-page style preview (native dialog)
│ │ └── templateRenderer.ts # Generates CSS/DOCX from JSON │ │ └── StyleSelector.tsx # Style gallery with listbox pattern
│ ├── 📁 hooks/ # React hooks │ ├── 📁 hooks/ # React hooks
│ │ ├── useDialog.ts # Native <dialog> management with focus restore
│ │ └── ...
│ ├── 📁 services/ # Logic
│ │ ├── templateLoader.ts # Loads JSON templates via Rust
│ │ └── templateRenderer.ts # Generates CSS/DOCX with contrast validation
│ ├── 📁 utils/ # Utilities
│ │ ├── contrastUtils.ts # WCAG contrast ratio validation
│ │ └── docxConverter.ts # Accessible DOCX generation
│ └── 📄 App.tsx # Main application component │ └── 📄 App.tsx # Main application component
├── 📁 src-tauri/ # Tauri desktop app ├── 📁 src-tauri/ # Tauri desktop app
│ ├── 📁 src/ # Rust backend code (filesystem access) │ ├── 📁 src/ # Rust backend code (filesystem access)
@@ -369,7 +449,8 @@ typogenie/
| **Framework** | React 19.2.4 | UI components & state management | | **Framework** | React 19.2.4 | UI components & state management |
| **Language** | TypeScript 5.8.2 | Type safety & developer experience | | **Language** | TypeScript 5.8.2 | Type safety & developer experience |
| **Build Tool** | Vite 6.2.0 | Fast development & optimized builds | | **Build Tool** | Vite 6.2.0 | Fast development & optimized builds |
| **Styling** | Tailwind CSS | Utility-first CSS | | **Styling** | Tailwind CSS 4 | Utility-first CSS |
| **Animation** | Framer Motion (motion/react) | Transitions with reduced-motion support |
| **Icons** | Lucide React | Beautiful, consistent iconography | | **Icons** | Lucide React | Beautiful, consistent iconography |
| **Markdown** | marked 12.0.0 | Local Markdown parsing | | **Markdown** | marked 12.0.0 | Local Markdown parsing |
| **Documents** | docx 8.5.0 | Client-side DOCX generation | | **Documents** | docx 8.5.0 | Client-side DOCX generation |
@@ -382,23 +463,23 @@ typogenie/
## 🤝 Contributing ## 🤝 Contributing
TypoGenie is a **commons** it belongs to everyone who uses it and improves it. We welcome contributions of all kinds: TypoGenie is a **commons** - it belongs to everyone who uses it and improves it. We welcome contributions of all kinds:
### Ways to Contribute ### Ways to Contribute
- 🎨 **New Styles** Design and share new typography styles - 🎨 **New Styles** - Design and share new typography styles
- 🐛 **Bug Reports** Found something broken? Let us know! - 🐛 **Bug Reports** - Found something broken? Let us know!
- 💡 **Feature Ideas** Have a suggestion? Open a discussion - 💡 **Feature Ideas** - Have a suggestion? Open a discussion
- 📖 **Documentation** Help us explain things better - 📖 **Documentation** - Help us explain things better
- 🔧 **Code Improvements** Refactors, optimizations, fixes - 🔧 **Code Improvements** - Refactors, optimizations, fixes
- 🌍 **Translations** Help make TypoGenie accessible globally - 🌍 **Translations** - Help make TypoGenie accessible globally
### Contribution Guidelines ### Contribution Guidelines
1. **Fork the repository** 1. **Fork the repository**
2. **Create a feature branch**: `git checkout -b feature/amazing-thing` 2. **Create a feature branch**: `git checkout -b feature/amazing-thing`
3. **Make your changes** with clear, descriptive commits 3. **Make your changes** with clear, descriptive commits
4. **Test thoroughly** preview AND Word export 4. **Test thoroughly** - preview AND Word export
5. **Submit a Pull Request** with a detailed description 5. **Submit a Pull Request** with a detailed description
### Code of Conduct ### Code of Conduct
@@ -420,35 +501,15 @@ TypoGenie exists because we believe:
> **Access to beautiful tools for self-expression should not depend on one's ability to pay.** > **Access to beautiful tools for self-expression should not depend on one's ability to pay.**
In an era of increasingly proprietary, subscription-based software, TypoGenie is an offering to the digital commons a tool that exists purely to serve those who need it, without extracting value, without surveillance, without artificial limitations. In an era of increasingly proprietary, subscription-based software, TypoGenie is an offering to the digital commons - a tool that exists purely to serve those who need it, without extracting value, without surveillance, without artificial limitations.
### MIT License ### CC0 1.0 Universal - Public Domain Dedication
``` [![CC0](https://licensebuttons.net/p/zero/1.0/88x31.png)](https://creativecommons.org/publicdomain/zero/1.0/)
MIT License
Copyright (c) 2026 TypoGenie Contributors This project is released into the public domain under [CC0 1.0 Universal](LICENSE). To the extent possible under law, the authors have waived all copyright and related rights to this work.
Permission is hereby granted, free of charge, to any person obtaining a copy **tl;dr:** Do whatever you want with this code. No permission needed, no attribution required, no strings attached. Copy it, modify it, sell it, use it for whatever purpose serves your community. It belongs to everyone.
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
**tl;dr:** Do whatever you want with this code. Just keep the license notice. Share it. Modify it. Use it for whatever purpose serves your community. No attribution required beyond preserving the license, though we always appreciate a shout-out!
<br/> <br/>
@@ -456,17 +517,17 @@ SOFTWARE.
## 🙏 Acknowledgments ## 🙏 Acknowledgments
TypoGenie stands on the shoulders of giants the countless open-source contributors who built the libraries, tools, and standards that make this project possible: TypoGenie stands on the shoulders of giants - the countless open-source contributors who built the libraries, tools, and standards that make this project possible:
- **The React Team** For the component model that powers our UI - **The React Team** - For the component model that powers our UI
- **The marked contributors** For battle-tested Markdown parsing - **The marked contributors** - For battle-tested Markdown parsing
- **The docx maintainers** For making client-side Word generation possible - **The docx maintainers** - For making client-side Word generation possible
- **Google Fonts** For democratizing access to beautiful typography - **Google Fonts** - For democratizing access to beautiful typography
- **Tailwind CSS** For the utility-first approach that speeds development - **Tailwind CSS** - For the utility-first approach that speeds development
- **Vite** For the build tooling that makes development a joy - **Vite** - For the build tooling that makes development a joy
- **The broader open-source community** For proving that collective, voluntary cooperation can create tools that rival (and exceed) proprietary alternatives - **The broader open-source community** - For proving that collective, voluntary cooperation can create tools that rival (and exceed) proprietary alternatives
**And to you, the user** for choosing tools that respect your freedom. **And to you, the user** - for choosing tools that respect your freedom.
<br/> <br/>
@@ -488,10 +549,10 @@ TypoGenie stands on the shoulders of giants — the countless open-source contri
If TypoGenie brings value to your life, consider: If TypoGenie brings value to your life, consider:
-**Starring the repository** Helps others discover it -**Starring the repository** - Helps others discover it
- 🗣️ **Spreading the word** Tell friends and colleagues - 🗣️ **Spreading the word** - Tell friends and colleagues
- 🔧 **Contributing code** Improve it for everyone - 🔧 **Contributing code** - Improve it for everyone
- 💝 **Supporting open-source** Donate to the projects TypoGenie depends on - 💝 **Supporting open-source** - Donate to the projects TypoGenie depends on
<br/> <br/>
Binary file not shown.
+3 -1
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TypoGenie</title> <title>TypoGenie - Markdown to Word Converter</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Playfair+Display:ital,wght@0,400;0,600;1,400&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Playfair+Display:ital,wght@0,400;0,600;1,400&display=swap" rel="stylesheet">
@@ -18,6 +18,8 @@
</style> </style>
</head> </head>
<body> <body>
<a href="#main-content" class="sr-only" style="position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden;" onfocus="this.style.position='static';this.style.width='auto';this.style.height='auto';this.style.overflow='visible';" onblur="this.style.position='absolute';this.style.left='-9999px';this.style.width='1px';this.style.height='1px';this.style.overflow='hidden';">Skip to main content</a>
<noscript><p style="padding:2rem;color:#e4e4e7;background:#09090b;font-family:sans-serif;">TypoGenie requires JavaScript to run.</p></noscript>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
-5
View File
@@ -1,5 +0,0 @@
{
"name": "TypoGenie",
"description": "Transform markdown into beautifully formatted Microsoft Word documents with professional typography styles.",
"requestFramePermissions": []
}
+1 -1
View File
@@ -3,7 +3,7 @@ name = "typogenie"
version = "1.0.0" version = "1.0.0"
description = "TypoGenie - Portable Markdown to Word document converter" description = "TypoGenie - Portable Markdown to Word document converter"
authors = ["TypoGenie Contributors"] authors = ["TypoGenie Contributors"]
license = "MIT" license = "CC0-1.0"
repository = "https://git.lashman.live/lashman/typogenie" repository = "https://git.lashman.live/lashman/typogenie"
edition = "2021" edition = "2021"
rust-version = "1.77.2" rust-version = "1.77.2"
+1 -1
View File
@@ -47,7 +47,7 @@
"longDescription": "TypoGenie is a free, open-source, portable typesetting engine. No installation required - just run the EXE. All data stays in the same folder.", "longDescription": "TypoGenie is a free, open-source, portable typesetting engine. No installation required - just run the EXE. All data stays in the same folder.",
"publisher": "TypoGenie Contributors", "publisher": "TypoGenie Contributors",
"copyright": "Copyright (c) 2026 TypoGenie Contributors", "copyright": "Copyright (c) 2026 TypoGenie Contributors",
"license": "MIT", "license": "CC0-1.0",
"homepage": "https://git.lashman.live/lashman/typogenie", "homepage": "https://git.lashman.live/lashman/typogenie",
"resources": { "resources": {
"templates": "templates" "templates": "templates"
@@ -2,7 +2,7 @@
"id": "creative-portfolio", "id": "creative-portfolio",
"name": "Creative Portfolio", "name": "Creative Portfolio",
"category": "creative", "category": "creative",
"description": "Minimalist gallery style for showcasing visual work. Focuses on images and clean typography.", "description": "Minimalist gallery style for presenting visual work. Focuses on images and clean typography.",
"vibe": "Minimalist, Visual, Gallery", "vibe": "Minimalist, Visual, Gallery",
"googleFontsImport": "https://fonts.googleapis.com/css2?family=Jost:ital,wght@0,300..900;1,300..900&family=Playfair+Display:ital,wght@0,400..900;1,400..900&display=swap", "googleFontsImport": "https://fonts.googleapis.com/css2?family=Jost:ital,wght@0,300..900;1,300..900&family=Playfair+Display:ital,wght@0,400..900;1,400..900&display=swap",
"typography": { "typography": {
@@ -2,7 +2,7 @@
"id": "rococo-romance", "id": "rococo-romance",
"name": "Rococo Romance", "name": "Rococo Romance",
"category": "editorial", "category": "editorial",
"description": "Inspired by 18th-century Rococo. Light, playful, and intricate with pastel tones and swirling elegance.", "description": "Inspired by 18th-century Rococo. Light, playful, and delicate with pastel tones and swirling elegance.",
"vibe": "Romantic, Ornate, Pastel", "vibe": "Romantic, Ornate, Pastel",
"googleFontsImport": "https://fonts.googleapis.com/css2?family=Pinyon+Script&family=Playfair+Display+SC:ital,wght@0,400;0,700;1,400;1,700&family=Lora:ital,wght@0,400..700;1,400..700&display=swap", "googleFontsImport": "https://fonts.googleapis.com/css2?family=Pinyon+Script&family=Playfair+Display+SC:ital,wght@0,400;0,700;1,400;1,700&family=Lora:ital,wght@0,400..700;1,400..700&display=swap",
"typography": { "typography": {
@@ -1,5 +1,5 @@
{ {
"id": "artificial-intelligence", "id": "neural-network",
"name": "Neural Network", "name": "Neural Network",
"category": "tech", "category": "tech",
"description": "Cutting-edge design for advanced computing and data science. Neural network inspired with gradient accents.", "description": "Cutting-edge design for advanced computing and data science. Neural network inspired with gradient accents.",
+1 -1
View File
@@ -2,7 +2,7 @@
"id": "tech-startup", "id": "tech-startup",
"name": "Tech Venture", "name": "Tech Venture",
"category": "tech", "category": "tech",
"description": "Modern, energetic design for technology companies and ventures. Bold gradients, vibrant colors, and confident typography.", "description": "Modern, energetic design for technology companies and ventures. Bold gradients, punchy colors, and confident typography.",
"vibe": "Innovative, Dynamic, Bold", "vibe": "Innovative, Dynamic, Bold",
"googleFontsImport": "https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;700&display=swap", "googleFontsImport": "https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;700&display=swap",
"typography": { "typography": {
@@ -2,7 +2,7 @@
"id": "memphis-pattern", "id": "memphis-pattern",
"name": "Memphis Pattern", "name": "Memphis Pattern",
"category": "retro", "category": "retro",
"description": "The chaotic 80s aesthetic. Clashing patterns, zig-zags, and vibrant pastel-neons.", "description": "The chaotic 80s aesthetic. Clashing patterns, zig-zags, and loud pastel-neons.",
"vibe": "Playful, Chaotic, 80s", "vibe": "Playful, Chaotic, 80s",
"googleFontsImport": "https://fonts.googleapis.com/css2?family=Righteous&family=Comfortaa:wght@400;700&display=swap", "googleFontsImport": "https://fonts.googleapis.com/css2?family=Righteous&family=Comfortaa:wght@400;700&display=swap",
"typography": { "typography": {
@@ -2,7 +2,7 @@
"id": "mid-century-modern", "id": "mid-century-modern",
"name": "Mid-Century Modern", "name": "Mid-Century Modern",
"category": "retro", "category": "retro",
"description": "Optimistic and playful design from the 1950s-60s. Geometric sans-serifs, vibrant atomic-age colors, and approachable warmth.", "description": "Optimistic and playful design from the 1950s-60s. Geometric sans-serifs, bold atomic-age colors, and approachable warmth.",
"vibe": "Playful, Nostalgic, Friendly", "vibe": "Playful, Nostalgic, Friendly",
"googleFontsImport": "https://fonts.googleapis.com/css2?family=Quicksand:wght@400;600;700&display=swap", "googleFontsImport": "https://fonts.googleapis.com/css2?family=Quicksand:wght@400;600;700&display=swap",
"typography": { "typography": {
+88 -60
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'motion/react'; import { motion, AnimatePresence, useReducedMotion } from 'motion/react';
import { AppState, PaperSize } from './types'; import { AppState, PaperSize } from './types';
import { FileUpload } from './components/FileUpload'; import { FileUpload } from './components/FileUpload';
import { StyleSelector } from './components/StyleSelector'; import { StyleSelector } from './components/StyleSelector';
@@ -7,6 +7,7 @@ import { Preview } from './components/Preview';
import { ZoomControl } from './components/ZoomControl'; import { ZoomControl } from './components/ZoomControl';
import { useSettings } from './hooks/useSettings'; import { useSettings } from './hooks/useSettings';
import { useTemplates } from './hooks/useTemplates'; import { useTemplates } from './hooks/useTemplates';
import { useDialog } from './hooks/useDialog';
// @ts-ignore // @ts-ignore
import { parse } from 'marked'; import { parse } from 'marked';
import { Sparkles, Loader2, FileType, Keyboard, X, RefreshCw } from 'lucide-react'; import { Sparkles, Loader2, FileType, Keyboard, X, RefreshCw } from 'lucide-react';
@@ -14,7 +15,8 @@ import { Sparkles, Loader2, FileType, Keyboard, X, RefreshCw } from 'lucide-reac
import { useKeyboardNavigation } from './hooks/useKeyboardNavigation'; import { useKeyboardNavigation } from './hooks/useKeyboardNavigation';
// Keyboard shortcuts help component // Keyboard shortcuts help component
const KeyboardShortcutsHelp: React.FC<{ onClose: () => void }> = ({ onClose }) => { const KeyboardShortcutsHelp: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ isOpen, onClose }) => {
const { dialogRef, handleBackdropClick, close } = useDialog(isOpen, { onClose });
const shortcuts = [ const shortcuts = [
{ key: '↑ / ↓', description: 'Navigate styles' }, { key: '↑ / ↓', description: 'Navigate styles' },
{ key: '← / →', description: 'Navigate categories (when focused)' }, { key: '← / →', description: 'Navigate categories (when focused)' },
@@ -26,60 +28,61 @@ const KeyboardShortcutsHelp: React.FC<{ onClose: () => void }> = ({ onClose }) =
{ key: 'Escape', description: 'Go back / Close' }, { key: 'Escape', description: 'Go back / Close' },
]; ];
if (!isOpen) return null;
return ( return (
<motion.div <dialog
initial={{ opacity: 0 }} ref={dialogRef}
animate={{ opacity: 1 }} onClick={handleBackdropClick}
exit={{ opacity: 0 }} aria-labelledby="shortcuts-title"
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4" className="fixed inset-0 z-50 p-4"
onClick={onClose}
> >
<motion.div <motion.div
initial={{ scale: 0.9, opacity: 0 }} initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }} animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="bg-zinc-900 border border-zinc-700 rounded-2xl p-6 max-w-md w-full shadow-2xl" className="bg-zinc-900 border border-zinc-700 rounded-2xl p-6 max-w-md w-full shadow-2xl"
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
> >
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-2 bg-indigo-500/10 rounded-lg text-indigo-400"> <div className="p-2 bg-indigo-500/10 rounded-lg text-indigo-400" aria-hidden="true">
<Keyboard size={20} /> <Keyboard size={20} />
</div> </div>
<h2 className="text-xl font-bold text-white">Keyboard Shortcuts</h2> <h2 id="shortcuts-title" className="text-xl font-bold text-white">Keyboard Shortcuts</h2>
</div> </div>
<button <button
onClick={onClose} onClick={close}
className="p-1 hover:bg-zinc-800 rounded-lg text-zinc-400 hover:text-white transition-colors" className="p-2 min-w-[44px] min-h-[44px] flex items-center justify-center hover:bg-zinc-800 rounded-lg text-zinc-400 hover:text-white transition-colors"
aria-label="Close shortcuts"
> >
<X size={20} /> <X size={20} />
</button> </button>
</div> </div>
<div className="space-y-3"> <dl className="space-y-3">
{shortcuts.map((shortcut, index) => ( {shortcuts.map((shortcut) => (
<motion.div <div
key={shortcut.key} key={shortcut.key}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.05 }}
className="flex justify-between items-center py-2 border-b border-zinc-800 last:border-0" className="flex justify-between items-center py-2 border-b border-zinc-800 last:border-0"
> >
<span className="text-zinc-400">{shortcut.description}</span> <dt className="text-zinc-400">{shortcut.description}</dt>
<kbd className="px-2 py-1 bg-zinc-800 rounded text-sm font-mono text-zinc-300 border border-zinc-700"> <dd className="ml-4">
{shortcut.key} <kbd className="px-2 py-1 bg-zinc-800 rounded text-sm font-mono text-zinc-300 border border-zinc-700">
</kbd> {shortcut.key}
</motion.div> </kbd>
</dd>
</div>
))} ))}
</div> </dl>
<p className="mt-6 text-xs text-zinc-500 text-center"> <p className="mt-6 text-xs text-zinc-400 text-center">
Press Escape or click outside to close Press Escape or click outside to close
</p> </p>
</motion.div> </motion.div>
</motion.div> </dialog>
); );
}; };
const App: React.FC = () => { const App: React.FC = () => {
const prefersReducedMotion = useReducedMotion();
const [appState, setAppState] = useState<AppState>(AppState.UPLOAD); const [appState, setAppState] = useState<AppState>(AppState.UPLOAD);
const [content, setContent] = useState<string>(''); const [content, setContent] = useState<string>('');
const [inputFileName, setInputFileName] = useState<string>(''); const [inputFileName, setInputFileName] = useState<string>('');
@@ -88,6 +91,7 @@ const App: React.FC = () => {
const [generatedHtml, setGeneratedHtml] = useState<string>(''); const [generatedHtml, setGeneratedHtml] = useState<string>('');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showShortcuts, setShowShortcuts] = useState(false); const [showShortcuts, setShowShortcuts] = useState(false);
const [statusMessage, setStatusMessage] = useState('');
const { uiZoom, setUiZoom, isLoaded } = useSettings(); const { uiZoom, setUiZoom, isLoaded } = useSettings();
const { templates, categories, isLoading: templatesLoading, error: templatesError, refresh, openFolder } = useTemplates(); const { templates, categories, isLoading: templatesLoading, error: templatesError, refresh, openFolder } = useTemplates();
@@ -101,9 +105,24 @@ const App: React.FC = () => {
// The Tauri file drop is disabled to allow the webview to handle drag events // The Tauri file drop is disabled to allow the webview to handle drag events
// This preserves the drag hover animations in the FileUpload component // This preserves the drag hover animations in the FileUpload component
// Announce state changes to screen readers
useEffect(() => {
const messages: Record<string, string> = {
[AppState.UPLOAD]: 'Upload screen',
[AppState.CONFIG]: 'Style configuration',
[AppState.GENERATING]: 'Generating document',
[AppState.PREVIEW]: 'Document preview',
};
setStatusMessage(messages[appState] || '');
}, [appState]);
// Global keydown listener for shortcuts help // Global keydown listener for shortcuts help
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName;
const isEditable = tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable;
if (isEditable) return;
if (e.key === '?' || e.key === '/') { if (e.key === '?' || e.key === '/') {
e.preventDefault(); e.preventDefault();
setShowShortcuts(prev => !prev); setShowShortcuts(prev => !prev);
@@ -162,7 +181,11 @@ const App: React.FC = () => {
}; };
if (!isLoaded) { if (!isLoaded) {
return null; return (
<div className="h-screen w-screen flex items-center justify-center bg-zinc-950" role="status" aria-live="polite">
<span className="sr-only">Loading TypoGenie</span>
</div>
);
} }
if (appState === AppState.PREVIEW) { if (appState === AppState.PREVIEW) {
@@ -171,7 +194,8 @@ const App: React.FC = () => {
className="h-screen w-screen overflow-hidden" className="h-screen w-screen overflow-hidden"
style={{ fontSize: `${uiZoom}%` }} style={{ fontSize: `${uiZoom}%` }}
> >
<div className="h-full w-full flex flex-col"> <div role="status" aria-live="polite" className="sr-only">{statusMessage}</div>
<main id="main-content" className="h-full w-full flex flex-col">
<Preview <Preview
htmlContent={generatedHtml} htmlContent={generatedHtml}
onBack={handleBackToConfig} onBack={handleBackToConfig}
@@ -182,7 +206,7 @@ const App: React.FC = () => {
onZoomChange={setUiZoom} onZoomChange={setUiZoom}
templates={templates} templates={templates}
/> />
</div> </main>
</div> </div>
); );
} }
@@ -192,13 +216,10 @@ const App: React.FC = () => {
className="h-screen bg-zinc-950 text-zinc-100 flex flex-col font-sans selection:bg-indigo-500/30 overflow-hidden" className="h-screen bg-zinc-950 text-zinc-100 flex flex-col font-sans selection:bg-indigo-500/30 overflow-hidden"
style={{ fontSize: `${uiZoom}%` }} style={{ fontSize: `${uiZoom}%` }}
> >
<div role="status" aria-live="polite" className="sr-only">{statusMessage}</div>
{/* Keyboard Shortcuts Modal */} {/* Keyboard Shortcuts Modal */}
<AnimatePresence> <KeyboardShortcutsHelp isOpen={showShortcuts} onClose={() => setShowShortcuts(false)} />
{showShortcuts && (
<KeyboardShortcutsHelp onClose={() => setShowShortcuts(false)} />
)}
</AnimatePresence>
{/* Header - Fixed height */} {/* Header - Fixed height */}
<motion.header <motion.header
@@ -208,20 +229,22 @@ const App: React.FC = () => {
className="flex-none border-b border-zinc-800 bg-zinc-950/50 backdrop-blur-sm z-40" className="flex-none border-b border-zinc-800 bg-zinc-950/50 backdrop-blur-sm z-40"
> >
<div className="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between"> <div className="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between">
<motion.div <motion.button
className="flex items-center gap-2 cursor-pointer" className="flex items-center gap-2 cursor-pointer bg-transparent border-none"
onClick={handleReset} onClick={handleReset}
whileHover={{ scale: 1.02 }} whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
aria-label="TypoGenie - Reset to home"
> >
<motion.div <motion.div
className="bg-gradient-to-br from-indigo-500 to-violet-600 p-2 rounded-lg" className="bg-gradient-to-br from-indigo-500 to-violet-600 p-2 rounded-lg"
whileHover={{ rotate: [0, -10, 10, 0], transition: { duration: 0.5 } }} whileHover={{ rotate: [0, -10, 10, 0], transition: { duration: 0.5 } }}
aria-hidden="true"
> >
<FileType className="text-white" size={20} /> <FileType className="text-white" size={20} />
</motion.div> </motion.div>
<h1 className="text-xl font-bold tracking-tight text-white">TypoGenie</h1> <h1 className="text-xl font-bold tracking-tight text-white">TypoGenie</h1>
</motion.div> </motion.button>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{/* UI Zoom Control */} {/* UI Zoom Control */}
@@ -233,10 +256,10 @@ const App: React.FC = () => {
onClick={refresh} onClick={refresh}
whileHover={{ scale: 1.05 }} whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }} whileTap={{ scale: 0.95 }}
className="hidden sm:flex items-center gap-2 px-3 py-1.5 text-xs text-zinc-500 hover:text-zinc-300 bg-zinc-900 hover:bg-zinc-800 rounded-lg transition-colors border border-zinc-800" className="hidden sm:flex items-center gap-2 px-3 py-1.5 text-xs text-zinc-400 hover:text-zinc-300 bg-zinc-900 hover:bg-zinc-800 rounded-lg transition-colors border border-zinc-800"
title="Reload templates from disk" title="Reload templates from disk"
> >
<RefreshCw size={14} /> <RefreshCw size={14} aria-hidden="true" />
<span>Refresh</span> <span>Refresh</span>
</motion.button> </motion.button>
)} )}
@@ -246,28 +269,32 @@ const App: React.FC = () => {
onClick={() => setShowShortcuts(true)} onClick={() => setShowShortcuts(true)}
whileHover={{ scale: 1.05 }} whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }} whileTap={{ scale: 0.95 }}
className="hidden sm:flex items-center gap-2 px-3 py-1.5 text-xs text-zinc-500 hover:text-zinc-300 bg-zinc-900 hover:bg-zinc-800 rounded-lg transition-colors border border-zinc-800" className="hidden sm:flex items-center gap-2 px-3 py-1.5 text-xs text-zinc-400 hover:text-zinc-300 bg-zinc-900 hover:bg-zinc-800 rounded-lg transition-colors border border-zinc-800"
title="Show keyboard shortcuts" title="Show keyboard shortcuts"
> >
<Keyboard size={14} /> <Keyboard size={14} aria-hidden="true" />
<span>Shortcuts</span> <span>Shortcuts</span>
<kbd className="px-1.5 py-0.5 bg-zinc-800 rounded text-zinc-400 font-mono">?</kbd> <kbd className="px-1.5 py-0.5 bg-zinc-800 rounded text-zinc-400 font-mono">?</kbd>
</motion.button> </motion.button>
<kbd className="sm:hidden px-1.5 py-0.5 bg-zinc-800 rounded text-zinc-400 font-mono text-xs border border-zinc-700 cursor-pointer" onClick={() => setShowShortcuts(true)} role="button" aria-label="Show keyboard shortcuts">?</kbd>
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
{appState !== AppState.UPLOAD && ( {appState !== AppState.UPLOAD && (
<motion.div <motion.nav
aria-label="Progress"
initial={{ opacity: 0, x: 20 }} initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }} exit={{ opacity: 0, x: -20 }}
className="flex items-center gap-4 text-sm text-zinc-500"
> >
<span className={appState === AppState.CONFIG ? "text-indigo-400 font-medium" : ""}>Configure</span> <ol className="flex items-center gap-4 text-sm text-zinc-400">
<span>/</span> <li className={appState === AppState.CONFIG ? "text-indigo-400 font-medium" : ""} aria-current={appState === AppState.CONFIG ? "step" : undefined}>Configure</li>
<span className={appState === AppState.GENERATING ? "text-indigo-400 font-medium" : ""}>Generate</span> <li aria-hidden="true">/</li>
<span>/</span> <li className={appState === AppState.GENERATING ? "text-indigo-400 font-medium" : ""} aria-current={appState === AppState.GENERATING ? "step" : undefined}>Generate</li>
<span>Preview</span> <li aria-hidden="true">/</li>
</motion.div> <li className={appState === AppState.PREVIEW ? "text-indigo-400 font-medium" : ""} aria-current={appState === AppState.PREVIEW ? "step" : undefined}>Preview</li>
</ol>
</motion.nav>
)} )}
</AnimatePresence> </AnimatePresence>
</div> </div>
@@ -275,12 +302,12 @@ const App: React.FC = () => {
</motion.header> </motion.header>
{/* Main Content - Takes remaining space */} {/* Main Content - Takes remaining space */}
<main className="flex-1 relative overflow-hidden"> <main id="main-content" className="flex-1 relative overflow-hidden">
{/* Animated background blobs */} {/* Animated background blobs */}
<div className="absolute inset-0 overflow-hidden pointer-events-none"> <div className="absolute inset-0 overflow-hidden pointer-events-none" aria-hidden="true">
<motion.div <motion.div
className="absolute -top-[20%] -left-[10%] w-[50%] h-[50%] bg-indigo-900/10 rounded-full blur-3xl" className="absolute -top-[20%] -left-[10%] w-[50%] h-[50%] bg-indigo-900/10 rounded-full blur-3xl"
animate={{ animate={prefersReducedMotion ? {} : {
x: [0, 30, 0], x: [0, 30, 0],
y: [0, -20, 0], y: [0, -20, 0],
scale: [1, 1.1, 1] scale: [1, 1.1, 1]
@@ -289,7 +316,7 @@ const App: React.FC = () => {
/> />
<motion.div <motion.div
className="absolute top-[20%] -right-[10%] w-[40%] h-[40%] bg-violet-900/10 rounded-full blur-3xl" className="absolute top-[20%] -right-[10%] w-[40%] h-[40%] bg-violet-900/10 rounded-full blur-3xl"
animate={{ animate={prefersReducedMotion ? {} : {
x: [0, -20, 0], x: [0, -20, 0],
y: [0, 30, 0], y: [0, 30, 0],
scale: [1, 1.15, 1] scale: [1, 1.15, 1]
@@ -324,7 +351,7 @@ const App: React.FC = () => {
Turn Markdown into <br /> Turn Markdown into <br />
<motion.span <motion.span
className="text-transparent bg-clip-text bg-gradient-to-r from-indigo-400 to-violet-400" className="text-transparent bg-clip-text bg-gradient-to-r from-indigo-400 to-violet-400"
animate={{ animate={prefersReducedMotion ? {} : {
backgroundPosition: ["0% 50%", "100% 50%", "0% 50%"] backgroundPosition: ["0% 50%", "100% 50%", "0% 50%"]
}} }}
transition={{ duration: 5, repeat: Infinity, ease: "linear" }} transition={{ duration: 5, repeat: Infinity, ease: "linear" }}
@@ -376,7 +403,6 @@ const App: React.FC = () => {
exit={{ opacity: 0, y: -10, scale: 0.95 }} exit={{ opacity: 0, y: -10, scale: 0.95 }}
className="mt-4 p-4 bg-red-900/20 border border-red-800 rounded-xl text-center text-red-300" className="mt-4 p-4 bg-red-900/20 border border-red-800 rounded-xl text-center text-red-300"
role="alert" role="alert"
aria-live="polite"
> >
{error} {error}
</motion.div> </motion.div>
@@ -388,6 +414,8 @@ const App: React.FC = () => {
{appState === AppState.GENERATING && ( {appState === AppState.GENERATING && (
<motion.div <motion.div
key="generating" key="generating"
role="status"
aria-busy="true"
initial={{ opacity: 0, scale: 0.9 }} initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 1.1 }} exit={{ opacity: 0, scale: 1.1 }}
@@ -396,15 +424,15 @@ const App: React.FC = () => {
> >
<motion.div <motion.div
className="relative" className="relative"
animate={{ rotate: 360 }} animate={prefersReducedMotion ? {} : { rotate: 360 }}
transition={{ duration: 2, repeat: Infinity, ease: "linear" }} transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
> >
<motion.div <motion.div
className="absolute inset-0 bg-indigo-500 blur-xl opacity-20" className="absolute inset-0 bg-indigo-500 blur-xl opacity-20"
animate={{ scale: [1, 1.2, 1], opacity: [0.2, 0.4, 0.2] }} animate={prefersReducedMotion ? {} : { scale: [1, 1.2, 1], opacity: [0.2, 0.4, 0.2] }}
transition={{ duration: 1.5, repeat: Infinity }} transition={{ duration: 1.5, repeat: Infinity }}
/> />
<Loader2 size={64} className="text-indigo-400 relative z-10" /> <Loader2 size={64} className="text-indigo-400 relative z-10" aria-hidden="true" />
</motion.div> </motion.div>
<motion.h3 <motion.h3
className="mt-8 text-2xl font-bold text-white" className="mt-8 text-2xl font-bold text-white"
@@ -433,7 +461,7 @@ const App: React.FC = () => {
initial={{ y: 20, opacity: 0 }} initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }} animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.5, duration: 0.5 }} transition={{ delay: 0.5, duration: 0.5 }}
className="flex-none py-4 text-center text-zinc-600 text-sm border-t border-zinc-900 bg-zinc-950" className="flex-none py-4 text-center text-zinc-400 text-sm border-t border-zinc-900 bg-zinc-950"
> >
<p>TypoGenie - Professional Typesetting Engine</p> <p>TypoGenie - Professional Typesetting Engine</p>
</motion.footer> </motion.footer>
+43
View File
@@ -0,0 +1,43 @@
import React from 'react';
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends React.Component<{ children: React.ReactNode }, ErrorBoundaryState> {
constructor(props: { children: React.ReactNode }) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
render() {
if (this.state.hasError) {
return (
<div
role="alert"
className="h-screen w-screen flex items-center justify-center bg-zinc-950 text-zinc-100 p-8"
>
<div className="max-w-md text-center">
<h1 className="text-2xl font-bold mb-4">Something went wrong</h1>
<p className="text-zinc-400 mb-6">
TypoGenie encountered an unexpected error. Please reload the application.
</p>
<button
onClick={() => window.location.reload()}
className="px-6 py-3 bg-indigo-600 hover:bg-indigo-500 text-white font-semibold rounded-xl transition-colors"
>
Reload
</button>
</div>
</div>
);
}
return this.props.children;
}
}
+104 -90
View File
@@ -1,5 +1,6 @@
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { useDialog } from '../hooks/useDialog';
interface ExportOptionsModalProps { interface ExportOptionsModalProps {
isOpen: boolean; isOpen: boolean;
@@ -8,6 +9,7 @@ interface ExportOptionsModalProps {
} }
export default function ExportOptionsModal({ isOpen, onClose, onExport }: ExportOptionsModalProps) { export default function ExportOptionsModal({ isOpen, onClose, onExport }: ExportOptionsModalProps) {
const { dialogRef, handleBackdropClick, close } = useDialog(isOpen, { onClose });
const [selectedMode, setSelectedMode] = useState<'table' | 'semantic'>('semantic'); const [selectedMode, setSelectedMode] = useState<'table' | 'semantic'>('semantic');
if (!isOpen) return null; if (!isOpen) return null;
@@ -17,15 +19,23 @@ export default function ExportOptionsModal({ isOpen, onClose, onExport }: Export
}; };
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-zinc-950/80 backdrop-blur-sm"> <dialog
<div className="relative w-full max-w-2xl bg-zinc-900 rounded-2xl shadow-2xl m-4 overflow-hidden border border-zinc-700"> ref={dialogRef}
onClick={handleBackdropClick}
aria-labelledby="export-title"
className="fixed inset-0 z-50 p-4"
>
<div
className="relative w-full max-w-2xl bg-zinc-900 rounded-2xl shadow-2xl overflow-hidden border border-zinc-700"
onClick={e => e.stopPropagation()}
>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-800 bg-zinc-900/50"> <div className="flex items-center justify-between px-6 py-4 border-b border-zinc-800 bg-zinc-900/50">
<h2 className="text-xl font-semibold text-white">Export Options</h2> <h2 id="export-title" className="text-xl font-semibold text-white">Export Options</h2>
<button <button
onClick={onClose} onClick={close}
className="p-1 text-zinc-400 hover:text-white transition-colors rounded-md hover:bg-zinc-800" className="p-2 min-w-[44px] min-h-[44px] flex items-center justify-center text-zinc-400 hover:text-white transition-colors rounded-md hover:bg-zinc-800"
aria-label="Close" aria-label="Close export options"
> >
<X size={20} /> <X size={20} />
</button> </button>
@@ -37,109 +47,113 @@ export default function ExportOptionsModal({ isOpen, onClose, onExport }: Export
Choose how headers should be rendered in your Word document: Choose how headers should be rendered in your Word document:
</p> </p>
{/* Option 1: High-Fidelity */} <fieldset>
<label <legend className="sr-only">Header rendering mode</legend>
className={`block p-4 border-2 rounded-lg cursor-pointer transition-all ${selectedMode === 'table'
? 'border-indigo-500 bg-indigo-500/10'
: 'border-zinc-700 hover:border-zinc-600 bg-zinc-800/50'
}`}
>
<div className="flex items-start">
<input
type="radio"
name="exportMode"
value="table"
checked={selectedMode === 'table'}
onChange={() => setSelectedMode('table')}
className="mt-1 mr-3 text-indigo-600 focus:ring-indigo-500 bg-zinc-700 border-zinc-600"
/>
<div className="flex-1">
<div className="font-semibold text-white mb-2">High-Fidelity Layout</div>
<div className="space-y-1 text-sm">
<div className="flex items-start">
<span className="text-green-500 mr-2"></span>
<span className="text-zinc-300">Perfect padding and border alignment</span>
</div>
<div className="flex items-start">
<span className="text-green-500 mr-2"></span>
<span className="text-zinc-300">Backgrounds contained precisely</span>
</div>
<div className="flex items-start">
<span className="text-red-500 mr-2"></span>
<span className="text-zinc-300">No automatic Table of Contents</span>
</div>
<div className="flex items-start">
<span className="text-red-500 mr-2"></span>
<span className="text-zinc-300">Document outline/navigation disabled</span>
</div>
</div>
<div className="mt-3 text-xs text-zinc-500 italic">
Best for: Portfolios, brochures, print-ready designs
</div>
</div>
</div>
</label>
{/* Option 2: Semantic */} {/* Option 1: High-Fidelity */}
<label <label
className={`block p-4 border-2 rounded-lg cursor-pointer transition-all ${selectedMode === 'semantic' className={`block p-4 border-2 rounded-lg cursor-pointer transition-all ${selectedMode === 'table'
? 'border-indigo-500 bg-indigo-500/10' ? 'border-indigo-500 bg-indigo-500/10'
: 'border-zinc-700 hover:border-zinc-600 bg-zinc-800/50' : 'border-zinc-700 hover:border-zinc-600 bg-zinc-800/50'
}`} }`}
> >
<div className="flex items-start"> <div className="flex items-start">
<input <input
type="radio" type="radio"
name="exportMode" name="exportMode"
value="semantic" value="table"
checked={selectedMode === 'semantic'} checked={selectedMode === 'table'}
onChange={() => setSelectedMode('semantic')} onChange={() => setSelectedMode('table')}
className="mt-1 mr-3 text-indigo-600 focus:ring-indigo-500 bg-zinc-700 border-zinc-600" className="mt-1 mr-3 text-indigo-600 focus:ring-indigo-500 bg-zinc-700 border-zinc-600"
/> />
<div className="flex-1"> <div className="flex-1">
<div className="font-semibold text-white mb-2">Semantic Structure</div> <div className="font-semibold text-white mb-2">High-Fidelity Layout</div>
<div className="space-y-1 text-sm"> <div className="space-y-1 text-sm">
<div className="flex items-start"> <div className="flex items-start">
<span className="text-green-500 mr-2"></span> <span className="text-green-500 mr-2" aria-hidden="true">&#10003;</span>
<span className="text-zinc-300">Auto-generated Table of Contents</span> <span className="text-zinc-300">Perfect padding and border alignment</span>
</div>
<div className="flex items-start">
<span className="text-green-500 mr-2" aria-hidden="true">&#10003;</span>
<span className="text-zinc-300">Backgrounds contained precisely</span>
</div>
<div className="flex items-start">
<span className="text-red-500 mr-2" aria-hidden="true">&#10007;</span>
<span className="text-zinc-300">No automatic Table of Contents</span>
</div>
<div className="flex items-start">
<span className="text-red-500 mr-2" aria-hidden="true">&#10007;</span>
<span className="text-zinc-300">Document outline/navigation disabled</span>
</div>
</div> </div>
<div className="flex items-start"> <div className="mt-3 text-xs text-zinc-400 italic">
<span className="text-green-500 mr-2"></span> Best for: Portfolios, brochures, print-ready designs
<span className="text-zinc-300">Document navigation panel works</span>
</div> </div>
<div className="flex items-start">
<span className="text-green-500 mr-2"></span>
<span className="text-zinc-300">Screen reader accessible</span>
</div>
<div className="flex items-start">
<span className="text-yellow-500 mr-2"></span>
<span className="text-zinc-300">Minor padding/border alignment issues</span>
</div>
</div>
<div className="mt-3 text-xs text-zinc-500 italic">
Best for: Academic papers, reports, accessible documents
</div> </div>
</div> </div>
</div> </label>
</label>
{/* Option 2: Semantic */}
<label
className={`block p-4 mt-4 border-2 rounded-lg cursor-pointer transition-all ${selectedMode === 'semantic'
? 'border-indigo-500 bg-indigo-500/10'
: 'border-zinc-700 hover:border-zinc-600 bg-zinc-800/50'
}`}
>
<div className="flex items-start">
<input
type="radio"
name="exportMode"
value="semantic"
checked={selectedMode === 'semantic'}
onChange={() => setSelectedMode('semantic')}
className="mt-1 mr-3 text-indigo-600 focus:ring-indigo-500 bg-zinc-700 border-zinc-600"
/>
<div className="flex-1">
<div className="font-semibold text-white mb-2">Semantic Structure</div>
<div className="space-y-1 text-sm">
<div className="flex items-start">
<span className="text-green-500 mr-2" aria-hidden="true">&#10003;</span>
<span className="text-zinc-300">Auto-generated Table of Contents</span>
</div>
<div className="flex items-start">
<span className="text-green-500 mr-2" aria-hidden="true">&#10003;</span>
<span className="text-zinc-300">Document navigation panel works</span>
</div>
<div className="flex items-start">
<span className="text-green-500 mr-2" aria-hidden="true">&#10003;</span>
<span className="text-zinc-300">Screen reader accessible</span>
</div>
<div className="flex items-start">
<span className="text-yellow-500 mr-2" aria-hidden="true">&#9888;</span>
<span className="text-zinc-300">Minor padding/border alignment issues</span>
</div>
</div>
<div className="mt-3 text-xs text-zinc-400 italic">
Best for: Academic papers, reports, accessible documents
</div>
</div>
</div>
</label>
</fieldset>
</div> </div>
{/* Footer */} {/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-zinc-800 bg-zinc-900"> <div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-zinc-800 bg-zinc-900">
<button <button
onClick={onClose} onClick={close}
className="px-4 py-2 text-sm font-medium text-zinc-300 bg-zinc-800 border border-zinc-700 rounded-md hover:bg-zinc-700 transition-colors" className="px-5 py-3 text-sm font-medium text-zinc-300 bg-zinc-800 border border-zinc-700 rounded-md hover:bg-zinc-700 transition-colors"
> >
Cancel Cancel
</button> </button>
<button <button
onClick={handleExport} onClick={handleExport}
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-500 transition-colors" className="px-5 py-3 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-500 transition-colors"
> >
Export to Word Export to Word
</button> </button>
</div> </div>
</div> </div>
</div> </dialog>
); );
} }
+8 -15
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useState, useRef, useEffect } from 'react'; import React, { useCallback, useState, useRef } from 'react';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { Upload, FileText, AlertCircle } from 'lucide-react'; import { Upload, FileText, AlertCircle } from 'lucide-react';
import { useKeyboardNavigation } from '../hooks/useKeyboardNavigation'; import { useKeyboardNavigation } from '../hooks/useKeyboardNavigation';
@@ -87,14 +87,6 @@ export const FileUpload: React.FC<FileUploadProps> = ({ onFileLoaded }) => {
}, },
}, [isFocused]); }, [isFocused]);
// Clear error after 5 seconds
useEffect(() => {
if (error) {
const timer = setTimeout(() => setError(null), 5000);
return () => clearTimeout(timer);
}
}, [error]);
return ( return (
<motion.div <motion.div
className="w-full max-w-xl mx-auto" className="w-full max-w-xl mx-auto"
@@ -109,6 +101,7 @@ export const FileUpload: React.FC<FileUploadProps> = ({ onFileLoaded }) => {
role="button" role="button"
tabIndex={0} tabIndex={0}
aria-label="Drop zone. Click or press Enter to select a file." aria-label="Drop zone. Click or press Enter to select a file."
aria-describedby={error ? "upload-error" : undefined}
className={`relative group flex flex-col items-center justify-center w-full h-64 border-2 border-dashed rounded-2xl cursor-pointer overflow-hidden outline-none transition-all className={`relative group flex flex-col items-center justify-center w-full h-64 border-2 border-dashed rounded-2xl cursor-pointer overflow-hidden outline-none transition-all
${dragActive ? 'border-indigo-500 bg-indigo-500/10' : 'border-zinc-700 bg-zinc-900/50'} ${dragActive ? 'border-indigo-500 bg-indigo-500/10' : 'border-zinc-700 bg-zinc-900/50'}
${isFocused ? 'ring-2 ring-indigo-500 ring-offset-2 ring-offset-zinc-950 border-indigo-400' : ''} ${isFocused ? 'ring-2 ring-indigo-500 ring-offset-2 ring-offset-zinc-950 border-indigo-400' : ''}
@@ -149,7 +142,7 @@ export const FileUpload: React.FC<FileUploadProps> = ({ onFileLoaded }) => {
transition={{ duration: 0.3 }} transition={{ duration: 0.3 }}
> >
<motion.div <motion.div
className={`p-4 rounded-full mb-4 ${dragActive ? 'bg-indigo-500/20 text-indigo-400' : 'bg-zinc-800 text-zinc-500'}`} className={`p-4 rounded-full mb-4 ${dragActive ? 'bg-indigo-500/20 text-indigo-400' : 'bg-zinc-800 text-zinc-400'}`}
animate={dragActive ? { animate={dragActive ? {
scale: [1, 1.2, 1], scale: [1, 1.2, 1],
rotate: [0, 10, -10, 0] rotate: [0, 10, -10, 0]
@@ -157,7 +150,7 @@ export const FileUpload: React.FC<FileUploadProps> = ({ onFileLoaded }) => {
transition={{ duration: 0.5 }} transition={{ duration: 0.5 }}
whileHover={{ scale: 1.1, rotate: 5 }} whileHover={{ scale: 1.1, rotate: 5 }}
> >
<Upload size={32} /> <Upload size={32} aria-hidden="true" />
</motion.div> </motion.div>
<motion.p <motion.p
className="mb-2 text-lg font-medium" className="mb-2 text-lg font-medium"
@@ -174,7 +167,7 @@ export const FileUpload: React.FC<FileUploadProps> = ({ onFileLoaded }) => {
or drag and drop or drag and drop
</motion.p> </motion.p>
<motion.p <motion.p
className="text-sm text-zinc-500" className="text-sm text-zinc-400"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ delay: 0.7 }} transition={{ delay: 0.7 }}
@@ -182,7 +175,7 @@ export const FileUpload: React.FC<FileUploadProps> = ({ onFileLoaded }) => {
Markdown or Plain Text files Markdown or Plain Text files
</motion.p> </motion.p>
<motion.p <motion.p
className="text-xs text-zinc-600 mt-2" className="text-xs text-zinc-400 mt-2"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ delay: 0.8 }} transition={{ delay: 0.8 }}
@@ -208,15 +201,15 @@ export const FileUpload: React.FC<FileUploadProps> = ({ onFileLoaded }) => {
initial={{ opacity: 0, y: -10, height: 0 }} initial={{ opacity: 0, y: -10, height: 0 }}
animate={{ opacity: 1, y: 0, height: 'auto' }} animate={{ opacity: 1, y: 0, height: 'auto' }}
exit={{ opacity: 0, y: -10, height: 0 }} exit={{ opacity: 0, y: -10, height: 0 }}
id="upload-error"
className="mt-4 p-4 bg-red-900/20 border border-red-800 rounded-lg flex items-center gap-3 text-red-200" className="mt-4 p-4 bg-red-900/20 border border-red-800 rounded-lg flex items-center gap-3 text-red-200"
role="alert" role="alert"
aria-live="polite"
> >
<motion.div <motion.div
animate={{ rotate: [0, 10, -10, 0] }} animate={{ rotate: [0, 10, -10, 0] }}
transition={{ duration: 0.5 }} transition={{ duration: 0.5 }}
> >
<AlertCircle size={20} /> <AlertCircle size={20} aria-hidden="true" />
</motion.div> </motion.div>
<span>{error}</span> <span>{error}</span>
</motion.div> </motion.div>
+28 -17
View File
@@ -33,20 +33,20 @@ const ZoomControl: React.FC<{ zoom: number; onZoomChange: (zoom: number) => void
onClick={decreaseZoom} onClick={decreaseZoom}
whileHover={{ scale: 1.1 }} whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }} whileTap={{ scale: 0.9 }}
className="p-1 text-zinc-400 hover:text-white transition-colors" className="p-2 min-w-[44px] min-h-[44px] flex items-center justify-center text-zinc-400 hover:text-white transition-colors"
aria-label="Zoom out" aria-label="Zoom out"
> >
<ZoomOut size={16} /> <ZoomOut size={16} aria-hidden="true" />
</motion.button> </motion.button>
<span className="text-xs font-medium text-zinc-300 min-w-[3rem] text-center">{zoom}%</span> <span className="text-xs font-medium text-zinc-300 min-w-[3rem] text-center">{zoom}%</span>
<motion.button <motion.button
onClick={increaseZoom} onClick={increaseZoom}
whileHover={{ scale: 1.1 }} whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }} whileTap={{ scale: 0.9 }}
className="p-1 text-zinc-400 hover:text-white transition-colors" className="p-2 min-w-[44px] min-h-[44px] flex items-center justify-center text-zinc-400 hover:text-white transition-colors"
aria-label="Zoom in" aria-label="Zoom in"
> >
<ZoomIn size={16} /> <ZoomIn size={16} aria-hidden="true" />
</motion.button> </motion.button>
</div> </div>
); );
@@ -85,7 +85,7 @@ const FontList: React.FC<{ fonts: string[] }> = ({ fonts }) => {
}; };
return ( return (
<div className="flex items-center gap-2 text-sm text-zinc-500"> <div className="flex items-center gap-2 text-sm text-zinc-400">
<span className="hidden md:inline">Fonts:</span> <span className="hidden md:inline">Fonts:</span>
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
{fonts.map((font, index) => ( {fonts.map((font, index) => (
@@ -96,18 +96,20 @@ const FontList: React.FC<{ fonts: string[] }> = ({ fonts }) => {
disabled={downloadingFont === font} disabled={downloadingFont === font}
whileHover={{ scale: 1.05 }} whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }} whileTap={{ scale: 0.95 }}
aria-label={`Download ${font} font`}
className="px-2 py-1 bg-zinc-900 border border-zinc-800 hover:border-zinc-600 rounded-l text-zinc-300 hover:text-white transition-all flex items-center gap-1.5 text-xs font-medium" className="px-2 py-1 bg-zinc-900 border border-zinc-800 hover:border-zinc-600 rounded-l text-zinc-300 hover:text-white transition-all flex items-center gap-1.5 text-xs font-medium"
> >
{downloadingFont === font ? <Loader2 size={10} className="animate-spin" /> : <Download size={10} />} {downloadingFont === font ? <Loader2 size={10} className="animate-spin" aria-hidden="true" /> : <Download size={10} aria-hidden="true" />}
{font} {font}
</motion.button> </motion.button>
<motion.button <motion.button
onClick={() => openGoogleFonts(font)} onClick={() => openGoogleFonts(font)}
whileHover={{ scale: 1.05 }} whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }} whileTap={{ scale: 0.95 }}
aria-label="View on Google Fonts"
className="px-1.5 py-1 bg-zinc-900 border border-l-0 border-zinc-800 hover:border-zinc-600 rounded-r text-zinc-400 hover:text-white transition-all" className="px-1.5 py-1 bg-zinc-900 border border-l-0 border-zinc-800 hover:border-zinc-600 rounded-r text-zinc-400 hover:text-white transition-all"
> >
<ExternalLink size={10} /> <ExternalLink size={10} aria-hidden="true" />
</motion.button> </motion.button>
</div> </div>
{downloadStatus[font] && ( {downloadStatus[font] && (
@@ -139,6 +141,7 @@ export const Preview: React.FC<PreviewProps> = ({
const [successMsg, setSuccessMsg] = useState(false); const [successMsg, setSuccessMsg] = useState(false);
const [showExportModal, setShowExportModal] = useState(false); const [showExportModal, setShowExportModal] = useState(false);
const [focusedElement, setFocusedElement] = useState<'back' | 'fonts' | 'save'>('save'); const [focusedElement, setFocusedElement] = useState<'back' | 'fonts' | 'save'>('save');
const [exportError, setExportError] = useState<string | null>(null);
// Get current style from templates // Get current style from templates
const style = templates.find(s => s.id === selectedStyleId) || templates[0] || null; const style = templates.find(s => s.id === selectedStyleId) || templates[0] || null;
@@ -221,7 +224,8 @@ export const Preview: React.FC<PreviewProps> = ({
palette: template.typography?.colors, palette: template.typography?.colors,
id: template.id, id: template.id,
page: template.page, page: template.page,
useTableHeaders useTableHeaders,
inputFileName
}); });
const arrayBuffer = await blob.arrayBuffer(); const arrayBuffer = await blob.arrayBuffer();
@@ -240,7 +244,7 @@ export const Preview: React.FC<PreviewProps> = ({
} }
} catch (e) { } catch (e) {
console.error("Docx Gen Error", e); console.error("Docx Gen Error", e);
alert("Failed to generate DOCX: " + e); setExportError("Failed to generate DOCX: " + e);
} finally { } finally {
setIsExporting(false); setIsExporting(false);
} }
@@ -310,7 +314,7 @@ export const Preview: React.FC<PreviewProps> = ({
// Inject CSS directly as inline style tag // Inject CSS directly as inline style tag
const html = ` const html = `
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="en">
<head> <head>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -373,7 +377,7 @@ export const Preview: React.FC<PreviewProps> = ({
}, [htmlContent, paperSize, selectedStyleId, templates, style]); }, [htmlContent, paperSize, selectedStyleId, templates, style]);
if (!style) { if (!style) {
return <div className="h-screen flex items-center justify-center text-white">Loading...</div>; return <div className="h-screen flex items-center justify-center text-white" role="status">Loading...</div>;
} }
return ( return (
@@ -381,22 +385,23 @@ export const Preview: React.FC<PreviewProps> = ({
<motion.div className="sticky top-0 z-50 bg-zinc-950/90 backdrop-blur-md border-b border-zinc-800 p-4" initial={{ y: -20, opacity: 0 }} animate={{ y: 0, opacity: 1 }} transition={{ duration: 0.4, ease: [0.22, 1, 0.36, 1] }}> <motion.div className="sticky top-0 z-50 bg-zinc-950/90 backdrop-blur-md border-b border-zinc-800 p-4" initial={{ y: -20, opacity: 0 }} animate={{ y: 0, opacity: 1 }} transition={{ duration: 0.4, ease: [0.22, 1, 0.36, 1] }}>
<div className="max-w-7xl mx-auto flex flex-col sm:flex-row justify-between items-center gap-4"> <div className="max-w-7xl mx-auto flex flex-col sm:flex-row justify-between items-center gap-4">
<motion.button ref={backButtonRef} onClick={onBack} onFocus={() => setFocusedElement('back')} whileHover={{ x: -3 }} whileTap={{ scale: 0.95 }} className="flex items-center gap-2 text-zinc-400 hover:text-white transition-colors outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-zinc-950 rounded-lg px-2 py-1"> <motion.button ref={backButtonRef} onClick={onBack} onFocus={() => setFocusedElement('back')} whileHover={{ x: -3 }} whileTap={{ scale: 0.95 }} className="flex items-center gap-2 text-zinc-400 hover:text-white transition-colors outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-zinc-950 rounded-lg px-2 py-1">
<ArrowLeft size={20} /> <ArrowLeft size={20} aria-hidden="true" />
<span>Back to Editor</span> <span>Back to Editor</span>
</motion.button> </motion.button>
<div className="flex flex-col sm:flex-row items-center gap-6"> <div className="flex flex-col sm:flex-row items-center gap-6">
<FontList fonts={usedFonts} /> <FontList fonts={usedFonts} />
<div className="h-4 w-px bg-zinc-800 hidden sm:block" /> <div className="h-4 w-px bg-zinc-800 hidden sm:block" aria-hidden="true" />
<ZoomControl zoom={uiZoom} onZoomChange={onZoomChange} /> <ZoomControl zoom={uiZoom} onZoomChange={onZoomChange} />
<div className="h-4 w-px bg-zinc-800 hidden sm:block" /> <div className="h-4 w-px bg-zinc-800 hidden sm:block" aria-hidden="true" />
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span className="text-zinc-500 text-sm hidden sm:inline">Format: {paperSize}</span> <span className="text-zinc-400 text-sm hidden sm:inline">Format: {paperSize}</span>
<motion.button <motion.button
ref={saveButtonRef} ref={saveButtonRef}
onClick={handleSave} onClick={handleSave}
onFocus={() => setFocusedElement('save')} onFocus={() => setFocusedElement('save')}
disabled={isExporting} disabled={isExporting}
aria-live="polite"
whileHover={{ scale: 1.05, boxShadow: '0 0 20px rgba(59, 130, 246, 0.4)' }} whileHover={{ scale: 1.05, boxShadow: '0 0 20px rgba(59, 130, 246, 0.4)' }}
whileTap={{ scale: 0.95 }} whileTap={{ scale: 0.95 }}
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg font-semibold transition-colors shadow-lg ${successMsg ? 'bg-emerald-600 text-white' : 'bg-blue-600 text-white hover:bg-blue-500'} ${isExporting ? 'opacity-50 cursor-wait' : ''}`} className={`flex items-center gap-2 px-5 py-2.5 rounded-lg font-semibold transition-colors shadow-lg ${successMsg ? 'bg-emerald-600 text-white' : 'bg-blue-600 text-white hover:bg-blue-500'} ${isExporting ? 'opacity-50 cursor-wait' : ''}`}
@@ -404,12 +409,12 @@ export const Preview: React.FC<PreviewProps> = ({
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
{successMsg ? ( {successMsg ? (
<motion.div key="success" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }} className="flex items-center gap-2"> <motion.div key="success" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }} className="flex items-center gap-2">
<CheckCircle2 size={18} /> <CheckCircle2 size={18} aria-hidden="true" />
<span>Saved!</span> <span>Saved!</span>
</motion.div> </motion.div>
) : ( ) : (
<motion.div key="default" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }} className="flex items-center gap-2"> <motion.div key="default" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }} className="flex items-center gap-2">
{isExporting ? <Loader2 size={18} className="animate-spin" /> : <FileText size={18} />} {isExporting ? <Loader2 size={18} className="animate-spin" aria-hidden="true" /> : <FileText size={18} aria-hidden="true" />}
<span>{isExporting ? 'Generating...' : 'Save Word Doc'}</span> <span>{isExporting ? 'Generating...' : 'Save Word Doc'}</span>
</motion.div> </motion.div>
)} )}
@@ -429,6 +434,12 @@ export const Preview: React.FC<PreviewProps> = ({
onClose={() => setShowExportModal(false)} onClose={() => setShowExportModal(false)}
onExport={handleExportConfirm} onExport={handleExportConfirm}
/> />
{exportError && (
<div role="alert" className="fixed bottom-4 right-4 z-50 p-4 bg-red-900/90 border border-red-800 rounded-xl text-red-200 max-w-md">
{exportError}
<button onClick={() => setExportError(null)} className="ml-3 text-red-400 hover:text-white" aria-label="Dismiss error">&#10005;</button>
</div>
)}
</motion.div> </motion.div>
); );
}; };
+33 -36
View File
@@ -1,8 +1,10 @@
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef } from 'react';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { StyleOption } from '../types'; import { StyleOption } from '../types';
import { useDialog } from '../hooks/useDialog';
interface StylePreviewModalProps { interface StylePreviewModalProps {
isOpen: boolean;
style: StyleOption; style: StyleOption;
onClose: () => void; onClose: () => void;
} }
@@ -10,42 +12,35 @@ interface StylePreviewModalProps {
const SAMPLE_CONTENT = ` const SAMPLE_CONTENT = `
<h1>The Art of Typography</h1> <h1>The Art of Typography</h1>
<p>Typography is the art and technique of arranging type to make written language legible, readable, and appealing when displayed. The arrangement of type involves selecting typefaces, point sizes, line lengths, line-spacing, and letter-spacing.</p> <p>Typography is the art and technique of arranging type to make written language legible, readable, and appealing when displayed. The arrangement of type involves selecting typefaces, point sizes, line lengths, line-spacing, and letter-spacing.</p>
<h2>1. Visual Hierarchy</h2> <h2>1. Visual Hierarchy</h2>
<p>Visual hierarchy enables the reader to understand the importance of different sections. By using size, weight, and color, we guide the eye through the document in a logical flow.</p> <p>Visual hierarchy enables the reader to understand the importance of different sections. By using size, weight, and color, we guide the eye through the document in a logical flow.</p>
<blockquote> <blockquote>
"Design is not just what it looks like and feels like. Design is how it works." "Design is not just what it looks like and feels like. Design is how it works."
</blockquote> </blockquote>
<h2>2. Key Elements</h2> <h2>2. Key Elements</h2>
<ul> <ul>
<li><strong>Typeface:</strong> The design of the letters.</li> <li><strong>Typeface:</strong> The design of the letters.</li>
<li><strong>Contrast:</strong> Distinguishing elements effectively.</li> <li><strong>Contrast:</strong> Distinguishing elements effectively.</li>
<li><strong>Consistency:</strong> Maintaining a coherent structure.</li> <li><strong>Consistency:</strong> Maintaining a coherent structure.</li>
</ul> </ul>
<p>Good typography is invisible. It should not distract the reader but rather enhance the reading experience, ensuring the message is delivered clearly and effectively.</p> <p>Good typography is invisible. It should not distract the reader but rather enhance the reading experience, ensuring the message is delivered clearly and effectively.</p>
`; `;
export const StylePreviewModal: React.FC<StylePreviewModalProps> = ({ style, onClose }) => { export const StylePreviewModal: React.FC<StylePreviewModalProps> = ({ isOpen, style, onClose }) => {
const { dialogRef, handleBackdropClick, close } = useDialog(isOpen, { onClose });
const iframeRef = useRef<HTMLIFrameElement>(null); const iframeRef = useRef<HTMLIFrameElement>(null);
useEffect(() => { useEffect(() => {
const handleEscape = (e: KeyboardEvent) => { if (!isOpen || !iframeRef.current) return;
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleEscape);
return () => window.removeEventListener('keydown', handleEscape);
}, [onClose]);
useEffect(() => {
if (!iframeRef.current) return;
const doc = iframeRef.current.contentDocument; const doc = iframeRef.current.contentDocument;
if (doc) { if (doc) {
const html = ` const html = `
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="en">
<head> <head>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -74,29 +69,31 @@ export const StylePreviewModal: React.FC<StylePreviewModalProps> = ({ style, onC
doc.write(html); doc.write(html);
doc.close(); doc.close();
} }
}, [style]); }, [isOpen, style]);
if (!isOpen) return null;
return ( return (
<div <dialog
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-zinc-950/80 backdrop-blur-sm animate-in fade-in duration-200" ref={dialogRef}
onClick={(e) => { onClick={handleBackdropClick}
// Close if clicking the background overlay directly aria-labelledby="preview-title"
if (e.target === e.currentTarget) { className="fixed inset-0 z-[100] p-4"
onClose();
}
}}
> >
<div className="relative w-full max-w-3xl bg-zinc-900 rounded-2xl border border-zinc-700 shadow-2xl overflow-hidden flex flex-col h-[85vh]"> <div
className="relative w-full max-w-3xl bg-zinc-900 rounded-2xl border border-zinc-700 shadow-2xl overflow-hidden flex flex-col h-[85vh]"
onClick={e => e.stopPropagation()}
>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-4 border-b border-zinc-800 bg-zinc-900/50"> <div className="flex items-center justify-between p-4 border-b border-zinc-800 bg-zinc-900/50">
<div> <div>
<h3 className="text-xl font-bold text-white">{style.name}</h3> <h3 id="preview-title" className="text-xl font-bold text-white">{style.name}</h3>
<p className="text-sm text-zinc-400">{style.description}</p> <p className="text-sm text-zinc-400">{style.description}</p>
</div> </div>
<button <button
onClick={onClose} onClick={close}
className="p-2 text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-full transition-colors" className="p-2 min-w-[44px] min-h-[44px] flex items-center justify-center text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-full transition-colors"
aria-label="Close preview"
> >
<X size={24} /> <X size={24} />
</button> </button>
@@ -105,24 +102,24 @@ export const StylePreviewModal: React.FC<StylePreviewModalProps> = ({ style, onC
{/* Content */} {/* Content */}
<div className="flex-grow bg-zinc-800 p-1 overflow-hidden relative"> <div className="flex-grow bg-zinc-800 p-1 overflow-hidden relative">
<div className="w-full h-full bg-white rounded-lg overflow-hidden shadow-inner"> <div className="w-full h-full bg-white rounded-lg overflow-hidden shadow-inner">
<iframe <iframe
ref={iframeRef} ref={iframeRef}
title="Style Preview" title="Style Preview"
className="w-full h-full border-0" className="w-full h-full border-0"
/> />
</div> </div>
</div> </div>
{/* Footer */} {/* Footer */}
<div className="p-4 border-t border-zinc-800 bg-zinc-900 flex justify-end"> <div className="p-4 border-t border-zinc-800 bg-zinc-900 flex justify-end">
<button <button
onClick={onClose} onClick={close}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white font-medium rounded-lg transition-colors" className="px-5 py-3 bg-indigo-600 hover:bg-indigo-500 text-white font-medium rounded-lg transition-colors"
> >
Close Preview Close Preview
</button> </button>
</div> </div>
</div> </div>
</div> </dialog>
); );
}; };
+59 -32
View File
@@ -30,7 +30,7 @@ const SAMPLE_CONTENT = `
<p>The smallest details matter most. Kerning, tracking, and leading work together to create <code>readable</code> text that flows naturally.</p> <p>The smallest details matter most. Kerning, tracking, and leading work together to create <code>readable</code> text that flows naturally.</p>
<blockquote> <blockquote>
"Design is not just what it looks like and feels like. Design is how it works." Steve Jobs "Design is not just what it looks like and feels like. Design is how it works." - Steve Jobs
</blockquote> </blockquote>
<h2>2. Essential Elements</h2> <h2>2. Essential Elements</h2>
@@ -190,7 +190,7 @@ export const StyleSelector: React.FC<StyleSelectorProps> = ({
const html = ` const html = `
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="en">
<head> <head>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -250,7 +250,7 @@ export const StyleSelector: React.FC<StyleSelectorProps> = ({
if (isLoading) { if (isLoading) {
return ( return (
<div className="w-full h-full flex items-center justify-center"> <div className="w-full h-full flex items-center justify-center" role="status">
<motion.div className="text-center" initial={{ opacity: 0 }} animate={{ opacity: 1 }}> <motion.div className="text-center" initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
<motion.div className="w-12 h-12 border-4 border-indigo-500/30 border-t-indigo-500 rounded-full mx-auto mb-4" animate={{ rotate: 360 }} transition={{ duration: 1, repeat: Infinity, ease: "linear" }} /> <motion.div className="w-12 h-12 border-4 border-indigo-500/30 border-t-indigo-500 rounded-full mx-auto mb-4" animate={{ rotate: 360 }} transition={{ duration: 1, repeat: Infinity, ease: "linear" }} />
<p className="text-zinc-400">Loading templates...</p> <p className="text-zinc-400">Loading templates...</p>
@@ -261,10 +261,10 @@ export const StyleSelector: React.FC<StyleSelectorProps> = ({
if (error) { if (error) {
return ( return (
<div className="w-full h-full flex flex-col items-center justify-center p-8 text-center"> <div className="w-full h-full flex flex-col items-center justify-center p-8 text-center" role="alert">
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="max-w-md"> <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="max-w-md">
<div className="w-16 h-16 bg-red-500/10 rounded-full flex items-center justify-center mx-auto mb-4"> <div className="w-16 h-16 bg-red-500/10 rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-2xl"></span> <span className="text-2xl" aria-hidden="true"></span>
</div> </div>
<h3 className="text-xl font-semibold text-white mb-2">Template Error</h3> <h3 className="text-xl font-semibold text-white mb-2">Template Error</h3>
<p className="text-zinc-400 mb-6">{error}</p> <p className="text-zinc-400 mb-6">{error}</p>
@@ -279,7 +279,7 @@ export const StyleSelector: React.FC<StyleSelectorProps> = ({
{/* Top Controls */} {/* Top Controls */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 px-1"> <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 px-1">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-2 bg-indigo-500/10 rounded-lg text-indigo-400"> <div className="p-2 bg-indigo-500/10 rounded-lg text-indigo-400" aria-hidden="true">
<Type size={24} /> <Type size={24} />
</div> </div>
<div> <div>
@@ -290,17 +290,18 @@ export const StyleSelector: React.FC<StyleSelectorProps> = ({
<div className="flex items-center gap-4 bg-zinc-900/50 p-1.5 rounded-xl border border-zinc-800/50"> <div className="flex items-center gap-4 bg-zinc-900/50 p-1.5 rounded-xl border border-zinc-800/50">
<div className="flex items-center gap-2 text-zinc-400 px-3"> <div className="flex items-center gap-2 text-zinc-400 px-3">
<Printer size={16} /> <Printer size={16} aria-hidden="true" />
<span className="text-xs font-semibold uppercase tracking-wider">Format</span> <span className="text-xs font-semibold uppercase tracking-wider">Format</span>
</div> </div>
<div className="flex gap-1"> <div className="flex gap-1" role="group" aria-label="Paper size">
{(['Letter', 'A4'] as PaperSize[]).map((size) => ( {(['Letter', 'A4'] as PaperSize[]).map((size) => (
<button <button
key={size} key={size}
onClick={() => onSelectPaperSize(size)} onClick={() => onSelectPaperSize(size)}
aria-pressed={selectedPaperSize === size}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${selectedPaperSize === size className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${selectedPaperSize === size
? 'bg-zinc-700 text-white shadow-sm' ? 'bg-zinc-700 text-white shadow-sm'
: 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800' : 'text-zinc-400 hover:text-zinc-300 hover:bg-zinc-800'
}`} }`}
> >
{size} {size}
@@ -317,22 +318,24 @@ export const StyleSelector: React.FC<StyleSelectorProps> = ({
<div className="lg:col-span-4 flex flex-col gap-4 min-h-0 bg-zinc-900/30 rounded-2xl border border-zinc-800/50 overflow-hidden"> <div className="lg:col-span-4 flex flex-col gap-4 min-h-0 bg-zinc-900/30 rounded-2xl border border-zinc-800/50 overflow-hidden">
{/* Category Filter Tabs */} {/* Category Filter Tabs */}
<div className="flex flex-col border-b border-zinc-800/50 bg-zinc-900/20"> <nav aria-label="Style filters" className="flex flex-col border-b border-zinc-800/50 bg-zinc-900/20">
<div className="p-4 overflow-x-auto no-scrollbar pb-2"> <div className="p-4 overflow-x-auto no-scrollbar pb-2">
<div className="flex gap-2"> <div className="flex gap-2" role="group" aria-label="Filter by category">
<button <button
onClick={() => setActiveCategory('fav')} onClick={() => setActiveCategory('fav')}
aria-pressed={activeCategory === 'fav'}
className={`px-3 py-1.5 rounded-full text-xs font-semibold whitespace-nowrap transition-all flex items-center gap-1.5 ${activeCategory === 'fav' className={`px-3 py-1.5 rounded-full text-xs font-semibold whitespace-nowrap transition-all flex items-center gap-1.5 ${activeCategory === 'fav'
? 'bg-rose-500 text-white shadow-lg shadow-rose-500/20' ? 'bg-rose-500 text-white shadow-lg shadow-rose-500/20'
: 'bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200' : 'bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200'
}`} }`}
> >
<Heart size={12} className={activeCategory === 'fav' ? 'fill-current' : ''} /> <Heart size={12} className={activeCategory === 'fav' ? 'fill-current' : ''} aria-hidden="true" />
fav fav
</button> </button>
<div className="w-px h-6 bg-zinc-800 mx-1 self-center" /> <div className="w-px h-6 bg-zinc-800 mx-1 self-center" />
<button <button
onClick={() => setActiveCategory('All')} onClick={() => setActiveCategory('All')}
aria-pressed={activeCategory === 'All'}
className={`px-3 py-1.5 rounded-full text-xs font-semibold whitespace-nowrap transition-all ${activeCategory === 'All' className={`px-3 py-1.5 rounded-full text-xs font-semibold whitespace-nowrap transition-all ${activeCategory === 'All'
? 'bg-white text-black' ? 'bg-white text-black'
: 'bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200' : 'bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200'
@@ -344,6 +347,7 @@ export const StyleSelector: React.FC<StyleSelectorProps> = ({
<button <button
key={cat} key={cat}
onClick={() => setActiveCategory(cat)} onClick={() => setActiveCategory(cat)}
aria-pressed={activeCategory === cat}
className={`px-3 py-1.5 rounded-full text-xs font-semibold whitespace-nowrap transition-all ${activeCategory === cat className={`px-3 py-1.5 rounded-full text-xs font-semibold whitespace-nowrap transition-all ${activeCategory === cat
? 'bg-indigo-500 text-white shadow-lg shadow-indigo-500/20' ? 'bg-indigo-500 text-white shadow-lg shadow-indigo-500/20'
: 'bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200' : 'bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200'
@@ -358,29 +362,48 @@ export const StyleSelector: React.FC<StyleSelectorProps> = ({
{/* Search Input */} {/* Search Input */}
<div className="px-4 pb-4"> <div className="px-4 pb-4">
<div className="relative group"> <div className="relative group">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500 group-focus-within:text-indigo-400 transition-colors" /> <Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400 group-focus-within:text-indigo-400 transition-colors" aria-hidden="true" />
<input <input
type="text" type="text"
placeholder="Search templates..." placeholder="Search templates..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-zinc-900 border border-zinc-700 rounded-xl py-2 pl-9 pr-4 text-sm text-zinc-200 placeholder:text-zinc-600 focus:outline-none focus:border-indigo-500/50 focus:ring-1 focus:ring-indigo-500/50 transition-all" aria-label="Search templates"
className="w-full bg-zinc-900 border border-zinc-700 rounded-xl py-2 pl-9 pr-4 text-sm text-zinc-200 placeholder:text-zinc-500 focus:outline-none focus:border-indigo-500/50 focus:ring-1 focus:ring-indigo-500/50 transition-all"
/> />
</div> </div>
<div role="status" aria-live="polite" className="sr-only">
{searchQuery.trim() ? `${filteredStyles.length} template${filteredStyles.length !== 1 ? 's' : ''} found` : ''}
</div>
</div> </div>
</div> </nav>
{/* Scrollable List */} {/* Scrollable List */}
<div className="flex-1 overflow-y-auto p-4 space-y-3 custom-scrollbar"> <section aria-label="Style list" className="flex-1 overflow-y-auto p-4 space-y-3 custom-scrollbar">
<div
role="listbox"
aria-label="Typography styles"
aria-activedescendant={selectedStyle ? `style-${selectedStyle}` : undefined}
>
{filteredStyles.length === 0 ? ( {filteredStyles.length === 0 ? (
<div className="text-center py-8 text-zinc-500"> <div className="text-center py-8 text-zinc-400">
<p className="text-sm">No templates found</p> <p className="text-sm">No templates found</p>
</div> </div>
) : filteredStyles.map((style) => ( ) : filteredStyles.map((style) => (
<div <div
key={style.id} key={style.id}
id={`style-${style.id}`}
role="option"
aria-selected={selectedStyle === style.id}
tabIndex={selectedStyle === style.id ? 0 : -1}
onClick={() => onSelectStyle(style.id)} onClick={() => onSelectStyle(style.id)}
className={`p-4 rounded-xl border transition-all cursor-pointer group relative onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelectStyle(style.id);
}
}}
className={`p-4 rounded-xl border transition-all cursor-pointer group relative outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950
${selectedStyle === style.id ${selectedStyle === style.id
? 'border-indigo-500 bg-indigo-500/10 shadow-[0_0_15px_rgba(99,102,241,0.15)]' ? 'border-indigo-500 bg-indigo-500/10 shadow-[0_0_15px_rgba(99,102,241,0.15)]'
: 'border-zinc-800 bg-zinc-900/40 hover:border-zinc-700 hover:bg-zinc-800'}`} : 'border-zinc-800 bg-zinc-900/40 hover:border-zinc-700 hover:bg-zinc-800'}`}
@@ -392,45 +415,48 @@ export const StyleSelector: React.FC<StyleSelectorProps> = ({
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<button <button
onClick={(e) => toggleFavorite(e, style.id)} onClick={(e) => toggleFavorite(e, style.id)}
className={`p-1.5 rounded-full transition-all ${favorites.includes(style.id) aria-label={favorites.includes(style.id) ? `Remove ${style.name} from favorites` : `Add ${style.name} to favorites`}
aria-pressed={favorites.includes(style.id)}
className={`min-w-[44px] min-h-[44px] flex items-center justify-center rounded-full transition-all ${favorites.includes(style.id)
? 'text-rose-400 bg-rose-500/10 hover:bg-rose-500/20' ? 'text-rose-400 bg-rose-500/10 hover:bg-rose-500/20'
: 'text-zinc-600 hover:text-zinc-400 hover:bg-zinc-700/50' : 'text-zinc-400 hover:text-zinc-300 hover:bg-zinc-700/50'
}`} }`}
> >
<Heart size={14} className={favorites.includes(style.id) ? 'fill-current' : ''} /> <Heart size={14} className={favorites.includes(style.id) ? 'fill-current' : ''} aria-hidden="true" />
</button> </button>
{selectedStyle === style.id && ( {selectedStyle === style.id && (
<div className="bg-indigo-500 rounded-full p-0.5"> <div className="bg-indigo-500 rounded-full p-0.5" aria-hidden="true">
<Check size={12} className="text-white" /> <Check size={12} className="text-white" />
</div> </div>
)} )}
</div> </div>
</div> </div>
<p className="text-xs text-zinc-500 line-clamp-2 leading-relaxed mb-2">{style.description}</p> <p className="text-xs text-zinc-400 line-clamp-2 leading-relaxed mb-2" title={style.description}>{style.description}</p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={`text-[10px] uppercase font-mono tracking-wider px-1.5 py-0.5 rounded <span className={`text-[10px] uppercase font-mono tracking-wider px-1.5 py-0.5 rounded
${selectedStyle === style.id ? 'bg-indigo-500/20 text-indigo-300' : 'bg-zinc-800 text-zinc-500'}`}> ${selectedStyle === style.id ? 'bg-indigo-500/20 text-indigo-300' : 'bg-zinc-800 text-zinc-400'}`}>
{style.category} {style.category}
</span> </span>
</div> </div>
</div> </div>
))} ))}
</div> </div>
</section>
</div> </div>
{/* RIGHT COLUMN: Preview Window */} {/* RIGHT COLUMN: Preview Window */}
<div className="lg:col-span-8 flex flex-col min-h-0 bg-zinc-950 rounded-2xl border border-zinc-800 shadow-2xl relative overflow-hidden"> <section aria-label="Style preview" className="lg:col-span-8 flex flex-col min-h-0 bg-zinc-950 rounded-2xl border border-zinc-800 shadow-2xl relative overflow-hidden">
{/* Preview Header */} {/* Preview Header */}
<div className="h-12 border-b border-zinc-800 bg-zinc-900/50 flex items-center px-4 justify-between"> <div className="h-12 border-b border-zinc-800 bg-zinc-900/50 flex items-center px-4 justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex gap-1.5"> <div className="flex gap-1.5" aria-hidden="true">
<div className="w-3 h-3 rounded-full bg-red-500/20 border border-red-500/50"></div> <div className="w-3 h-3 rounded-full bg-red-500/20 border border-red-500/50"></div>
<div className="w-3 h-3 rounded-full bg-amber-500/20 border border-amber-500/50"></div> <div className="w-3 h-3 rounded-full bg-amber-500/20 border border-amber-500/50"></div>
<div className="w-3 h-3 rounded-full bg-emerald-500/20 border border-emerald-500/50"></div> <div className="w-3 h-3 rounded-full bg-emerald-500/20 border border-emerald-500/50"></div>
</div> </div>
<div className="h-4 w-px bg-zinc-800 mx-2"></div> <div className="h-4 w-px bg-zinc-800 mx-2" aria-hidden="true"></div>
<span className="text-xs text-zinc-500 font-medium">Live Preview</span> <span className="text-xs text-zinc-400 font-medium">Live Preview</span>
</div> </div>
{currentStyleObj && ( {currentStyleObj && (
<span className="text-xs text-indigo-400 font-mono">{currentStyleObj.name}</span> <span className="text-xs text-indigo-400 font-mono">{currentStyleObj.name}</span>
@@ -444,10 +470,11 @@ export const StyleSelector: React.FC<StyleSelectorProps> = ({
ref={iframeRef} ref={iframeRef}
className="w-full h-full border-0 block" className="w-full h-full border-0 block"
title="Style Preview" title="Style Preview"
tabIndex={-1}
/> />
) : ( ) : (
<div className="absolute inset-0 flex flex-col items-center justify-center text-zinc-600"> <div className="absolute inset-0 flex flex-col items-center justify-center text-zinc-400">
<Search size={48} className="mb-4 opacity-20" /> <Search size={48} className="mb-4 opacity-20" aria-hidden="true" />
<p className="text-lg font-medium">Select a style to preview</p> <p className="text-lg font-medium">Select a style to preview</p>
<p className="text-sm">Choose from the list on the left</p> <p className="text-sm">Choose from the list on the left</p>
</div> </div>
@@ -462,10 +489,10 @@ export const StyleSelector: React.FC<StyleSelectorProps> = ({
className="flex items-center gap-2 px-6 py-3 bg-indigo-600 hover:bg-indigo-500 text-white font-bold rounded-xl transition-all shadow-lg hover:shadow-indigo-500/25 disabled:opacity-50 disabled:cursor-not-allowed" className="flex items-center gap-2 px-6 py-3 bg-indigo-600 hover:bg-indigo-500 text-white font-bold rounded-xl transition-all shadow-lg hover:shadow-indigo-500/25 disabled:opacity-50 disabled:cursor-not-allowed"
> >
<span>Apply Style & Convert</span> <span>Apply Style & Convert</span>
<Printer size={18} /> <Printer size={18} aria-hidden="true" />
</button> </button>
</div> </div>
</div> </section>
</div> </div>
</div> </div>
); );
+58
View File
@@ -0,0 +1,58 @@
import { useRef, useEffect, useCallback } from 'react';
interface UseDialogOptions {
onClose: () => void;
}
export function useDialog(isOpen: boolean, options: UseDialogOptions) {
const dialogRef = useRef<HTMLDialogElement>(null);
const triggerRef = useRef<Element | null>(null);
const close = useCallback(() => {
dialogRef.current?.close();
options.onClose();
}, [options.onClose]);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (isOpen) {
triggerRef.current = document.activeElement;
if (!dialog.open) {
dialog.showModal();
}
} else {
if (dialog.open) {
dialog.close();
}
// Restore focus to trigger
if (triggerRef.current instanceof HTMLElement) {
triggerRef.current.focus();
}
}
}, [isOpen]);
// Handle native cancel event (Escape key)
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
const handleCancel = (e: Event) => {
e.preventDefault();
close();
};
dialog.addEventListener('cancel', handleCancel);
return () => dialog.removeEventListener('cancel', handleCancel);
}, [close]);
// Handle backdrop click
const handleBackdropClick = useCallback((e: React.MouseEvent<HTMLDialogElement>) => {
if (e.target === e.currentTarget) {
close();
}
}, [close]);
return { dialogRef, handleBackdropClick, close };
}
+55 -4
View File
@@ -32,14 +32,14 @@
@layer base { @layer base {
:root { :root {
font-size: 16px; font-size: 100%;
} }
body { body {
font-family: 'Inter', sans-serif; font-family: 'Inter', sans-serif;
background-color: #09090b; background-color: #09090b;
color: #e4e4e7; color: #e4e4e7;
overflow: hidden; overflow-x: hidden;
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
@@ -47,7 +47,7 @@
#root { #root {
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
overflow: hidden; overflow-x: hidden;
} }
} }
@@ -104,7 +104,58 @@ body ::-webkit-scrollbar-thumb:hover,
} }
.focus-ring-spacing:focus-within { .focus-ring-spacing:focus-within {
outline: 2px solid rgba(99, 102, 241, 0.3); outline: 2px solid rgba(99, 102, 241, 0.8);
outline-offset: -2px; outline-offset: -2px;
border-radius: 8px; border-radius: 8px;
} }
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* Forced colors / high contrast mode */
@media (forced-colors: active) {
.focus-ring-spacing:focus-within {
outline: 2px solid LinkText;
}
}
/* Screen reader only utility */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
/* Native dialog styles */
dialog {
background: transparent;
border: none;
padding: 0;
max-width: 100vw;
max-height: 100vh;
overflow: visible;
}
dialog::backdrop {
background: rgba(9, 9, 11, 0.8);
backdrop-filter: blur(4px);
}
dialog[open] {
display: flex;
align-items: center;
justify-content: center;
}
+4 -1
View File
@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import App from './App'; import App from './App';
import { ErrorBoundary } from './components/ErrorBoundary';
import './index.css'; import './index.css';
const rootElement = document.getElementById('root'); const rootElement = document.getElementById('root');
@@ -11,6 +12,8 @@ if (!rootElement) {
const root = ReactDOM.createRoot(rootElement); const root = ReactDOM.createRoot(rootElement);
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<App /> <ErrorBoundary>
<App />
</ErrorBoundary>
</React.StrictMode> </React.StrictMode>
); );
+32 -7
View File
@@ -1,4 +1,5 @@
import { StyleOption, ElementStyle, FontConfig, ColorPalette } from '../types'; import { StyleOption, ElementStyle, FontConfig, ColorPalette } from '../types';
import { ensureContrast, isLargeText } from '../utils/contrastUtils';
/** /**
* Resolves a color reference (e.g., 'accent', 'background') or returns the color directly * Resolves a color reference (e.g., 'accent', 'background') or returns the color directly
@@ -41,17 +42,33 @@ function elementToCss(
if (style.font) css.push(`font-family: ${resolveFont(style.font, fonts)}`); if (style.font) css.push(`font-family: ${resolveFont(style.font, fonts)}`);
if (style.size) css.push(`font-size: ${style.size}pt`); if (style.size) css.push(`font-size: ${style.size}pt`);
if (style.color) css.push(`color: ${resolveColor(style.color, palette)}`); if (style.color) {
let resolvedColor = resolveColor(style.color, palette);
const bgColor = resolveColor('background', palette);
const sizePt = style.size || 12;
const isBold = style.bold || false;
const minRatio = isLargeText(sizePt, isBold) ? 4.5 : 7;
resolvedColor = ensureContrast(resolvedColor, bgColor, minRatio);
css.push(`color: ${resolvedColor}`);
}
if (style.background) css.push(`background: ${resolveColor(style.background, palette)}`); if (style.background) css.push(`background: ${resolveColor(style.background, palette)}`);
if (style.bold) css.push('font-weight: 700'); if (style.bold) css.push('font-weight: 700');
if (style.italic) css.push('font-style: italic'); if (style.italic) css.push('font-style: italic');
if (style.underline) css.push('text-decoration: underline'); if (style.underline) css.push('text-decoration: underline');
if (style.allCaps) css.push('text-transform: uppercase'); if (style.allCaps) css.push('text-transform: uppercase');
if (style.align) css.push(`text-align: ${style.align === 'both' ? 'justify' : style.align}`); if (style.align) {
const align = style.align === 'both' || style.align === 'justify' ? 'left' : style.align;
css.push(`text-align: ${align}`);
}
if (style.spacing) { if (style.spacing) {
if (style.spacing.before) css.push(`margin-top: ${style.spacing.before}pt`); if (style.spacing.before) css.push(`margin-top: ${style.spacing.before}pt`);
if (style.spacing.after) css.push(`margin-bottom: ${style.spacing.after}pt`); if (style.spacing.after) css.push(`margin-bottom: ${style.spacing.after}pt`);
if (style.spacing.line) css.push(`line-height: ${style.spacing.line}`); if (style.spacing.line) {
const isHeading = selector.includes(' h1') || selector.includes(' h2') || selector.includes(' h3') || selector.includes(' h4') || selector.includes(' h5') || selector.includes(' h6');
const minLineHeight = isHeading ? 1.0 : 1.5;
const lineHeight = Math.max(style.spacing.line, minLineHeight);
css.push(`line-height: ${lineHeight}`);
}
} }
if (style.indent) css.push(`text-indent: ${style.indent}pt`); if (style.indent) css.push(`text-indent: ${style.indent}pt`);
if (style.padding) css.push(`padding: ${style.padding}pt`); if (style.padding) css.push(`padding: ${style.padding}pt`);
@@ -162,9 +179,13 @@ function generateCssFromWordConfig(template: StyleOption): string {
if (cfg.body?.font) pageStyles.push(`font-family: ${cfg.body.font}`); if (cfg.body?.font) pageStyles.push(`font-family: ${cfg.body.font}`);
if (cfg.body?.size) pageStyles.push(`font-size: ${cfg.body.size}pt`); if (cfg.body?.size) pageStyles.push(`font-size: ${cfg.body.size}pt`);
if (cfg.body?.color) pageStyles.push(`color: #${cfg.body.color}`); if (cfg.body?.color) pageStyles.push(`color: #${cfg.body.color}`);
if (cfg.body?.align) pageStyles.push(`text-align: ${cfg.body.align === 'both' ? 'justify' : cfg.body.align}`); if (cfg.body?.align) {
const align = cfg.body.align === 'both' || cfg.body.align === 'justify' ? 'left' : cfg.body.align;
pageStyles.push(`text-align: ${align}`);
}
if (cfg.body?.spacing?.line) { if (cfg.body?.spacing?.line) {
pageStyles.push(`line-height: ${cfg.body.spacing.line / 240}`); const lineHeight = Math.max(cfg.body.spacing.line / 240, 1.5);
pageStyles.push(`line-height: ${lineHeight}`);
} }
// White background for the document page // White background for the document page
pageStyles.push('background: #ffffff'); pageStyles.push('background: #ffffff');
@@ -213,9 +234,13 @@ function generateCssFromWordConfig(template: StyleOption): string {
if (cfg.body.font) pStyles.push(`font-family: ${cfg.body.font}`); if (cfg.body.font) pStyles.push(`font-family: ${cfg.body.font}`);
if (cfg.body.size) pStyles.push(`font-size: ${cfg.body.size}pt`); if (cfg.body.size) pStyles.push(`font-size: ${cfg.body.size}pt`);
if (cfg.body.color) pStyles.push(`color: #${cfg.body.color}`); if (cfg.body.color) pStyles.push(`color: #${cfg.body.color}`);
if (cfg.body.align) pStyles.push(`text-align: ${cfg.body.align === 'both' ? 'justify' : cfg.body.align}`); if (cfg.body.align) {
const align = cfg.body.align === 'both' || cfg.body.align === 'justify' ? 'left' : cfg.body.align;
pStyles.push(`text-align: ${align}`);
}
if (cfg.body.spacing?.line) { if (cfg.body.spacing?.line) {
pStyles.push(`line-height: ${cfg.body.spacing.line / 240}`); const lineHeight = Math.max(cfg.body.spacing.line / 240, 1.5);
pStyles.push(`line-height: ${lineHeight}`);
} }
if (cfg.body.spacing?.after) { if (cfg.body.spacing?.after) {
pStyles.push(`margin-bottom: ${cfg.body.spacing.after / 20}pt`); pStyles.push(`margin-bottom: ${cfg.body.spacing.after / 20}pt`);
+71
View File
@@ -0,0 +1,71 @@
/**
* WCAG 2.2 contrast ratio utilities for runtime color validation
*/
export function hexToRgb(hex: string): [number, number, number] {
const clean = hex.replace('#', '');
const full = clean.length === 3
? clean.split('').map(c => c + c).join('')
: clean;
const num = parseInt(full, 16);
return [(num >> 16) & 255, (num >> 8) & 255, num & 255];
}
export function relativeLuminance([r, g, b]: [number, number, number]): number {
const [rs, gs, bs] = [r, g, b].map(c => {
const s = c / 255;
return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
});
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}
export function contrastRatio(color1: string, color2: string): number {
const l1 = relativeLuminance(hexToRgb(color1));
const l2 = relativeLuminance(hexToRgb(color2));
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
/**
* Adjusts foreground color to meet minimum contrast ratio against background.
* Lightens or darkens the foreground as needed.
*/
export function ensureContrast(fg: string, bg: string, minRatio: number): string {
if (contrastRatio(fg, bg) >= minRatio) return fg;
const bgLum = relativeLuminance(hexToRgb(bg));
const [r, g, b] = hexToRgb(fg);
// Determine direction: lighten if bg is dark, darken if bg is light
const lighten = bgLum < 0.5;
for (let step = 1; step <= 50; step++) {
const factor = step * 0.02;
let nr: number, ng: number, nb: number;
if (lighten) {
nr = Math.min(255, Math.round(r + (255 - r) * factor));
ng = Math.min(255, Math.round(g + (255 - g) * factor));
nb = Math.min(255, Math.round(b + (255 - b) * factor));
} else {
nr = Math.max(0, Math.round(r * (1 - factor)));
ng = Math.max(0, Math.round(g * (1 - factor)));
nb = Math.max(0, Math.round(b * (1 - factor)));
}
const adjusted = '#' + [nr, ng, nb].map(c => c.toString(16).padStart(2, '0')).join('');
if (contrastRatio(adjusted, bg) >= minRatio) return adjusted;
}
// Fallback: return white or black
return lighten ? '#FFFFFF' : '#000000';
}
/**
* Determines if text at given size/weight qualifies as "large text" per WCAG.
* Large text = 18pt+ (24px+) or 14pt+ bold (18.66px+)
*/
export function isLargeText(sizePt: number, bold: boolean): boolean {
return sizePt >= 18 || (bold && sizePt >= 14);
}
+29 -1
View File
@@ -48,6 +48,7 @@ export interface ConversionOptions {
id?: string; id?: string;
page?: any; page?: any;
useTableHeaders?: boolean; useTableHeaders?: boolean;
inputFileName?: string;
} }
// Transform text to all caps if needed // Transform text to all caps if needed
@@ -622,6 +623,12 @@ export const generateDocxDocument = async (
// Create paragraph for table cell // Create paragraph for table cell
const paragraph = new Paragraph({ const paragraph = new Paragraph({
children: runs.length > 0 ? runs : [new TextRun({ text: headerEl.textContent || '', font: headerFont, size: headerSize, color: headerColor, bold: true })], children: runs.length > 0 ? runs : [new TextRun({ text: headerEl.textContent || '', font: headerFont, size: headerSize, color: headerColor, bold: true })],
heading: level === 1 ? HeadingLevel.HEADING_1 :
level === 2 ? HeadingLevel.HEADING_2 :
level === 3 ? HeadingLevel.HEADING_3 :
level === 4 ? HeadingLevel.HEADING_4 :
level === 5 ? HeadingLevel.HEADING_5 :
HeadingLevel.HEADING_6,
alignment: mapAlignment(cfg.align), alignment: mapAlignment(cfg.align),
spacing: { spacing: {
before: 0, before: 0,
@@ -777,7 +784,8 @@ export const generateDocxDocument = async (
})); }));
} }
rows.push(new TableRow({ children: cells })); const hasThCells = Array.from(rowEl.querySelectorAll('th')).length > 0;
rows.push(new TableRow({ children: cells, tableHeader: hasThCells }));
} }
return new Table({ return new Table({
@@ -1195,6 +1203,23 @@ export const generateDocxDocument = async (
return results; return results;
} }
// Images - produce accessible placeholder text
if (tag === 'img') {
const alt = el.getAttribute('alt') || '';
const placeholderText = alt ? `[Image: ${alt}]` : '[Image]';
results.push(new Paragraph({
children: [new TextRun({
text: placeholderText,
font: body.font,
size: pt(body.size),
color: formatColor(resolveColorToHex(body.color) || '666666'),
italics: true,
})],
spacing: { before: 120, after: 120 },
}));
return results;
}
// HR // HR
if (tag === 'hr') { if (tag === 'hr') {
const hrConfig = elements?.hr; const hrConfig = elements?.hr;
@@ -1229,6 +1254,9 @@ export const generateDocxDocument = async (
// Build document options // Build document options
const documentOptions: any = { const documentOptions: any = {
title: options.inputFileName || 'Document',
description: 'Generated by TypoGenie',
creator: 'TypoGenie',
styles: { styles: {
default: { default: {
document: { document: {