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