25 Commits

Author SHA1 Message Date
lashman 8b55855c81 Update README.md 2026-03-09 15:17:38 +02:00
TypoGenie 63cfed02d9 docs: add WCAG 2.2 AAA accessibility section to README 2026-02-19 00:12:20 +02:00
TypoGenie 87dd578e92 a11y: add error boundary, reduced motion, remaining fixes 2026-02-18 23:52:06 +02:00
TypoGenie 2ca1439680 a11y: add runtime contrast validation, justify override, line-height floor 2026-02-18 23:49:03 +02:00
TypoGenie bd842b87a2 a11y: add DOCX metadata, image placeholders, table headers 2026-02-18 23:46:32 +02:00
TypoGenie 1458210d88 a11y: add landmarks, semantic structure, skip nav, iframe lang 2026-02-18 23:43:48 +02:00
TypoGenie e460f4df68 a11y: add ARIA live regions for status announcements and errors 2026-02-18 23:35:52 +02:00
TypoGenie d0f88625b5 a11y: fix keyboard access, listbox pattern, ARIA labels, target sizes 2026-02-18 23:32:21 +02:00
TypoGenie 242e16f75d a11y: convert all modals to native dialog with focus management 2026-02-18 23:27:55 +02:00
TypoGenie 7d5af9e39c a11y: fix CSS foundation - contrast, overflow, focus ring, reduced motion 2026-02-18 23:22:04 +02:00
TypoGenie 3b8e80c3a3 docs: add WCAG 2.2 AAA implementation plan 2026-02-18 23:17:00 +02:00
TypoGenie 3ab4432fcf docs: add WCAG 2.2 AAA accessibility remediation design
Covers 147 issues across 8 sections: CSS foundation, modal system,
keyboard access, ARIA live regions, semantic structure, DOCX output,
template contrast validation, and miscellaneous fixes.
2026-02-18 23:11:05 +02:00
TypoGenie 32c9ab9e92 docs: standardize style count to 165+ across README 2026-02-09 23:00:24 +02:00
TypoGenie 592ded54fe chore: normalize dashes across project files 2026-02-09 22:59:03 +02:00
TypoGenie f34aa9e9a8 chore: switch license from MIT to CC0-1.0 2026-02-09 22:55:59 +02:00
lashman a1f3a86022 Update README.md 2026-02-09 18:10:57 +00:00
TypoGenie 285b9e835d docs: release 1.0.0
- Updated README to reflect Windows Portable focus
- Removed references to Mac/Linux builds
- Updated tech stack and project structure documentation
- Updated Contributing section
2026-02-01 19:17:10 +02:00
TypoGenie 3265ea899b fix: resolve lingering TypeScript errors found during cleanup
- Fixed unused import in useTemplates.ts
- Fixed argument mismatch in templateLoader.ts
2026-02-01 19:05:00 +02:00
TypoGenie 4a8ca94348 chore: cleanup unused files and scripts
- Removed legacy TypeScript templates (replaced by JSON)
- Removed unused PowerShell and CJS maintenance scripts
- Removed debug logs and unused components
- Files moved to local _TRASH/ directory (excluded from git)
2026-02-01 19:03:37 +02:00
TypoGenie 30cff63353 chore: sanitize template names and descriptions
- Renamed 'Artificial Intelligence' to 'Neural Network'
- Renamed 'Blockchain Protocol' to 'Secure Protocol'
- Renamed 'Crypto Futurism' to 'Digital Futurism'
- Renamed 'Startup SaaS' to 'Product Launch'
- Renamed 'Tech Startup' to 'Tech Venture'
- Removed references to AI, crypto, blockchain, and startups from template descriptions
- Updated README.md to remove specific startup/SaaS references
2026-02-01 18:57:57 +02:00
TypoGenie 60f39ed961 feat: port all template categories to JSON format
- Ported Minimalist templates to JSON (Swiss Grid, Brutalist, etc.)
- Ported Tech templates to JSON (SaaS, Terminal, Cyberpunk, etc.)
- Ported Creative templates to JSON (Art Gallery, Zine, Pop Art, etc.)
- Ported Industrial templates to JSON (Blueprint, Factory, Schematic, etc.)
- Ported Nature templates to JSON (Botanical, Ocean, Mountain, etc.)
- Ported Lifestyle templates to JSON (Cookbook, Travel, Coffee House, etc.)
- Ported Vintage templates to JSON (Art Deco, Medieval, Retro 80s, etc.)
- Updated README.md to reflect the new JSON-based style system (example configuration and contribution workflow)
- Completed migration of over 150 styles to the new architecture
2026-02-01 18:51:43 +02:00
TypoGenie a2631ac473 feat: Make app fully portable
- Add tauri-plugin-store for portable data storage
- Implement portable data directory (TypoGenie-Data/ next to EXE)
- Configure Rust backend to use EXE-relative paths
- Add store permissions for persistent settings
- Update README with portable badges and documentation
- Document how to build and use the portable EXE
- Zero registry, zero AppData, fully self-contained
2026-01-29 18:36:48 +02:00
TypoGenie 2c22b0fce6 feat: Transform to Tauri desktop app
- Initialize Tauri v2 project with Rust backend
- Restructure project: move source files to src/ directory
- Add Tauri configuration for Windows, macOS, and Linux builds
- Update Vite config for Tauri development workflow
- Add file system and dialog permissions for native features
- Update package.json with desktop build scripts
- Update tsconfig.json paths for new src structure
- Add Tauri and desktop badges to README
- Document desktop build process and architecture
2026-01-29 18:28:35 +02:00
TypoGenie ae5ce9d243 docs: Rewrite README with comprehensive documentation
- Added extensive project documentation covering all features
- Beautiful visual design with badges, shields, and banners
- Included architecture diagrams and style system deep-dive
- Added contribution guidelines and philosophy section
- Wove in themes of collective benefit, commons, and mutual aid throughout
- Celebrated open-source as a model of voluntary cooperation
2026-01-29 18:14:41 +02:00
TypoGenie f179e79f35 Initial commit: TypoGenie - Markdown to Word document converter 2026-01-29 18:10:07 +02:00
23 changed files with 2487 additions and 398 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/>
+164 -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/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/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" />
</p>
@@ -31,7 +31,8 @@
</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/community-owned-ff69b4?style=flat-square" alt="Community" />
</p>
@@ -41,6 +42,7 @@
<!-- Quick Links -->
<p>
<a href="#-features">✨ Features</a> •
<a href="#-accessibility">♿ Accessibility</a> •
<a href="#-demo">🎨 Demo</a> •
<a href="#-quick-start">🚀 Quick Start</a> •
<a href="#-documentation">📚 Docs</a> •
@@ -55,17 +57,17 @@
## 📖 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/>
<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>
@@ -77,16 +79,17 @@ In a world where document formatting tools are increasingly locked behind paywal
### 🎯 Core Capabilities
- **📄 Universal Markdown Support** Drop in any `.md`, `.txt`, or `.markdown` file
- **🎨 150+ Typography Styles** Curated across 8 aesthetic categories
- **📐 Multiple Paper Sizes** A4 and Letter formats supported
- **💾 Local Processing** Your documents never leave your machine
- **🖥️ Native Desktop App** Built with Tauri for Windows (Portable EXE)
- **📥 One-Click Export** Clean `.docx` files ready for Microsoft Word
- **📄 Universal Markdown Support** - Drop in any `.md`, `.txt`, or `.markdown` file
- **🎨 165+ Typography Styles** - Curated across 8 aesthetic categories
- **📐 Multiple Paper Sizes** - A4 and Letter formats supported
- **💾 Local Processing** - Your documents never leave your machine
- **🖥️ Native Desktop App** - Built with Tauri for Windows (Portable EXE)
- **📥 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
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 |
|----------|-------------|----------------|
@@ -103,13 +106,82 @@ Each of the 163+ styles is a labor of love, crafted with attention to typographi
Every style includes meticulously configured:
-**Google Fonts Integration** Beautiful web fonts that translate to Word
-**Hierarchical Headings** H1-H6 with distinct visual weight
-**Body Text Optimization** Optimal line height, spacing, and readability
-**List Formatting** Bulleted and numbered lists that work
-**Blockquote Styling** Pull quotes with accent colors
-**Table Support** Data tables with proper borders and alignment
-**Bold & Italic** Emphasis that carries through to the final document
-**Google Fonts Integration** - Beautiful web fonts that translate to Word
-**Hierarchical Headings** - H1-H6 with distinct visual weight
-**Body Text Optimization** - Optimal line height, spacing, and readability
-**List Formatting** - Bulleted and numbered lists that work
-**Blockquote Styling** - Pull quotes with accent colors
-**Table Support** - Data tables with proper borders and alignment
-**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/>
@@ -153,10 +225,10 @@ Every style includes meticulously configured:
### 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:
- 🪟 **Windows**: `TypoGenie.exe` Single executable, runs immediately
- 🪟 **Windows**: `TypoGenie.exe` - Single executable, runs immediately
**How it works:**
```
@@ -166,11 +238,11 @@ Just download and run:
└── 📂 TypoGenie-Data/ ← Auto-created on first run (settings/cache)
```
**No installer** Just double-click the EXE
**No registry** Windows registry untouched
**No AppData** Everything stays in the same folder
**USB-friendly** Run from a thumb drive anywhere
**Easy backup** Copy the whole folder, done
**No installer** - Just double-click the EXE
**No registry** - Windows registry untouched
**No AppData** - Everything stays in the same folder
**USB-friendly** - Run from a thumb drive anywhere
**Easy backup** - Copy the whole folder, done
### Option 2: Build from Source
@@ -196,7 +268,7 @@ npm install
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
@@ -212,7 +284,7 @@ npm run desktop:build
| 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:**
1. Build the release (`npm run tauri build`).
@@ -222,11 +294,11 @@ npm run desktop:build
### Usage
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
3. **📐 Choose Paper Size** A4 or Letter, depending on your needs
4. **✨ Generate** Watch the magic happen (with a satisfying loading animation)
5. **📥 Download** Grab your `.docx` file, ready for Word
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
3. **📐 Choose Paper Size** - A4 or Letter, depending on your needs
4. **✨ Generate** - Watch the magic happen (with a satisfying loading animation)
5. **📥 Download** - Grab your `.docx` file, ready for Word
<br/>
@@ -238,26 +310,26 @@ npm run desktop:build
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ TYPOGENIE ARCHITECTURE
│ TYPOGENIE ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ UPLOAD │───→│ CONFIG │───→│ PREVIEW │ │
│ │ (File) │ │ (Style) │ │ (Export) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ STATE MACHINE │ │
│ │ • UPLOAD → CONFIG → GENERATING → PREVIEW → (reset) │ │
│ └────────────────────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────────────────────┐
│ │ STATE MACHINE │
│ │ • UPLOAD → CONFIG → GENERATING → PREVIEW → (reset) │
│ └────────────────────────────────────────────────────────────┘
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ PROCESSING PIPELINE │ │
│ │ Markdown → marked parser → HTML → docx library → .docx │ │
│ └────────────────────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────────────────────┐
│ │ PROCESSING PIPELINE │
│ │ 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:**
TypoGenie maintains two parallel rendering paths:
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.
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.
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`).
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
@@ -343,13 +415,21 @@ npm run tauri # Access Tauri CLI directly
typogenie/
├── 📁 src/ # Frontend source code (React)
│ ├── 📁 components/ # UI components
│ │ ├── FileUpload.tsx # Drag-and-drop upload zone
│ │ ├── StyleSelector.tsx # Style gallery
│ │ ── Preview.tsx # Final preview & export
│ ├── 📁 services/ # Logic
│ │ ├── templateLoader.ts # Loads JSON templates via Rust
│ │ └── templateRenderer.ts # Generates CSS/DOCX from JSON
│ │ ├── ErrorBoundary.tsx # Accessible crash recovery screen
│ │ ├── ExportOptionsModal.tsx # DOCX export options (native dialog)
│ │ ── FileUpload.tsx # Drag-and-drop upload zone
│ ├── Preview.tsx # Final preview & export
│ │ ├── StylePreviewModal.tsx # Full-page style preview (native dialog)
│ │ └── StyleSelector.tsx # Style gallery with listbox pattern
│ ├── 📁 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
├── 📁 src-tauri/ # Tauri desktop app
│ ├── 📁 src/ # Rust backend code (filesystem access)
@@ -369,7 +449,8 @@ typogenie/
| **Framework** | React 19.2.4 | UI components & state management |
| **Language** | TypeScript 5.8.2 | Type safety & developer experience |
| **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 |
| **Markdown** | marked 12.0.0 | Local Markdown parsing |
| **Documents** | docx 8.5.0 | Client-side DOCX generation |
@@ -382,23 +463,23 @@ typogenie/
## 🤝 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
- 🎨 **New Styles** Design and share new typography styles
- 🐛 **Bug Reports** Found something broken? Let us know!
- 💡 **Feature Ideas** Have a suggestion? Open a discussion
- 📖 **Documentation** Help us explain things better
- 🔧 **Code Improvements** Refactors, optimizations, fixes
- 🌍 **Translations** Help make TypoGenie accessible globally
- 🎨 **New Styles** - Design and share new typography styles
- 🐛 **Bug Reports** - Found something broken? Let us know!
- 💡 **Feature Ideas** - Have a suggestion? Open a discussion
- 📖 **Documentation** - Help us explain things better
- 🔧 **Code Improvements** - Refactors, optimizations, fixes
- 🌍 **Translations** - Help make TypoGenie accessible globally
### Contribution Guidelines
1. **Fork the repository**
2. **Create a feature branch**: `git checkout -b feature/amazing-thing`
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
### 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.**
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
```
MIT License
[![CC0](https://licensebuttons.net/p/zero/1.0/88x31.png)](https://creativecommons.org/publicdomain/zero/1.0/)
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
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!
**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.
<br/>
@@ -456,17 +517,17 @@ SOFTWARE.
## 🙏 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 marked contributors** For battle-tested Markdown parsing
- **The docx maintainers** For making client-side Word generation possible
- **Google Fonts** For democratizing access to beautiful typography
- **Tailwind CSS** For the utility-first approach that speeds development
- **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 React Team** - For the component model that powers our UI
- **The marked contributors** - For battle-tested Markdown parsing
- **The docx maintainers** - For making client-side Word generation possible
- **Google Fonts** - For democratizing access to beautiful typography
- **Tailwind CSS** - For the utility-first approach that speeds development
- **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
**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/>
@@ -488,10 +549,10 @@ TypoGenie stands on the shoulders of giants — the countless open-source contri
If TypoGenie brings value to your life, consider:
-**Starring the repository** Helps others discover it
- 🗣️ **Spreading the word** Tell friends and colleagues
- 🔧 **Contributing code** Improve it for everyone
- 💝 **Supporting open-source** Donate to the projects TypoGenie depends on
-**Starring the repository** - Helps others discover it
- 🗣️ **Spreading the word** - Tell friends and colleagues
- 🔧 **Contributing code** - Improve it for everyone
- 💝 **Supporting open-source** - Donate to the projects TypoGenie depends on
<br/>
@@ -499,6 +560,8 @@ If TypoGenie brings value to your life, consider:
<p>
<i>"The master's tools will never dismantle the master's house. But they might help you format a really nice document while we build something better together."</i>
This project is developed with the help of a locally-run LLM via opencode for scaffolding, planning, and routine code tasks. Architecture and design decisions are my own.
</p>
<p>
Binary file not shown.
@@ -0,0 +1,212 @@
# WCAG 2.2 AAA Accessibility Remediation Design
**Date:** 2026-02-18
**Status:** Approved
**Scope:** 147 accessibility issues across 17 existing files, 3 new files
## Decisions
| Decision | Choice |
|----------|--------|
| Visual approach | Balance accessibility with dark aesthetic |
| Modals | Native `<dialog>` element |
| Style cards | Listbox pattern with existing `useFocusableList` hook |
| Template colors | Runtime contrast validation only, no JSON template edits |
| Justified text | Override to `left` at render time |
| Contrast enforcement | Auto-correct via `ensureContrast()` utility at render time |
## Section 1: CSS Foundation (~25 issues)
### Root font size
Change `:root { font-size: 16px }` to `font-size: 100%` in `index.css` so user browser preferences are respected.
### Overflow
Remove `overflow: hidden` from `body` and `#root`. Replace with `overflow-x: hidden` to prevent horizontal scroll but allow vertical content access when zoomed to 200%.
### Focus indicators
Replace `rgba(99, 102, 241, 0.3)` focus ring with `rgba(99, 102, 241, 0.8)` at 2px. Meets 3:1 contrast on dark backgrounds while fitting indigo theme.
### Reduced motion
Add global CSS rule:
```css
@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
Add `@media (forced-colors: active)` rules for Windows High Contrast Mode compatibility.
### Color replacements
Across all components: `text-zinc-600` -> `text-zinc-400`, `text-zinc-500` -> `text-zinc-400`. All text hits 7:1+ contrast on zinc-950. Keeps dark aesthetic.
## Section 2: Modal System (~20 issues)
### New `src/hooks/useDialog.ts`
Reusable hook wrapping native `<dialog>`:
- Manages `dialogRef`, `showModal()`/`close()`
- Restores focus to trigger element on close
- Wires `aria-labelledby`
- Framer Motion animations on inner content div
### KeyboardShortcutsHelp (App.tsx)
Convert from `motion.div` overlay to `<dialog>`. Add `aria-labelledby` to heading. Close button gets `aria-label="Close shortcuts"`. Escape/focus trap handled natively.
### ExportOptionsModal
Same `<dialog>` conversion. Radio buttons wrapped in `<fieldset><legend>`. Close button labeled.
### StylePreviewModal
Same `<dialog>` conversion. Close button labeled. Iframe HTML gets `lang="en"`.
### Animation approach
`dialog[open]` CSS selector for entrance. `::backdrop` gets fade-in. Framer Motion on inner content for exit.
## Section 3: Keyboard Access & Interactive Elements (~20 issues)
### Logo button
Replace `<motion.div onClick>` with `<motion.button aria-label="TypoGenie - Reset to home">`.
### Single-character shortcut
Scope `?`/`/` listener to only fire when `document.activeElement` is body or non-input element. Prevents intercepting typing in search.
### Style cards as listbox
Container: `role="listbox"` + `aria-label="Typography styles"`. Each card: `role="option"` + `tabIndex` + `aria-selected` + `onKeyDown`. Wire `useFocusableList` for arrow keys. Add `:focus-visible` indigo ring.
### Favorite button
Add `aria-label` (dynamic: "Add to favorites"/"Remove from favorites") + `aria-pressed`.
### Category filters
Wrap in `<div role="group" aria-label="Filter by category">`. Active button: `aria-pressed="true"`.
### Paper size buttons
Wrap in `<div role="group" aria-label="Paper size">`. Active button: `aria-pressed="true"`.
### Search input
Add `aria-label="Search templates"` + `aria-describedby` pointing to live result count region.
### Icon-only buttons
Add `aria-label` to all: zoom buttons, external link (Preview), close buttons. Decorative icons next to text: `aria-hidden="true"`.
### Target sizes
Increase padding on small buttons to hit 44x44px minimum. Use `min-w-[44px] min-h-[44px]` with centered content where inline growth not possible.
## Section 4: ARIA Live Regions & Status Announcements (~15 issues)
### Root status region
Persistent `<div role="status" aria-live="polite" className="sr-only">` in App.tsx root. Updated on `appState` changes.
### Generating state
Add `aria-busy="true"` + `role="status"` to container.
### Preview loading
Wrap "Loading..." in `role="status"`.
### Success message
`aria-live="polite"` on button text area for "Saved!" announcement.
### Search results
Visually-hidden `role="status"` announcing "{N} templates found". Debounced 300ms.
### Error fixes
- Remove `aria-live="polite"` where `role="alert"` already set (implies assertive)
- Remove 5-second auto-dismiss from FileUpload error
- Add `aria-describedby` linking FileUpload error to dropzone
- Template loading: `role="status"`. Template error: `role="alert"`
### Loading state
Replace `return null` with minimal `<div role="status">Loading TypoGenie</div>`.
## Section 5: Semantic Structure & Landmarks (~15 issues)
### Skip navigation
`<a href="#main-content" class="sr-only focus:not-sr-only">Skip to main content</a>` in index.html.
### Page title
`<title>TypoGenie - Markdown to Word Converter</title>`
### Noscript
`<noscript><p>TypoGenie requires JavaScript to run.</p></noscript>`
### Landmarks
- `id="main-content"` on `<main>` in both app states
- `<nav aria-label="Style filters">` around category buttons
- `<section aria-label="Style list">` and `<section aria-label="Style preview">` in StyleSelector
### Progress stepper
Wrap in `<nav aria-label="Progress"><ol>`. Active step: `aria-current="step"`.
### Shortcuts list
Convert to `<dl>` with `<dt>`/`<dd>`.
### Decorative elements
`aria-hidden="true"` on blob backgrounds, window-chrome dots, visual dividers.
### Iframe lang
Both Preview.tsx and StylePreviewModal.tsx: inject `<html lang="en">`.
## Section 6: DOCX Output Accessibility (~8 issues)
### Document metadata
Add `title` (from filename/first h1), `description`, `language` to Document constructor.
### Image handling
New `<img>` handler producing `[Image: {alt text}]` placeholder paragraph in italic.
### Table headers
Set `tableHeader: true` on `TableRow` containing `<th>` elements.
### Heading preservation in table mode
Add `HeadingLevel` to paragraph inside `processHeaderAsTable` table cells.
### Code block style
Add custom "Code" paragraph style to DOCX styles. Apply to code blocks.
## Section 7: Template Contrast Validation (~20 issues)
### New `src/utils/contrastUtils.ts`
- `hexToRgb(hex)` - parse hex to RGB
- `relativeLuminance(rgb)` - WCAG luminance formula
- `contrastRatio(color1, color2)` - ratio calculation
- `ensureContrast(fg, bg, minRatio)` - auto-correct foreground color until ratio met
### Integration in templateRenderer.ts
Run `ensureContrast()` on every text/background pair at CSS generation time. Body text: 7:1. Large text (18pt+ or 14pt+ bold): 4.5:1.
### `del` element fix
When resolving `del` color, if `border` palette color fails contrast, fall back to `textSecondary`.
### Justified text
Override `justify` to `left` at render time (1.4.8 AAA prohibits justified text).
### Line-height floor
Enforce minimum 1.5 for body text, 1.0 for headings. Clamp upward if template specifies lower.
## Section 8: Remaining Miscellaneous (~10 issues)
### Iframe keyboard
`tabIndex={-1}` on preview iframe in StyleSelector (passive preview, not interactive).
### Error boundary
New `src/components/ErrorBoundary.tsx` wrapping `<App />`. Renders accessible error with `role="alert"` on crash.
### Framer Motion reduced motion
Import `useReducedMotion` from `motion/react` in App.tsx. When true, disable infinite blob/gradient animations via conditional animate props.
### Hidden buttons on small screens
Add always-visible `<kbd>?</kbd>` hint regardless of breakpoint.
### Truncation
Add `title` attribute with full description on `line-clamp-2` elements.
## Files Modified (17)
`index.html`, `src/index.css`, `src/App.tsx`, `src/main.tsx`, `src/components/StyleSelector.tsx`, `src/components/Preview.tsx`, `src/components/ExportOptionsModal.tsx`, `src/components/StylePreviewModal.tsx`, `src/components/FileUpload.tsx`, `src/hooks/useSettings.ts`, `src/hooks/useTemplates.ts`, `src/services/templateRenderer.ts`, `src/utils/docxConverter.ts`, `src/utils/fontUtils.ts`
## Files Created (3)
`src/utils/contrastUtils.ts`, `src/hooks/useDialog.ts`, `src/components/ErrorBoundary.tsx`
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<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.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">
@@ -18,6 +18,8 @@
</style>
</head>
<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>
<script type="module" src="/src/main.tsx"></script>
</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"
description = "TypoGenie - Portable Markdown to Word document converter"
authors = ["TypoGenie Contributors"]
license = "MIT"
license = "CC0-1.0"
repository = "https://git.lashman.live/lashman/typogenie"
edition = "2021"
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.",
"publisher": "TypoGenie Contributors",
"copyright": "Copyright (c) 2026 TypoGenie Contributors",
"license": "MIT",
"license": "CC0-1.0",
"homepage": "https://git.lashman.live/lashman/typogenie",
"resources": {
"templates": "templates"
+88 -60
View File
@@ -1,5 +1,5 @@
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 { FileUpload } from './components/FileUpload';
import { StyleSelector } from './components/StyleSelector';
@@ -7,6 +7,7 @@ import { Preview } from './components/Preview';
import { ZoomControl } from './components/ZoomControl';
import { useSettings } from './hooks/useSettings';
import { useTemplates } from './hooks/useTemplates';
import { useDialog } from './hooks/useDialog';
// @ts-ignore
import { parse } from 'marked';
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';
// 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 = [
{ key: '↑ / ↓', description: 'Navigate styles' },
{ key: '← / →', description: 'Navigate categories (when focused)' },
@@ -26,60 +28,61 @@ const KeyboardShortcutsHelp: React.FC<{ onClose: () => void }> = ({ onClose }) =
{ key: 'Escape', description: 'Go back / Close' },
];
if (!isOpen) return null;
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onClick={onClose}
<dialog
ref={dialogRef}
onClick={handleBackdropClick}
aria-labelledby="shortcuts-title"
className="fixed inset-0 z-50 p-4"
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
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"
onClick={e => e.stopPropagation()}
>
<div className="flex justify-between items-center mb-6">
<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} />
</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>
<button
onClick={onClose}
className="p-1 hover:bg-zinc-800 rounded-lg text-zinc-400 hover:text-white transition-colors"
onClick={close}
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} />
</button>
</div>
<div className="space-y-3">
{shortcuts.map((shortcut, index) => (
<motion.div
<dl className="space-y-3">
{shortcuts.map((shortcut) => (
<div
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"
>
<span className="text-zinc-400">{shortcut.description}</span>
<kbd className="px-2 py-1 bg-zinc-800 rounded text-sm font-mono text-zinc-300 border border-zinc-700">
{shortcut.key}
</kbd>
</motion.div>
<dt className="text-zinc-400">{shortcut.description}</dt>
<dd className="ml-4">
<kbd className="px-2 py-1 bg-zinc-800 rounded text-sm font-mono text-zinc-300 border border-zinc-700">
{shortcut.key}
</kbd>
</dd>
</div>
))}
</div>
<p className="mt-6 text-xs text-zinc-500 text-center">
</dl>
<p className="mt-6 text-xs text-zinc-400 text-center">
Press Escape or click outside to close
</p>
</motion.div>
</motion.div>
</dialog>
);
};
const App: React.FC = () => {
const prefersReducedMotion = useReducedMotion();
const [appState, setAppState] = useState<AppState>(AppState.UPLOAD);
const [content, setContent] = useState<string>('');
const [inputFileName, setInputFileName] = useState<string>('');
@@ -88,6 +91,7 @@ const App: React.FC = () => {
const [generatedHtml, setGeneratedHtml] = useState<string>('');
const [error, setError] = useState<string | null>(null);
const [showShortcuts, setShowShortcuts] = useState(false);
const [statusMessage, setStatusMessage] = useState('');
const { uiZoom, setUiZoom, isLoaded } = useSettings();
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
// 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
useEffect(() => {
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 === '/') {
e.preventDefault();
setShowShortcuts(prev => !prev);
@@ -162,7 +181,11 @@ const App: React.FC = () => {
};
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) {
@@ -171,7 +194,8 @@ const App: React.FC = () => {
className="h-screen w-screen overflow-hidden"
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
htmlContent={generatedHtml}
onBack={handleBackToConfig}
@@ -182,7 +206,7 @@ const App: React.FC = () => {
onZoomChange={setUiZoom}
templates={templates}
/>
</div>
</main>
</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"
style={{ fontSize: `${uiZoom}%` }}
>
<div role="status" aria-live="polite" className="sr-only">{statusMessage}</div>
{/* Keyboard Shortcuts Modal */}
<AnimatePresence>
{showShortcuts && (
<KeyboardShortcutsHelp onClose={() => setShowShortcuts(false)} />
)}
</AnimatePresence>
<KeyboardShortcutsHelp isOpen={showShortcuts} onClose={() => setShowShortcuts(false)} />
{/* Header - Fixed height */}
<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"
>
<div className="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between">
<motion.div
className="flex items-center gap-2 cursor-pointer"
<motion.button
className="flex items-center gap-2 cursor-pointer bg-transparent border-none"
onClick={handleReset}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
aria-label="TypoGenie - Reset to home"
>
<motion.div
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 } }}
aria-hidden="true"
>
<FileType className="text-white" size={20} />
</motion.div>
<h1 className="text-xl font-bold tracking-tight text-white">TypoGenie</h1>
</motion.div>
</motion.button>
<div className="flex items-center gap-4">
{/* UI Zoom Control */}
@@ -233,10 +256,10 @@ const App: React.FC = () => {
onClick={refresh}
whileHover={{ scale: 1.05 }}
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"
>
<RefreshCw size={14} />
<RefreshCw size={14} aria-hidden="true" />
<span>Refresh</span>
</motion.button>
)}
@@ -246,28 +269,32 @@ const App: React.FC = () => {
onClick={() => setShowShortcuts(true)}
whileHover={{ scale: 1.05 }}
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"
>
<Keyboard size={14} />
<Keyboard size={14} aria-hidden="true" />
<span>Shortcuts</span>
<kbd className="px-1.5 py-0.5 bg-zinc-800 rounded text-zinc-400 font-mono">?</kbd>
</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">
{appState !== AppState.UPLOAD && (
<motion.div
<motion.nav
aria-label="Progress"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
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>
<span>/</span>
<span className={appState === AppState.GENERATING ? "text-indigo-400 font-medium" : ""}>Generate</span>
<span>/</span>
<span>Preview</span>
</motion.div>
<ol className="flex items-center gap-4 text-sm text-zinc-400">
<li className={appState === AppState.CONFIG ? "text-indigo-400 font-medium" : ""} aria-current={appState === AppState.CONFIG ? "step" : undefined}>Configure</li>
<li aria-hidden="true">/</li>
<li className={appState === AppState.GENERATING ? "text-indigo-400 font-medium" : ""} aria-current={appState === AppState.GENERATING ? "step" : undefined}>Generate</li>
<li aria-hidden="true">/</li>
<li className={appState === AppState.PREVIEW ? "text-indigo-400 font-medium" : ""} aria-current={appState === AppState.PREVIEW ? "step" : undefined}>Preview</li>
</ol>
</motion.nav>
)}
</AnimatePresence>
</div>
@@ -275,12 +302,12 @@ const App: React.FC = () => {
</motion.header>
{/* 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 */}
<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
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],
y: [0, -20, 0],
scale: [1, 1.1, 1]
@@ -289,7 +316,7 @@ const App: React.FC = () => {
/>
<motion.div
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],
y: [0, 30, 0],
scale: [1, 1.15, 1]
@@ -324,7 +351,7 @@ const App: React.FC = () => {
Turn Markdown into <br />
<motion.span
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%"]
}}
transition={{ duration: 5, repeat: Infinity, ease: "linear" }}
@@ -376,7 +403,6 @@ const App: React.FC = () => {
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"
role="alert"
aria-live="polite"
>
{error}
</motion.div>
@@ -388,6 +414,8 @@ const App: React.FC = () => {
{appState === AppState.GENERATING && (
<motion.div
key="generating"
role="status"
aria-busy="true"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 1.1 }}
@@ -396,15 +424,15 @@ const App: React.FC = () => {
>
<motion.div
className="relative"
animate={{ rotate: 360 }}
animate={prefersReducedMotion ? {} : { rotate: 360 }}
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
>
<motion.div
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 }}
/>
<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.h3
className="mt-8 text-2xl font-bold text-white"
@@ -433,7 +461,7 @@ const App: React.FC = () => {
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
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>
</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 { useState } from 'react';
import { useDialog } from '../hooks/useDialog';
interface ExportOptionsModalProps {
isOpen: boolean;
@@ -8,6 +9,7 @@ interface ExportOptionsModalProps {
}
export default function ExportOptionsModal({ isOpen, onClose, onExport }: ExportOptionsModalProps) {
const { dialogRef, handleBackdropClick, close } = useDialog(isOpen, { onClose });
const [selectedMode, setSelectedMode] = useState<'table' | 'semantic'>('semantic');
if (!isOpen) return null;
@@ -17,15 +19,23 @@ export default function ExportOptionsModal({ isOpen, onClose, onExport }: Export
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-zinc-950/80 backdrop-blur-sm">
<div className="relative w-full max-w-2xl bg-zinc-900 rounded-2xl shadow-2xl m-4 overflow-hidden border border-zinc-700">
<dialog
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 */}
<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
onClick={onClose}
className="p-1 text-zinc-400 hover:text-white transition-colors rounded-md hover:bg-zinc-800"
aria-label="Close"
onClick={close}
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 export options"
>
<X size={20} />
</button>
@@ -37,109 +47,113 @@ export default function ExportOptionsModal({ isOpen, onClose, onExport }: Export
Choose how headers should be rendered in your Word document:
</p>
{/* Option 1: High-Fidelity */}
<label
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>
<fieldset>
<legend className="sr-only">Header rendering mode</legend>
{/* Option 2: Semantic */}
<label
className={`block p-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"></span>
<span className="text-zinc-300">Auto-generated Table of Contents</span>
{/* Option 1: High-Fidelity */}
<label
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" aria-hidden="true">&#10003;</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 className="flex items-start">
<span className="text-green-500 mr-2"></span>
<span className="text-zinc-300">Document navigation panel works</span>
<div className="mt-3 text-xs text-zinc-400 italic">
Best for: Portfolios, brochures, print-ready designs
</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>
</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>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-zinc-800 bg-zinc-900">
<button
onClick={onClose}
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"
onClick={close}
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
</button>
<button
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
</button>
</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 { Upload, FileText, AlertCircle } from 'lucide-react';
import { useKeyboardNavigation } from '../hooks/useKeyboardNavigation';
@@ -87,14 +87,6 @@ export const FileUpload: React.FC<FileUploadProps> = ({ onFileLoaded }) => {
},
}, [isFocused]);
// Clear error after 5 seconds
useEffect(() => {
if (error) {
const timer = setTimeout(() => setError(null), 5000);
return () => clearTimeout(timer);
}
}, [error]);
return (
<motion.div
className="w-full max-w-xl mx-auto"
@@ -109,6 +101,7 @@ export const FileUpload: React.FC<FileUploadProps> = ({ onFileLoaded }) => {
role="button"
tabIndex={0}
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
${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' : ''}
@@ -149,7 +142,7 @@ export const FileUpload: React.FC<FileUploadProps> = ({ onFileLoaded }) => {
transition={{ duration: 0.3 }}
>
<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 ? {
scale: [1, 1.2, 1],
rotate: [0, 10, -10, 0]
@@ -157,7 +150,7 @@ export const FileUpload: React.FC<FileUploadProps> = ({ onFileLoaded }) => {
transition={{ duration: 0.5 }}
whileHover={{ scale: 1.1, rotate: 5 }}
>
<Upload size={32} />
<Upload size={32} aria-hidden="true" />
</motion.div>
<motion.p
className="mb-2 text-lg font-medium"
@@ -174,7 +167,7 @@ export const FileUpload: React.FC<FileUploadProps> = ({ onFileLoaded }) => {
or drag and drop
</motion.p>
<motion.p
className="text-sm text-zinc-500"
className="text-sm text-zinc-400"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.7 }}
@@ -182,7 +175,7 @@ export const FileUpload: React.FC<FileUploadProps> = ({ onFileLoaded }) => {
Markdown or Plain Text files
</motion.p>
<motion.p
className="text-xs text-zinc-600 mt-2"
className="text-xs text-zinc-400 mt-2"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.8 }}
@@ -208,15 +201,15 @@ export const FileUpload: React.FC<FileUploadProps> = ({ onFileLoaded }) => {
initial={{ opacity: 0, y: -10, height: 0 }}
animate={{ opacity: 1, y: 0, height: 'auto' }}
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"
role="alert"
aria-live="polite"
>
<motion.div
animate={{ rotate: [0, 10, -10, 0] }}
transition={{ duration: 0.5 }}
>
<AlertCircle size={20} />
<AlertCircle size={20} aria-hidden="true" />
</motion.div>
<span>{error}</span>
</motion.div>
+28 -17
View File
@@ -33,20 +33,20 @@ const ZoomControl: React.FC<{ zoom: number; onZoomChange: (zoom: number) => void
onClick={decreaseZoom}
whileHover={{ scale: 1.1 }}
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"
>
<ZoomOut size={16} />
<ZoomOut size={16} aria-hidden="true" />
</motion.button>
<span className="text-xs font-medium text-zinc-300 min-w-[3rem] text-center">{zoom}%</span>
<motion.button
onClick={increaseZoom}
whileHover={{ scale: 1.1 }}
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"
>
<ZoomIn size={16} />
<ZoomIn size={16} aria-hidden="true" />
</motion.button>
</div>
);
@@ -85,7 +85,7 @@ const FontList: React.FC<{ fonts: string[] }> = ({ fonts }) => {
};
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>
<div className="flex gap-2 flex-wrap">
{fonts.map((font, index) => (
@@ -96,18 +96,20 @@ const FontList: React.FC<{ fonts: string[] }> = ({ fonts }) => {
disabled={downloadingFont === font}
whileHover={{ scale: 1.05 }}
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"
>
{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}
</motion.button>
<motion.button
onClick={() => openGoogleFonts(font)}
whileHover={{ scale: 1.05 }}
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"
>
<ExternalLink size={10} />
<ExternalLink size={10} aria-hidden="true" />
</motion.button>
</div>
{downloadStatus[font] && (
@@ -139,6 +141,7 @@ export const Preview: React.FC<PreviewProps> = ({
const [successMsg, setSuccessMsg] = useState(false);
const [showExportModal, setShowExportModal] = useState(false);
const [focusedElement, setFocusedElement] = useState<'back' | 'fonts' | 'save'>('save');
const [exportError, setExportError] = useState<string | null>(null);
// Get current style from templates
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,
id: template.id,
page: template.page,
useTableHeaders
useTableHeaders,
inputFileName
});
const arrayBuffer = await blob.arrayBuffer();
@@ -240,7 +244,7 @@ export const Preview: React.FC<PreviewProps> = ({
}
} catch (e) {
console.error("Docx Gen Error", e);
alert("Failed to generate DOCX: " + e);
setExportError("Failed to generate DOCX: " + e);
} finally {
setIsExporting(false);
}
@@ -310,7 +314,7 @@ export const Preview: React.FC<PreviewProps> = ({
// Inject CSS directly as inline style tag
const html = `
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -373,7 +377,7 @@ export const Preview: React.FC<PreviewProps> = ({
}, [htmlContent, paperSize, selectedStyleId, templates, 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 (
@@ -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] }}>
<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">
<ArrowLeft size={20} />
<ArrowLeft size={20} aria-hidden="true" />
<span>Back to Editor</span>
</motion.button>
<div className="flex flex-col sm:flex-row items-center gap-6">
<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} />
<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">
<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
ref={saveButtonRef}
onClick={handleSave}
onFocus={() => setFocusedElement('save')}
disabled={isExporting}
aria-live="polite"
whileHover={{ scale: 1.05, boxShadow: '0 0 20px rgba(59, 130, 246, 0.4)' }}
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' : ''}`}
@@ -404,12 +409,12 @@ export const Preview: React.FC<PreviewProps> = ({
<AnimatePresence mode="wait">
{successMsg ? (
<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>
</motion.div>
) : (
<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>
</motion.div>
)}
@@ -429,6 +434,12 @@ export const Preview: React.FC<PreviewProps> = ({
onClose={() => setShowExportModal(false)}
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>
);
};
+33 -36
View File
@@ -1,8 +1,10 @@
import React, { useEffect, useRef } from 'react';
import { X } from 'lucide-react';
import { StyleOption } from '../types';
import { useDialog } from '../hooks/useDialog';
interface StylePreviewModalProps {
isOpen: boolean;
style: StyleOption;
onClose: () => void;
}
@@ -10,42 +12,35 @@ interface StylePreviewModalProps {
const SAMPLE_CONTENT = `
<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>
<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>
<blockquote>
"Design is not just what it looks like and feels like. Design is how it works."
</blockquote>
<h2>2. Key Elements</h2>
<ul>
<li><strong>Typeface:</strong> The design of the letters.</li>
<li><strong>Contrast:</strong> Distinguishing elements effectively.</li>
<li><strong>Consistency:</strong> Maintaining a coherent structure.</li>
</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>
`;
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);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleEscape);
return () => window.removeEventListener('keydown', handleEscape);
}, [onClose]);
useEffect(() => {
if (!iframeRef.current) return;
if (!isOpen || !iframeRef.current) return;
const doc = iframeRef.current.contentDocument;
if (doc) {
const html = `
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com">
<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.close();
}
}, [style]);
}, [isOpen, style]);
if (!isOpen) return null;
return (
<div
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"
onClick={(e) => {
// Close if clicking the background overlay directly
if (e.target === e.currentTarget) {
onClose();
}
}}
<dialog
ref={dialogRef}
onClick={handleBackdropClick}
aria-labelledby="preview-title"
className="fixed inset-0 z-[100] p-4"
>
<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 */}
<div className="flex items-center justify-between p-4 border-b border-zinc-800 bg-zinc-900/50">
<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>
</div>
<button
onClick={onClose}
className="p-2 text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-full transition-colors"
<button
onClick={close}
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} />
</button>
@@ -105,24 +102,24 @@ export const StylePreviewModal: React.FC<StylePreviewModalProps> = ({ style, onC
{/* Content */}
<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">
<iframe
<iframe
ref={iframeRef}
title="Style Preview"
className="w-full h-full border-0"
/>
</div>
</div>
{/* Footer */}
<div className="p-4 border-t border-zinc-800 bg-zinc-900 flex justify-end">
<button
onClick={onClose}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white font-medium rounded-lg transition-colors"
onClick={close}
className="px-5 py-3 bg-indigo-600 hover:bg-indigo-500 text-white font-medium rounded-lg transition-colors"
>
Close Preview
</button>
</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>
<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>
<h2>2. Essential Elements</h2>
@@ -190,7 +190,7 @@ export const StyleSelector: React.FC<StyleSelectorProps> = ({
const html = `
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -250,7 +250,7 @@ export const StyleSelector: React.FC<StyleSelectorProps> = ({
if (isLoading) {
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="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>
@@ -261,10 +261,10 @@ export const StyleSelector: React.FC<StyleSelectorProps> = ({
if (error) {
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">
<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>
<h3 className="text-xl font-semibold text-white mb-2">Template Error</h3>
<p className="text-zinc-400 mb-6">{error}</p>
@@ -279,7 +279,7 @@ export const StyleSelector: React.FC<StyleSelectorProps> = ({
{/* Top Controls */}
<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="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} />
</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-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>
</div>
<div className="flex gap-1">
<div className="flex gap-1" role="group" aria-label="Paper size">
{(['Letter', 'A4'] as PaperSize[]).map((size) => (
<button
key={size}
onClick={() => onSelectPaperSize(size)}
aria-pressed={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'
: 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800'
: 'text-zinc-400 hover:text-zinc-300 hover:bg-zinc-800'
}`}
>
{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">
{/* 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="flex gap-2">
<div className="flex gap-2" role="group" aria-label="Filter by category">
<button
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'
? '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'
}`}
>
<Heart size={12} className={activeCategory === 'fav' ? 'fill-current' : ''} />
<Heart size={12} className={activeCategory === 'fav' ? 'fill-current' : ''} aria-hidden="true" />
fav
</button>
<div className="w-px h-6 bg-zinc-800 mx-1 self-center" />
<button
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'
? 'bg-white text-black'
: '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
key={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
? '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'
@@ -358,29 +362,48 @@ export const StyleSelector: React.FC<StyleSelectorProps> = ({
{/* Search Input */}
<div className="px-4 pb-4">
<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
type="text"
placeholder="Search templates..."
value={searchQuery}
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 role="status" aria-live="polite" className="sr-only">
{searchQuery.trim() ? `${filteredStyles.length} template${filteredStyles.length !== 1 ? 's' : ''} found` : ''}
</div>
</div>
</div>
</nav>
{/* 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 ? (
<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>
</div>
) : filteredStyles.map((style) => (
<div
key={style.id}
id={`style-${style.id}`}
role="option"
aria-selected={selectedStyle === style.id}
tabIndex={selectedStyle === style.id ? 0 : -1}
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
? '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'}`}
@@ -392,45 +415,48 @@ export const StyleSelector: React.FC<StyleSelectorProps> = ({
<div className="flex items-center gap-2 shrink-0">
<button
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-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>
{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" />
</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">
<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}
</span>
</div>
</div>
))}
</div>
</section>
</div>
{/* 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 */}
<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 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-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>
<div className="h-4 w-px bg-zinc-800 mx-2"></div>
<span className="text-xs text-zinc-500 font-medium">Live Preview</span>
<div className="h-4 w-px bg-zinc-800 mx-2" aria-hidden="true"></div>
<span className="text-xs text-zinc-400 font-medium">Live Preview</span>
</div>
{currentStyleObj && (
<span className="text-xs text-indigo-400 font-mono">{currentStyleObj.name}</span>
@@ -444,10 +470,11 @@ export const StyleSelector: React.FC<StyleSelectorProps> = ({
ref={iframeRef}
className="w-full h-full border-0 block"
title="Style Preview"
tabIndex={-1}
/>
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center text-zinc-600">
<Search size={48} className="mb-4 opacity-20" />
<div className="absolute inset-0 flex flex-col items-center justify-center text-zinc-400">
<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-sm">Choose from the list on the left</p>
</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"
>
<span>Apply Style & Convert</span>
<Printer size={18} />
<Printer size={18} aria-hidden="true" />
</button>
</div>
</div>
</section>
</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 {
:root {
font-size: 16px;
font-size: 100%;
}
body {
font-family: 'Inter', sans-serif;
background-color: #09090b;
color: #e4e4e7;
overflow: hidden;
overflow-x: hidden;
margin: 0;
padding: 0;
}
@@ -47,7 +47,7 @@
#root {
width: 100vw;
height: 100vh;
overflow: hidden;
overflow-x: hidden;
}
}
@@ -104,7 +104,58 @@ body ::-webkit-scrollbar-thumb:hover,
}
.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;
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 ReactDOM from 'react-dom/client';
import App from './App';
import { ErrorBoundary } from './components/ErrorBoundary';
import './index.css';
const rootElement = document.getElementById('root');
@@ -11,6 +12,8 @@ if (!rootElement) {
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
<ErrorBoundary>
<App />
</ErrorBoundary>
</React.StrictMode>
);
+32 -7
View File
@@ -1,4 +1,5 @@
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
@@ -41,17 +42,33 @@ function elementToCss(
if (style.font) css.push(`font-family: ${resolveFont(style.font, fonts)}`);
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.bold) css.push('font-weight: 700');
if (style.italic) css.push('font-style: italic');
if (style.underline) css.push('text-decoration: underline');
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.before) css.push(`margin-top: ${style.spacing.before}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.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?.size) pageStyles.push(`font-size: ${cfg.body.size}pt`);
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) {
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
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.size) pStyles.push(`font-size: ${cfg.body.size}pt`);
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) {
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) {
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;
page?: any;
useTableHeaders?: boolean;
inputFileName?: string;
}
// Transform text to all caps if needed
@@ -622,6 +623,12 @@ export const generateDocxDocument = async (
// Create paragraph for table cell
const paragraph = new Paragraph({
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),
spacing: {
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({
@@ -1195,6 +1203,23 @@ export const generateDocxDocument = async (
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
if (tag === 'hr') {
const hrConfig = elements?.hr;
@@ -1229,6 +1254,9 @@ export const generateDocxDocument = async (
// Build document options
const documentOptions: any = {
title: options.inputFileName || 'Document',
description: 'Generated by TypoGenie',
creator: 'TypoGenie',
styles: {
default: {
document: {