Commit ·
0842d68
0
Parent(s):
Initial commit: TranslateGemma browser translator with Transformers.js
Browse files- Complete translation UI with 56 languages
- Mobile-responsive design
- Private & offline-capable translation
- URL hash sharing functionality
- Auto-translate with debounce
- Copy and share features
- Google Translate-inspired design
- .agents/skills/tailwind-v4/SKILL.md +225 -0
- .agents/skills/tailwind-v4/references/dark-mode.md +458 -0
- .agents/skills/tailwind-v4/references/setup.md +264 -0
- .agents/skills/tailwind-v4/references/theming.md +492 -0
- .gitignore +24 -0
- .prettierignore +7 -0
- .prettierrc +7 -0
- README.md +37 -0
- eslint.config.js +28 -0
- index.html +51 -0
- package-lock.json +0 -0
- package.json +37 -0
- public/favicon.svg +8 -0
- public/gemma.svg +1 -0
- public/hf-logo.svg +8 -0
- src/App.tsx +103 -0
- src/Initialize.tsx +104 -0
- src/Translate.tsx +280 -0
- src/ai/Translator.ts +64 -0
- src/components/LanguageSelector.tsx +116 -0
- src/constants.ts +72 -0
- src/index.css +66 -0
- src/main.tsx +10 -0
- src/theme/button/Button.tsx +78 -0
- src/theme/form/Textarea.tsx +60 -0
- src/theme/index.ts +3 -0
- src/theme/misc/Loader.tsx +15 -0
- src/types/transformers.ts +77 -0
- src/utils/classnames.ts +13 -0
- src/utils/countWords.ts +13 -0
- src/utils/format.ts +28 -0
- src/vite-env.d.ts +1 -0
- tsconfig.app.json +26 -0
- tsconfig.app.tsbuildinfo +1 -0
- tsconfig.json +7 -0
- tsconfig.node.json +23 -0
- tsconfig.node.tsbuildinfo +1 -0
- vite.config.ts +8 -0
.agents/skills/tailwind-v4/SKILL.md
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
name: tailwind-v4
|
| 3 |
+
description: Tailwind CSS v4 with CSS-first configuration and design tokens. Use when setting up Tailwind v4, defining theme variables, using OKLCH colors, or configuring dark mode. Triggers on @theme, @tailwindcss/vite, oklch, CSS variables, --color-, tailwind v4.
|
| 4 |
+
---
|
| 5 |
+
|
| 6 |
+
# Tailwind CSS v4 Best Practices
|
| 7 |
+
|
| 8 |
+
## Quick Reference
|
| 9 |
+
|
| 10 |
+
**Vite Plugin Setup**:
|
| 11 |
+
```ts
|
| 12 |
+
// vite.config.ts
|
| 13 |
+
import tailwindcss from '@tailwindcss/vite';
|
| 14 |
+
import { defineConfig } from 'vite';
|
| 15 |
+
|
| 16 |
+
export default defineConfig({
|
| 17 |
+
plugins: [tailwindcss()],
|
| 18 |
+
});
|
| 19 |
+
```
|
| 20 |
+
|
| 21 |
+
**CSS Entry Point**:
|
| 22 |
+
```css
|
| 23 |
+
/* src/index.css */
|
| 24 |
+
@import 'tailwindcss';
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
**@theme Inline Directive**:
|
| 28 |
+
```css
|
| 29 |
+
@theme inline {
|
| 30 |
+
--color-primary: oklch(60% 0.24 262);
|
| 31 |
+
--color-surface: oklch(98% 0.002 247);
|
| 32 |
+
}
|
| 33 |
+
```
|
| 34 |
+
|
| 35 |
+
## Key Differences from v3
|
| 36 |
+
|
| 37 |
+
| Feature | v3 | v4 |
|
| 38 |
+
|---------|----|----|
|
| 39 |
+
| Configuration | tailwind.config.js | @theme in CSS |
|
| 40 |
+
| Build Tool | PostCSS plugin | @tailwindcss/vite |
|
| 41 |
+
| Colors | rgb() / hsl() | oklch() (default) |
|
| 42 |
+
| Theme Extension | extend: {} in JS | CSS variables |
|
| 43 |
+
| Dark Mode | darkMode config option | CSS variants |
|
| 44 |
+
|
| 45 |
+
## @theme Directive Modes
|
| 46 |
+
|
| 47 |
+
### default (standard mode)
|
| 48 |
+
Generates CSS variables that can be referenced elsewhere:
|
| 49 |
+
```css
|
| 50 |
+
@theme {
|
| 51 |
+
--color-brand: oklch(60% 0.24 262);
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
/* Generates: :root { --color-brand: oklch(...); } */
|
| 55 |
+
/* Usage: text-brand → color: var(--color-brand) */
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
**Note**: You can also use `@theme default` explicitly to mark theme values that can be overridden by non-default @theme declarations.
|
| 59 |
+
|
| 60 |
+
### inline
|
| 61 |
+
Inlines values directly without CSS variables (better performance):
|
| 62 |
+
```css
|
| 63 |
+
@theme inline {
|
| 64 |
+
--color-brand: oklch(60% 0.24 262);
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
/* Usage: text-brand → color: oklch(60% 0.24 262) */
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
### reference
|
| 71 |
+
Inlines values as fallbacks without emitting CSS variables:
|
| 72 |
+
```css
|
| 73 |
+
@theme reference {
|
| 74 |
+
--color-internal: oklch(50% 0.1 180);
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
/* No :root variable, but utilities use fallback */
|
| 78 |
+
/* Usage: bg-internal → background-color: var(--color-internal, oklch(50% 0.1 180)) */
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
## OKLCH Color Format
|
| 82 |
+
|
| 83 |
+
OKLCH provides perceptually uniform colors with better consistency across hues:
|
| 84 |
+
|
| 85 |
+
```css
|
| 86 |
+
oklch(L% C H)
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
- **L (Lightness)**: 0% (black) to 100% (white)
|
| 90 |
+
- **C (Chroma)**: 0 (gray) to ~0.4 (vibrant)
|
| 91 |
+
- **H (Hue)**: 0-360 degrees (red → yellow → green → blue → magenta)
|
| 92 |
+
|
| 93 |
+
**Examples**:
|
| 94 |
+
```css
|
| 95 |
+
--color-sky-500: oklch(68.5% 0.169 237.323); /* Bright blue */
|
| 96 |
+
--color-red-600: oklch(57.7% 0.245 27.325); /* Vibrant red */
|
| 97 |
+
--color-zinc-900: oklch(21% 0.006 285.885); /* Near-black gray */
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
## CSS Variable Naming
|
| 101 |
+
|
| 102 |
+
Tailwind v4 uses double-dash CSS variable naming conventions:
|
| 103 |
+
|
| 104 |
+
```css
|
| 105 |
+
@theme {
|
| 106 |
+
/* Colors: --color-{name}-{shade} */
|
| 107 |
+
--color-primary-500: oklch(60% 0.24 262);
|
| 108 |
+
|
| 109 |
+
/* Spacing: --spacing multiplier */
|
| 110 |
+
--spacing: 0.25rem; /* Base unit for spacing scale */
|
| 111 |
+
|
| 112 |
+
/* Fonts: --font-{family} */
|
| 113 |
+
--font-display: 'Inter Variable', system-ui, sans-serif;
|
| 114 |
+
|
| 115 |
+
/* Breakpoints: --breakpoint-{size} */
|
| 116 |
+
--breakpoint-lg: 64rem;
|
| 117 |
+
|
| 118 |
+
/* Custom animations: --animate-{name} */
|
| 119 |
+
--animate-fade-in: fade-in 0.3s ease-out;
|
| 120 |
+
}
|
| 121 |
+
```
|
| 122 |
+
|
| 123 |
+
## No Config Files Needed
|
| 124 |
+
|
| 125 |
+
Tailwind v4 eliminates configuration files:
|
| 126 |
+
|
| 127 |
+
- **No `tailwind.config.js`** - Use @theme in CSS instead
|
| 128 |
+
- **No `postcss.config.js`** - Use @tailwindcss/vite plugin
|
| 129 |
+
- **TypeScript support** - Add `@types/node` for path resolution
|
| 130 |
+
|
| 131 |
+
```json
|
| 132 |
+
{
|
| 133 |
+
"devDependencies": {
|
| 134 |
+
"@tailwindcss/vite": "^4.0.0",
|
| 135 |
+
"@types/node": "^22.0.0",
|
| 136 |
+
"tailwindcss": "^4.0.0",
|
| 137 |
+
"vite": "^6.0.0"
|
| 138 |
+
}
|
| 139 |
+
}
|
| 140 |
+
```
|
| 141 |
+
|
| 142 |
+
## Progressive Disclosure
|
| 143 |
+
|
| 144 |
+
- **Setup & Installation**: See [references/setup.md](references/setup.md) for Vite plugin configuration, package setup, TypeScript config
|
| 145 |
+
- **Theming & Design Tokens**: See [references/theming.md](references/theming.md) for @theme modes, color palettes, custom fonts, animations
|
| 146 |
+
- **Dark Mode Strategies**: See [references/dark-mode.md](references/dark-mode.md) for media queries, class-based, attribute-based approaches
|
| 147 |
+
|
| 148 |
+
## Decision Guide
|
| 149 |
+
|
| 150 |
+
### When to use @theme inline vs default
|
| 151 |
+
|
| 152 |
+
**Use `@theme inline`**:
|
| 153 |
+
- Better performance (no CSS variable overhead)
|
| 154 |
+
- Static color values that won't change
|
| 155 |
+
- Animation keyframes with multiple values
|
| 156 |
+
- Utilities that need direct value inlining
|
| 157 |
+
|
| 158 |
+
**Use `@theme` (default)**:
|
| 159 |
+
- Dynamic theming with JavaScript
|
| 160 |
+
- CSS variable references in custom CSS
|
| 161 |
+
- Values that change based on context
|
| 162 |
+
- Better debugging (inspect CSS variables in DevTools)
|
| 163 |
+
|
| 164 |
+
### When to use @theme reference
|
| 165 |
+
|
| 166 |
+
**Use `@theme reference`**:
|
| 167 |
+
- Provide fallback values without CSS variable overhead
|
| 168 |
+
- Values that should work even if variable isn't defined
|
| 169 |
+
- Reducing :root bloat while maintaining utility support
|
| 170 |
+
- Combining with inline for direct value substitution
|
| 171 |
+
|
| 172 |
+
## Common Patterns
|
| 173 |
+
|
| 174 |
+
### Two-Tier Variable System
|
| 175 |
+
|
| 176 |
+
Semantic variables that map to design tokens:
|
| 177 |
+
|
| 178 |
+
```css
|
| 179 |
+
@theme {
|
| 180 |
+
/* Design tokens (OKLCH colors) */
|
| 181 |
+
--color-blue-600: oklch(54.6% 0.245 262.881);
|
| 182 |
+
--color-slate-800: oklch(27.9% 0.041 260.031);
|
| 183 |
+
|
| 184 |
+
/* Semantic mappings */
|
| 185 |
+
--color-primary: var(--color-blue-600);
|
| 186 |
+
--color-surface: var(--color-slate-800);
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
/* Usage: bg-primary, bg-surface */
|
| 190 |
+
```
|
| 191 |
+
|
| 192 |
+
### Custom Font Configuration
|
| 193 |
+
|
| 194 |
+
```css
|
| 195 |
+
@theme {
|
| 196 |
+
--font-display: 'Inter Variable', system-ui, sans-serif;
|
| 197 |
+
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
|
| 198 |
+
|
| 199 |
+
--font-display--font-variation-settings: 'wght' 400;
|
| 200 |
+
--font-display--font-feature-settings: 'cv02', 'cv03', 'cv04';
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
/* Usage: font-display, font-mono */
|
| 204 |
+
```
|
| 205 |
+
|
| 206 |
+
### Animation Keyframes
|
| 207 |
+
|
| 208 |
+
```css
|
| 209 |
+
@theme inline {
|
| 210 |
+
--animate-beacon: beacon 2s ease-in-out infinite;
|
| 211 |
+
|
| 212 |
+
@keyframes beacon {
|
| 213 |
+
0%, 100% {
|
| 214 |
+
opacity: 1;
|
| 215 |
+
transform: scale(1);
|
| 216 |
+
}
|
| 217 |
+
50% {
|
| 218 |
+
opacity: 0.5;
|
| 219 |
+
transform: scale(1.05);
|
| 220 |
+
}
|
| 221 |
+
}
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
/* Usage: animate-beacon */
|
| 225 |
+
```
|
.agents/skills/tailwind-v4/references/dark-mode.md
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dark Mode Strategies
|
| 2 |
+
|
| 3 |
+
## Contents
|
| 4 |
+
|
| 5 |
+
- [Media Query Strategy](#media-query-strategy)
|
| 6 |
+
- [Class-Based Strategy](#class-based-strategy)
|
| 7 |
+
- [Attribute-Based Strategy](#attribute-based-strategy)
|
| 8 |
+
- [Theme Switching Implementation](#theme-switching-implementation)
|
| 9 |
+
- [Respecting User Preferences](#respecting-user-preferences)
|
| 10 |
+
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
## Media Query Strategy
|
| 14 |
+
|
| 15 |
+
Use the system preference for dark mode detection.
|
| 16 |
+
|
| 17 |
+
### Configuration
|
| 18 |
+
|
| 19 |
+
**Default behavior** (v4):
|
| 20 |
+
```css
|
| 21 |
+
/* No configuration needed - dark: variant works by default */
|
| 22 |
+
@import 'tailwindcss';
|
| 23 |
+
```
|
| 24 |
+
|
| 25 |
+
**Generated CSS**:
|
| 26 |
+
```css
|
| 27 |
+
@media (prefers-color-scheme: dark) {
|
| 28 |
+
.dark\:bg-slate-900 {
|
| 29 |
+
background-color: oklch(20.8% 0.042 265.755);
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
### Usage
|
| 35 |
+
|
| 36 |
+
```tsx
|
| 37 |
+
export function Card({ children }: { children: React.ReactNode }) {
|
| 38 |
+
return (
|
| 39 |
+
<div className="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-50">
|
| 40 |
+
{children}
|
| 41 |
+
</div>
|
| 42 |
+
);
|
| 43 |
+
}
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
### Pros & Cons
|
| 47 |
+
|
| 48 |
+
**Pros**:
|
| 49 |
+
- Respects system preference automatically
|
| 50 |
+
- No JavaScript needed
|
| 51 |
+
- Simple implementation
|
| 52 |
+
- No FOUC (flash of unstyled content)
|
| 53 |
+
|
| 54 |
+
**Cons**:
|
| 55 |
+
- Users can't override system preference
|
| 56 |
+
- No manual toggle control
|
| 57 |
+
- Changes when system setting changes
|
| 58 |
+
|
| 59 |
+
### When to Use
|
| 60 |
+
|
| 61 |
+
- Documentation sites
|
| 62 |
+
- Content-focused websites
|
| 63 |
+
- Apps where system preference is preferred
|
| 64 |
+
- No need for manual theme switching
|
| 65 |
+
|
| 66 |
+
## Class-Based Strategy
|
| 67 |
+
|
| 68 |
+
Toggle dark mode with a `.dark` class on the root element.
|
| 69 |
+
|
| 70 |
+
### Configuration
|
| 71 |
+
|
| 72 |
+
**Pure v4 approach**: Use a v3 config file with darkMode setting:
|
| 73 |
+
|
| 74 |
+
```js
|
| 75 |
+
// tailwind.config.js (for v3 compatibility)
|
| 76 |
+
module.exports = {
|
| 77 |
+
darkMode: 'class', // or 'selector' (same as 'class')
|
| 78 |
+
};
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
**Note**: In pure v4, the default `dark:` variant uses media queries (`prefers-color-scheme: dark`). To use class-based dark mode, you need to either:
|
| 82 |
+
1. Use a v3 config file with `darkMode: 'class'` (shown above)
|
| 83 |
+
2. Use `@import "tailwindcss/compat"` and provide a config
|
| 84 |
+
3. Define a custom variant with `@custom-variant`
|
| 85 |
+
|
| 86 |
+
### Generated CSS
|
| 87 |
+
|
| 88 |
+
```css
|
| 89 |
+
.dark .dark\:bg-slate-900 {
|
| 90 |
+
background-color: oklch(20.8% 0.042 265.755);
|
| 91 |
+
}
|
| 92 |
+
```
|
| 93 |
+
|
| 94 |
+
### Usage
|
| 95 |
+
|
| 96 |
+
```tsx
|
| 97 |
+
export function App() {
|
| 98 |
+
const [isDark, setIsDark] = useState(false);
|
| 99 |
+
|
| 100 |
+
useEffect(() => {
|
| 101 |
+
if (isDark) {
|
| 102 |
+
document.documentElement.classList.add('dark');
|
| 103 |
+
} else {
|
| 104 |
+
document.documentElement.classList.remove('dark');
|
| 105 |
+
}
|
| 106 |
+
}, [isDark]);
|
| 107 |
+
|
| 108 |
+
return (
|
| 109 |
+
<div className="bg-white dark:bg-slate-900">
|
| 110 |
+
<button onClick={() => setIsDark(!isDark)}>
|
| 111 |
+
Toggle Theme
|
| 112 |
+
</button>
|
| 113 |
+
</div>
|
| 114 |
+
);
|
| 115 |
+
}
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
### Pros & Cons
|
| 119 |
+
|
| 120 |
+
**Pros**:
|
| 121 |
+
- Full JavaScript control
|
| 122 |
+
- User can override system preference
|
| 123 |
+
- Easy to implement manual toggle
|
| 124 |
+
- Widely supported pattern
|
| 125 |
+
|
| 126 |
+
**Cons**:
|
| 127 |
+
- Requires JavaScript
|
| 128 |
+
- Potential FOUC without SSR handling
|
| 129 |
+
- Class management overhead
|
| 130 |
+
|
| 131 |
+
### When to Use
|
| 132 |
+
|
| 133 |
+
- Applications with theme toggle
|
| 134 |
+
- User preference override needed
|
| 135 |
+
- Dashboard/admin interfaces
|
| 136 |
+
- Apps with per-user theme settings
|
| 137 |
+
|
| 138 |
+
## Attribute-Based Strategy
|
| 139 |
+
|
| 140 |
+
Use a `data-theme` attribute for more semantic theming.
|
| 141 |
+
|
| 142 |
+
### Configuration (v3 compat)
|
| 143 |
+
|
| 144 |
+
```js
|
| 145 |
+
// tailwind.config.js (v3 compat mode)
|
| 146 |
+
module.exports = {
|
| 147 |
+
darkMode: ['class', '[data-theme="dark"]'],
|
| 148 |
+
};
|
| 149 |
+
```
|
| 150 |
+
|
| 151 |
+
### Generated CSS
|
| 152 |
+
|
| 153 |
+
```css
|
| 154 |
+
[data-theme="dark"] .dark\:bg-slate-900 {
|
| 155 |
+
background-color: oklch(20.8% 0.042 265.755);
|
| 156 |
+
}
|
| 157 |
+
```
|
| 158 |
+
|
| 159 |
+
### Usage
|
| 160 |
+
|
| 161 |
+
```tsx
|
| 162 |
+
export function App() {
|
| 163 |
+
const [theme, setTheme] = useState<'light' | 'dark'>('light');
|
| 164 |
+
|
| 165 |
+
useEffect(() => {
|
| 166 |
+
document.documentElement.setAttribute('data-theme', theme);
|
| 167 |
+
}, [theme]);
|
| 168 |
+
|
| 169 |
+
return (
|
| 170 |
+
<div className="bg-white dark:bg-slate-900">
|
| 171 |
+
<button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
|
| 172 |
+
Toggle Theme
|
| 173 |
+
</button>
|
| 174 |
+
</div>
|
| 175 |
+
);
|
| 176 |
+
}
|
| 177 |
+
```
|
| 178 |
+
|
| 179 |
+
### Multiple Themes
|
| 180 |
+
|
| 181 |
+
Extend beyond light/dark with multiple theme attributes:
|
| 182 |
+
|
| 183 |
+
```tsx
|
| 184 |
+
type Theme = 'light' | 'dark' | 'aviation' | 'high-contrast';
|
| 185 |
+
|
| 186 |
+
export function App() {
|
| 187 |
+
const [theme, setTheme] = useState<Theme>('light');
|
| 188 |
+
|
| 189 |
+
useEffect(() => {
|
| 190 |
+
document.documentElement.setAttribute('data-theme', theme);
|
| 191 |
+
}, [theme]);
|
| 192 |
+
|
| 193 |
+
return (
|
| 194 |
+
<div className="bg-white dark:bg-slate-900 [&[data-theme='aviation']]:bg-blue-950">
|
| 195 |
+
<select value={theme} onChange={(e) => setTheme(e.target.value as Theme)}>
|
| 196 |
+
<option value="light">Light</option>
|
| 197 |
+
<option value="dark">Dark</option>
|
| 198 |
+
<option value="aviation">Aviation</option>
|
| 199 |
+
<option value="high-contrast">High Contrast</option>
|
| 200 |
+
</select>
|
| 201 |
+
</div>
|
| 202 |
+
);
|
| 203 |
+
}
|
| 204 |
+
```
|
| 205 |
+
|
| 206 |
+
### Pros & Cons
|
| 207 |
+
|
| 208 |
+
**Pros**:
|
| 209 |
+
- Semantic HTML attribute
|
| 210 |
+
- Supports multiple themes (not just light/dark)
|
| 211 |
+
- Easy to inspect in DevTools
|
| 212 |
+
- Clear intent
|
| 213 |
+
|
| 214 |
+
**Cons**:
|
| 215 |
+
- Requires JavaScript
|
| 216 |
+
- More verbose selector in CSS
|
| 217 |
+
- Less common pattern
|
| 218 |
+
|
| 219 |
+
### When to Use
|
| 220 |
+
|
| 221 |
+
- Multi-theme applications
|
| 222 |
+
- Semantic HTML preferences
|
| 223 |
+
- Complex theming systems
|
| 224 |
+
- Better DevTools debugging
|
| 225 |
+
|
| 226 |
+
## Theme Switching Implementation
|
| 227 |
+
|
| 228 |
+
Complete implementation with persistence and SSR support.
|
| 229 |
+
|
| 230 |
+
### React Hook
|
| 231 |
+
|
| 232 |
+
```tsx
|
| 233 |
+
// hooks/use-theme.ts
|
| 234 |
+
import { useEffect, useState } from 'react';
|
| 235 |
+
|
| 236 |
+
type Theme = 'light' | 'dark' | 'system';
|
| 237 |
+
|
| 238 |
+
export function useTheme() {
|
| 239 |
+
const [theme, setTheme] = useState<Theme>(() => {
|
| 240 |
+
if (typeof window === 'undefined') return 'system';
|
| 241 |
+
return (localStorage.getItem('theme') as Theme) || 'system';
|
| 242 |
+
});
|
| 243 |
+
|
| 244 |
+
useEffect(() => {
|
| 245 |
+
const root = document.documentElement;
|
| 246 |
+
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
|
| 247 |
+
? 'dark'
|
| 248 |
+
: 'light';
|
| 249 |
+
|
| 250 |
+
const effectiveTheme = theme === 'system' ? systemTheme : theme;
|
| 251 |
+
|
| 252 |
+
root.classList.remove('light', 'dark');
|
| 253 |
+
root.classList.add(effectiveTheme);
|
| 254 |
+
|
| 255 |
+
localStorage.setItem('theme', theme);
|
| 256 |
+
}, [theme]);
|
| 257 |
+
|
| 258 |
+
// Listen for system theme changes
|
| 259 |
+
useEffect(() => {
|
| 260 |
+
if (theme !== 'system') return;
|
| 261 |
+
|
| 262 |
+
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
| 263 |
+
const handleChange = () => {
|
| 264 |
+
const systemTheme = mediaQuery.matches ? 'dark' : 'light';
|
| 265 |
+
document.documentElement.classList.remove('light', 'dark');
|
| 266 |
+
document.documentElement.classList.add(systemTheme);
|
| 267 |
+
};
|
| 268 |
+
|
| 269 |
+
mediaQuery.addEventListener('change', handleChange);
|
| 270 |
+
return () => mediaQuery.removeEventListener('change', handleChange);
|
| 271 |
+
}, [theme]);
|
| 272 |
+
|
| 273 |
+
return { theme, setTheme };
|
| 274 |
+
}
|
| 275 |
+
```
|
| 276 |
+
|
| 277 |
+
### Theme Provider Component
|
| 278 |
+
|
| 279 |
+
```tsx
|
| 280 |
+
// components/theme-provider.tsx
|
| 281 |
+
import { createContext, useContext, type ReactNode } from 'react';
|
| 282 |
+
import { useTheme } from '@/hooks/use-theme';
|
| 283 |
+
|
| 284 |
+
type ThemeContextValue = ReturnType<typeof useTheme>;
|
| 285 |
+
|
| 286 |
+
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
| 287 |
+
|
| 288 |
+
export function ThemeProvider({ children }: { children: ReactNode }) {
|
| 289 |
+
const value = useTheme();
|
| 290 |
+
|
| 291 |
+
return (
|
| 292 |
+
<ThemeContext.Provider value={value}>
|
| 293 |
+
{children}
|
| 294 |
+
</ThemeContext.Provider>
|
| 295 |
+
);
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
export function useThemeContext() {
|
| 299 |
+
const context = useContext(ThemeContext);
|
| 300 |
+
if (!context) {
|
| 301 |
+
throw new Error('useThemeContext must be used within ThemeProvider');
|
| 302 |
+
}
|
| 303 |
+
return context;
|
| 304 |
+
}
|
| 305 |
+
```
|
| 306 |
+
|
| 307 |
+
### Theme Toggle Component
|
| 308 |
+
|
| 309 |
+
```tsx
|
| 310 |
+
// components/theme-toggle.tsx
|
| 311 |
+
import { Moon, Sun, Monitor } from 'lucide-react';
|
| 312 |
+
import { useThemeContext } from '@/components/theme-provider';
|
| 313 |
+
import { Button } from '@/components/ui/button';
|
| 314 |
+
|
| 315 |
+
export function ThemeToggle() {
|
| 316 |
+
const { theme, setTheme } = useThemeContext();
|
| 317 |
+
|
| 318 |
+
const cycleTheme = () => {
|
| 319 |
+
const themes: Array<'light' | 'dark' | 'system'> = ['light', 'dark', 'system'];
|
| 320 |
+
const currentIndex = themes.indexOf(theme);
|
| 321 |
+
const nextIndex = (currentIndex + 1) % themes.length;
|
| 322 |
+
setTheme(themes[nextIndex]);
|
| 323 |
+
};
|
| 324 |
+
|
| 325 |
+
const Icon = theme === 'light' ? Sun : theme === 'dark' ? Moon : Monitor;
|
| 326 |
+
|
| 327 |
+
return (
|
| 328 |
+
<Button
|
| 329 |
+
variant="outline"
|
| 330 |
+
size="icon"
|
| 331 |
+
onClick={cycleTheme}
|
| 332 |
+
aria-label={`Current theme: ${theme}. Click to cycle themes.`}
|
| 333 |
+
>
|
| 334 |
+
<Icon className="h-4 w-4" />
|
| 335 |
+
</Button>
|
| 336 |
+
);
|
| 337 |
+
}
|
| 338 |
+
```
|
| 339 |
+
|
| 340 |
+
### SSR Script (Prevent FOUC)
|
| 341 |
+
|
| 342 |
+
Inject this script before any styled content to prevent flash:
|
| 343 |
+
|
| 344 |
+
```tsx
|
| 345 |
+
// app/layout.tsx (Next.js example)
|
| 346 |
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
| 347 |
+
return (
|
| 348 |
+
<html lang="en" suppressHydrationWarning>
|
| 349 |
+
<head>
|
| 350 |
+
<script
|
| 351 |
+
dangerouslySetInnerHTML={{
|
| 352 |
+
__html: `
|
| 353 |
+
(function() {
|
| 354 |
+
const theme = localStorage.getItem('theme') || 'system';
|
| 355 |
+
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
| 356 |
+
const effectiveTheme = theme === 'system' ? systemTheme : theme;
|
| 357 |
+
document.documentElement.classList.add(effectiveTheme);
|
| 358 |
+
})();
|
| 359 |
+
`,
|
| 360 |
+
}}
|
| 361 |
+
/>
|
| 362 |
+
</head>
|
| 363 |
+
<body>
|
| 364 |
+
<ThemeProvider>
|
| 365 |
+
{children}
|
| 366 |
+
</ThemeProvider>
|
| 367 |
+
</body>
|
| 368 |
+
</html>
|
| 369 |
+
);
|
| 370 |
+
}
|
| 371 |
+
```
|
| 372 |
+
|
| 373 |
+
## Respecting User Preferences
|
| 374 |
+
|
| 375 |
+
### Reduced Motion
|
| 376 |
+
|
| 377 |
+
Always respect `prefers-reduced-motion`:
|
| 378 |
+
|
| 379 |
+
```css
|
| 380 |
+
@media (prefers-reduced-motion: reduce) {
|
| 381 |
+
*,
|
| 382 |
+
*::before,
|
| 383 |
+
*::after {
|
| 384 |
+
animation-duration: 0.01ms !important;
|
| 385 |
+
animation-iteration-count: 1 !important;
|
| 386 |
+
transition-duration: 0.01ms !important;
|
| 387 |
+
scroll-behavior: auto !important;
|
| 388 |
+
}
|
| 389 |
+
}
|
| 390 |
+
```
|
| 391 |
+
|
| 392 |
+
**Usage in components**:
|
| 393 |
+
```tsx
|
| 394 |
+
export function Card() {
|
| 395 |
+
return (
|
| 396 |
+
<div className="transition-all duration-300 motion-reduce:transition-none">
|
| 397 |
+
Content
|
| 398 |
+
</div>
|
| 399 |
+
);
|
| 400 |
+
}
|
| 401 |
+
```
|
| 402 |
+
|
| 403 |
+
### High Contrast
|
| 404 |
+
|
| 405 |
+
Support high contrast mode:
|
| 406 |
+
|
| 407 |
+
```css
|
| 408 |
+
@media (prefers-contrast: high) {
|
| 409 |
+
.button {
|
| 410 |
+
border-width: 2px;
|
| 411 |
+
}
|
| 412 |
+
}
|
| 413 |
+
```
|
| 414 |
+
|
| 415 |
+
**Tailwind utilities**:
|
| 416 |
+
```html
|
| 417 |
+
<button class="border contrast-more:border-2">
|
| 418 |
+
High Contrast Button
|
| 419 |
+
</button>
|
| 420 |
+
```
|
| 421 |
+
|
| 422 |
+
### Forced Colors
|
| 423 |
+
|
| 424 |
+
Respect forced colors mode (Windows High Contrast):
|
| 425 |
+
|
| 426 |
+
```tsx
|
| 427 |
+
export function Card() {
|
| 428 |
+
return (
|
| 429 |
+
<div className="bg-white dark:bg-slate-900 forced-colors:bg-[Canvas] forced-colors:border forced-colors:border-[CanvasText]">
|
| 430 |
+
Content
|
| 431 |
+
</div>
|
| 432 |
+
);
|
| 433 |
+
}
|
| 434 |
+
```
|
| 435 |
+
|
| 436 |
+
### Combined Example
|
| 437 |
+
|
| 438 |
+
```tsx
|
| 439 |
+
export function AccessibleCard({ children }: { children: React.ReactNode }) {
|
| 440 |
+
return (
|
| 441 |
+
<div
|
| 442 |
+
className={`
|
| 443 |
+
bg-white dark:bg-slate-900
|
| 444 |
+
text-slate-900 dark:text-slate-50
|
| 445 |
+
rounded-lg
|
| 446 |
+
transition-colors duration-200
|
| 447 |
+
motion-reduce:transition-none
|
| 448 |
+
border border-transparent
|
| 449 |
+
contrast-more:border-slate-300
|
| 450 |
+
forced-colors:bg-[Canvas]
|
| 451 |
+
forced-colors:border-[CanvasText]
|
| 452 |
+
`}
|
| 453 |
+
>
|
| 454 |
+
{children}
|
| 455 |
+
</div>
|
| 456 |
+
);
|
| 457 |
+
}
|
| 458 |
+
```
|
.agents/skills/tailwind-v4/references/setup.md
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Setup & Installation
|
| 2 |
+
|
| 3 |
+
## Contents
|
| 4 |
+
|
| 5 |
+
- [Package Installation](#package-installation)
|
| 6 |
+
- [Vite Plugin Configuration](#vite-plugin-configuration)
|
| 7 |
+
- [TypeScript Configuration](#typescript-configuration)
|
| 8 |
+
- [CSS Entry Point](#css-entry-point)
|
| 9 |
+
- [Why No Config Files](#why-no-config-files)
|
| 10 |
+
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
## Package Installation
|
| 14 |
+
|
| 15 |
+
Install Tailwind CSS v4 with the Vite plugin:
|
| 16 |
+
|
| 17 |
+
```bash
|
| 18 |
+
pnpm add -D tailwindcss@next @tailwindcss/vite@next
|
| 19 |
+
```
|
| 20 |
+
|
| 21 |
+
**Complete package.json example**:
|
| 22 |
+
```json
|
| 23 |
+
{
|
| 24 |
+
"name": "amelia-dashboard",
|
| 25 |
+
"type": "module",
|
| 26 |
+
"scripts": {
|
| 27 |
+
"dev": "vite",
|
| 28 |
+
"build": "vite build",
|
| 29 |
+
"preview": "vite preview"
|
| 30 |
+
},
|
| 31 |
+
"dependencies": {
|
| 32 |
+
"react": "^19.0.0",
|
| 33 |
+
"react-dom": "^19.0.0"
|
| 34 |
+
},
|
| 35 |
+
"devDependencies": {
|
| 36 |
+
"@tailwindcss/vite": "^4.0.0",
|
| 37 |
+
"@types/node": "^22.0.0",
|
| 38 |
+
"@vitejs/plugin-react": "^5.0.0",
|
| 39 |
+
"tailwindcss": "^4.0.0",
|
| 40 |
+
"typescript": "^5.6.0",
|
| 41 |
+
"vite": "^6.0.0"
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
## Vite Plugin Configuration
|
| 47 |
+
|
| 48 |
+
Use the `@tailwindcss/vite` plugin (NOT the PostCSS plugin):
|
| 49 |
+
|
| 50 |
+
```ts
|
| 51 |
+
// vite.config.ts
|
| 52 |
+
import tailwindcss from '@tailwindcss/vite';
|
| 53 |
+
import react from '@vitejs/plugin-react';
|
| 54 |
+
import { defineConfig } from 'vite';
|
| 55 |
+
|
| 56 |
+
export default defineConfig({
|
| 57 |
+
plugins: [
|
| 58 |
+
react(),
|
| 59 |
+
tailwindcss(),
|
| 60 |
+
],
|
| 61 |
+
});
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
**Plugin options**:
|
| 65 |
+
```ts
|
| 66 |
+
export type PluginOptions = {
|
| 67 |
+
/**
|
| 68 |
+
* Optimize and minify the output CSS.
|
| 69 |
+
* Default: true in build mode, false in dev mode
|
| 70 |
+
*/
|
| 71 |
+
optimize?: boolean | { minify?: boolean };
|
| 72 |
+
};
|
| 73 |
+
|
| 74 |
+
// Example with options
|
| 75 |
+
tailwindcss({
|
| 76 |
+
optimize: {
|
| 77 |
+
minify: true,
|
| 78 |
+
},
|
| 79 |
+
});
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
**How it works**:
|
| 83 |
+
- Scans source files for Tailwind class candidates
|
| 84 |
+
- Intercepts CSS files containing `@import 'tailwindcss'`
|
| 85 |
+
- Generates utilities based on detected classes
|
| 86 |
+
- Watches for file changes in dev mode
|
| 87 |
+
- Optimizes and minifies in build mode
|
| 88 |
+
|
| 89 |
+
## TypeScript Configuration
|
| 90 |
+
|
| 91 |
+
Add `@types/node` for path resolution in Vite config:
|
| 92 |
+
|
| 93 |
+
```json
|
| 94 |
+
{
|
| 95 |
+
"compilerOptions": {
|
| 96 |
+
"target": "ES2020",
|
| 97 |
+
"useDefineForClassFields": true,
|
| 98 |
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
| 99 |
+
"module": "ESNext",
|
| 100 |
+
"skipLibCheck": true,
|
| 101 |
+
|
| 102 |
+
/* Bundler mode */
|
| 103 |
+
"moduleResolution": "bundler",
|
| 104 |
+
"allowImportingTsExtensions": true,
|
| 105 |
+
"isolatedModules": true,
|
| 106 |
+
"moduleDetection": "force",
|
| 107 |
+
"noEmit": true,
|
| 108 |
+
"jsx": "react-jsx",
|
| 109 |
+
|
| 110 |
+
/* Linting */
|
| 111 |
+
"strict": true,
|
| 112 |
+
"noUnusedLocals": true,
|
| 113 |
+
"noUnusedParameters": true,
|
| 114 |
+
"noFallthroughCasesInSwitch": true,
|
| 115 |
+
|
| 116 |
+
/* Path resolution */
|
| 117 |
+
"types": ["node"],
|
| 118 |
+
"baseUrl": ".",
|
| 119 |
+
"paths": {
|
| 120 |
+
"@/*": ["./src/*"]
|
| 121 |
+
}
|
| 122 |
+
},
|
| 123 |
+
"include": ["src"]
|
| 124 |
+
}
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
**Why `@types/node` is needed**:
|
| 128 |
+
- Vite uses Node.js path resolution APIs
|
| 129 |
+
- Required for `import.meta.env` types
|
| 130 |
+
- Enables `path.resolve()` in config files
|
| 131 |
+
|
| 132 |
+
## CSS Entry Point
|
| 133 |
+
|
| 134 |
+
Create a single CSS file that imports Tailwind:
|
| 135 |
+
|
| 136 |
+
```css
|
| 137 |
+
/* src/index.css */
|
| 138 |
+
@import 'tailwindcss';
|
| 139 |
+
```
|
| 140 |
+
|
| 141 |
+
**That's it.** No other imports or configuration needed.
|
| 142 |
+
|
| 143 |
+
**Import in your app**:
|
| 144 |
+
```tsx
|
| 145 |
+
// src/main.tsx
|
| 146 |
+
import './index.css';
|
| 147 |
+
import React from 'react';
|
| 148 |
+
import ReactDOM from 'react-dom/client';
|
| 149 |
+
import App from './App';
|
| 150 |
+
|
| 151 |
+
ReactDOM.createRoot(document.getElementById('root')!).render(
|
| 152 |
+
<React.StrictMode>
|
| 153 |
+
<App />
|
| 154 |
+
</React.StrictMode>
|
| 155 |
+
);
|
| 156 |
+
```
|
| 157 |
+
|
| 158 |
+
**Advanced: Multiple entry points**:
|
| 159 |
+
```css
|
| 160 |
+
/* src/index.css */
|
| 161 |
+
@import 'tailwindcss';
|
| 162 |
+
|
| 163 |
+
/* Custom theme for this entry point */
|
| 164 |
+
@theme {
|
| 165 |
+
--color-primary: oklch(60% 0.24 262);
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
/* Custom utilities */
|
| 169 |
+
@layer utilities {
|
| 170 |
+
.content-auto {
|
| 171 |
+
content-visibility: auto;
|
| 172 |
+
}
|
| 173 |
+
}
|
| 174 |
+
```
|
| 175 |
+
|
| 176 |
+
## Why No Config Files
|
| 177 |
+
|
| 178 |
+
Tailwind v4 eliminates separate configuration files in favor of CSS-first configuration.
|
| 179 |
+
|
| 180 |
+
### No `tailwind.config.js`
|
| 181 |
+
|
| 182 |
+
**v3 approach** (separate JS config):
|
| 183 |
+
```js
|
| 184 |
+
// tailwind.config.js
|
| 185 |
+
module.exports = {
|
| 186 |
+
theme: {
|
| 187 |
+
extend: {
|
| 188 |
+
colors: {
|
| 189 |
+
primary: '#3b82f6',
|
| 190 |
+
},
|
| 191 |
+
},
|
| 192 |
+
},
|
| 193 |
+
};
|
| 194 |
+
```
|
| 195 |
+
|
| 196 |
+
**v4 approach** (CSS-first):
|
| 197 |
+
```css
|
| 198 |
+
@theme {
|
| 199 |
+
--color-primary: oklch(60% 0.24 262);
|
| 200 |
+
}
|
| 201 |
+
```
|
| 202 |
+
|
| 203 |
+
**Benefits**:
|
| 204 |
+
- Configuration lives with styles
|
| 205 |
+
- No build-time JS evaluation
|
| 206 |
+
- Better CSS tooling support (syntax highlighting, autocomplete)
|
| 207 |
+
- Easier to understand what CSS gets generated
|
| 208 |
+
- No context switching between files
|
| 209 |
+
|
| 210 |
+
### No `postcss.config.js`
|
| 211 |
+
|
| 212 |
+
**v3 approach** (PostCSS plugin):
|
| 213 |
+
```js
|
| 214 |
+
// postcss.config.js
|
| 215 |
+
module.exports = {
|
| 216 |
+
plugins: {
|
| 217 |
+
tailwindcss: {},
|
| 218 |
+
autoprefixer: {},
|
| 219 |
+
},
|
| 220 |
+
};
|
| 221 |
+
```
|
| 222 |
+
|
| 223 |
+
**v4 approach** (Vite plugin):
|
| 224 |
+
```ts
|
| 225 |
+
// vite.config.ts
|
| 226 |
+
import tailwindcss from '@tailwindcss/vite';
|
| 227 |
+
|
| 228 |
+
export default defineConfig({
|
| 229 |
+
plugins: [tailwindcss()],
|
| 230 |
+
});
|
| 231 |
+
```
|
| 232 |
+
|
| 233 |
+
**Benefits**:
|
| 234 |
+
- Faster builds (no PostCSS overhead)
|
| 235 |
+
- Integrated with Vite's dev server
|
| 236 |
+
- Better HMR (Hot Module Replacement)
|
| 237 |
+
- Automatic source map generation
|
| 238 |
+
- Native ES modules support
|
| 239 |
+
|
| 240 |
+
### Content Detection
|
| 241 |
+
|
| 242 |
+
**v3 approach** (manual content paths):
|
| 243 |
+
```js
|
| 244 |
+
// tailwind.config.js
|
| 245 |
+
module.exports = {
|
| 246 |
+
content: ['./src/**/*.{js,ts,jsx,tsx}'],
|
| 247 |
+
};
|
| 248 |
+
```
|
| 249 |
+
|
| 250 |
+
**v4 approach** (automatic scanning):
|
| 251 |
+
```css
|
| 252 |
+
/* Auto-scans all files by default */
|
| 253 |
+
@import 'tailwindcss';
|
| 254 |
+
|
| 255 |
+
/* Optional: Custom source patterns */
|
| 256 |
+
@source "src/**/*.{js,ts,jsx,tsx}";
|
| 257 |
+
@source "components/**/*.vue";
|
| 258 |
+
```
|
| 259 |
+
|
| 260 |
+
**Benefits**:
|
| 261 |
+
- Zero configuration by default
|
| 262 |
+
- Explicit control when needed
|
| 263 |
+
- CSS-based configuration
|
| 264 |
+
- Easier to understand and debug
|
.agents/skills/tailwind-v4/references/theming.md
ADDED
|
@@ -0,0 +1,492 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Theming & Design Tokens
|
| 2 |
+
|
| 3 |
+
## Contents
|
| 4 |
+
|
| 5 |
+
- [@theme Directive Modes](#theme-directive-modes)
|
| 6 |
+
- [CSS Variable Naming Conventions](#css-variable-naming-conventions)
|
| 7 |
+
- [OKLCH Color System](#oklch-color-system)
|
| 8 |
+
- [Aviation Theme Example](#aviation-theme-example)
|
| 9 |
+
- [Two-Tier Variable System](#two-tier-variable-system)
|
| 10 |
+
- [Custom Font Configuration](#custom-font-configuration)
|
| 11 |
+
- [Animation Keyframes](#animation-keyframes)
|
| 12 |
+
|
| 13 |
+
---
|
| 14 |
+
|
| 15 |
+
## @theme Directive Modes
|
| 16 |
+
|
| 17 |
+
Tailwind v4 provides multiple modes for defining theme values. Modes can be combined (e.g., `@theme default inline`, `@theme inline reference`).
|
| 18 |
+
|
| 19 |
+
### @theme (default mode)
|
| 20 |
+
|
| 21 |
+
Generates CSS variables that can be referenced in custom CSS:
|
| 22 |
+
|
| 23 |
+
```css
|
| 24 |
+
@theme {
|
| 25 |
+
--color-brand: oklch(60% 0.24 262);
|
| 26 |
+
--spacing: 0.25rem;
|
| 27 |
+
}
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
**Generated CSS**:
|
| 31 |
+
```css
|
| 32 |
+
:root {
|
| 33 |
+
--color-brand: oklch(60% 0.24 262);
|
| 34 |
+
--spacing: 0.25rem;
|
| 35 |
+
}
|
| 36 |
+
```
|
| 37 |
+
|
| 38 |
+
**Usage in utilities**:
|
| 39 |
+
```html
|
| 40 |
+
<div class="text-brand">Uses var(--color-brand)</div>
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
**Usage in custom CSS**:
|
| 44 |
+
```css
|
| 45 |
+
.custom-element {
|
| 46 |
+
color: var(--color-brand);
|
| 47 |
+
padding: calc(var(--spacing) * 4);
|
| 48 |
+
}
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
### @theme inline
|
| 52 |
+
|
| 53 |
+
Inlines values directly without CSS variable indirection:
|
| 54 |
+
|
| 55 |
+
```css
|
| 56 |
+
@theme inline {
|
| 57 |
+
--color-brand: oklch(60% 0.24 262);
|
| 58 |
+
}
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
**Generated CSS** (when `text-brand` is used):
|
| 62 |
+
```css
|
| 63 |
+
.text-brand {
|
| 64 |
+
color: oklch(60% 0.24 262);
|
| 65 |
+
}
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
**When to use**:
|
| 69 |
+
- Better performance (no `var()` lookups)
|
| 70 |
+
- Static values that won't change
|
| 71 |
+
- Utilities with multiple values (animations, shadows)
|
| 72 |
+
- Production builds with no runtime theming
|
| 73 |
+
|
| 74 |
+
### @theme reference
|
| 75 |
+
|
| 76 |
+
Inlines values as fallbacks without emitting CSS variables to :root:
|
| 77 |
+
|
| 78 |
+
```css
|
| 79 |
+
@theme reference {
|
| 80 |
+
--color-internal: oklch(50% 0.1 180);
|
| 81 |
+
}
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
**Generated CSS** (when `bg-internal` is used):
|
| 85 |
+
```css
|
| 86 |
+
.bg-internal {
|
| 87 |
+
background-color: var(--color-internal, oklch(50% 0.1 180));
|
| 88 |
+
}
|
| 89 |
+
```
|
| 90 |
+
|
| 91 |
+
**Key behavior**: No `:root` variable is created, but the utility still works by using the value as a fallback in `var()`.
|
| 92 |
+
|
| 93 |
+
**When to use**:
|
| 94 |
+
- Provide fallback values without CSS variable overhead
|
| 95 |
+
- Reduce :root bloat while maintaining utility functionality
|
| 96 |
+
- Values that should work even if the variable isn't defined elsewhere
|
| 97 |
+
- Combine with `inline` for direct value substitution (e.g., `@theme reference inline`)
|
| 98 |
+
|
| 99 |
+
### @theme default
|
| 100 |
+
|
| 101 |
+
Explicitly marks theme values as defaults that can be overridden:
|
| 102 |
+
|
| 103 |
+
```css
|
| 104 |
+
@theme default {
|
| 105 |
+
--color-primary: oklch(60% 0.24 262);
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
/* Later in the file or another file */
|
| 109 |
+
@theme {
|
| 110 |
+
--color-primary: oklch(70% 0.20 180); /* This overrides the default */
|
| 111 |
+
}
|
| 112 |
+
```
|
| 113 |
+
|
| 114 |
+
**Generated CSS**:
|
| 115 |
+
```css
|
| 116 |
+
:root, :host {
|
| 117 |
+
--color-primary: oklch(70% 0.20 180);
|
| 118 |
+
}
|
| 119 |
+
```
|
| 120 |
+
|
| 121 |
+
**When to use**:
|
| 122 |
+
- Providing base theme values that can be customized
|
| 123 |
+
- Library or framework default themes
|
| 124 |
+
- Creating overridable design systems
|
| 125 |
+
- Used extensively in Tailwind's built-in `theme.css`
|
| 126 |
+
|
| 127 |
+
**Mode combinations**:
|
| 128 |
+
- `@theme default inline` - Default values, inlined directly
|
| 129 |
+
- `@theme default reference` - Default fallbacks without :root emission
|
| 130 |
+
- `@theme default inline reference` - All three combined
|
| 131 |
+
|
| 132 |
+
## CSS Variable Naming Conventions
|
| 133 |
+
|
| 134 |
+
Tailwind v4 uses consistent naming patterns for theme variables:
|
| 135 |
+
|
| 136 |
+
### Colors
|
| 137 |
+
|
| 138 |
+
```css
|
| 139 |
+
--color-{name}-{shade}
|
| 140 |
+
```
|
| 141 |
+
|
| 142 |
+
**Examples**:
|
| 143 |
+
```css
|
| 144 |
+
@theme {
|
| 145 |
+
--color-primary-500: oklch(60% 0.24 262);
|
| 146 |
+
--color-surface-900: oklch(21% 0.006 286);
|
| 147 |
+
--color-success-600: oklch(62.7% 0.194 149);
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
/* Usage: text-primary-500, bg-surface-900, border-success-600 */
|
| 151 |
+
```
|
| 152 |
+
|
| 153 |
+
### Spacing
|
| 154 |
+
|
| 155 |
+
```css
|
| 156 |
+
--spacing: {base-unit}
|
| 157 |
+
```
|
| 158 |
+
|
| 159 |
+
**Example**:
|
| 160 |
+
```css
|
| 161 |
+
@theme {
|
| 162 |
+
--spacing: 0.25rem; /* Base unit (4px at 16px root) */
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
/* Generated scale:
|
| 166 |
+
p-1 → padding: calc(0.25rem * 1) → 4px
|
| 167 |
+
p-4 → padding: calc(0.25rem * 4) → 16px
|
| 168 |
+
p-12 → padding: calc(0.25rem * 12) → 48px
|
| 169 |
+
*/
|
| 170 |
+
```
|
| 171 |
+
|
| 172 |
+
### Fonts
|
| 173 |
+
|
| 174 |
+
```css
|
| 175 |
+
--font-{family}
|
| 176 |
+
--font-{family}--{feature}
|
| 177 |
+
```
|
| 178 |
+
|
| 179 |
+
**Examples**:
|
| 180 |
+
```css
|
| 181 |
+
@theme {
|
| 182 |
+
--font-sans: ui-sans-serif, system-ui, sans-serif;
|
| 183 |
+
--font-mono: 'JetBrains Mono', monospace;
|
| 184 |
+
--font-display: 'Inter Variable', system-ui;
|
| 185 |
+
|
| 186 |
+
--font-display--font-variation-settings: 'wght' 400;
|
| 187 |
+
--font-display--font-feature-settings: 'cv02', 'cv03';
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
/* Usage: font-sans, font-mono, font-display */
|
| 191 |
+
```
|
| 192 |
+
|
| 193 |
+
### Breakpoints
|
| 194 |
+
|
| 195 |
+
```css
|
| 196 |
+
--breakpoint-{size}: {value}
|
| 197 |
+
```
|
| 198 |
+
|
| 199 |
+
**Examples**:
|
| 200 |
+
```css
|
| 201 |
+
@theme {
|
| 202 |
+
--breakpoint-sm: 40rem; /* 640px */
|
| 203 |
+
--breakpoint-md: 48rem; /* 768px */
|
| 204 |
+
--breakpoint-lg: 64rem; /* 1024px */
|
| 205 |
+
--breakpoint-xl: 80rem; /* 1280px */
|
| 206 |
+
--breakpoint-2xl: 96rem; /* 1536px */
|
| 207 |
+
}
|
| 208 |
+
```
|
| 209 |
+
|
| 210 |
+
### Animations
|
| 211 |
+
|
| 212 |
+
```css
|
| 213 |
+
--animate-{name}: {animation-value}
|
| 214 |
+
```
|
| 215 |
+
|
| 216 |
+
**Examples**:
|
| 217 |
+
```css
|
| 218 |
+
@theme inline {
|
| 219 |
+
--animate-spin: spin 1s linear infinite;
|
| 220 |
+
--animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
| 221 |
+
--animate-beacon: beacon 2s ease-in-out infinite;
|
| 222 |
+
|
| 223 |
+
@keyframes spin {
|
| 224 |
+
to { transform: rotate(360deg); }
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
@keyframes pulse {
|
| 228 |
+
50% { opacity: 0.5; }
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
@keyframes beacon {
|
| 232 |
+
0%, 100% { opacity: 1; transform: scale(1); }
|
| 233 |
+
50% { opacity: 0.5; transform: scale(1.05); }
|
| 234 |
+
}
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
/* Usage: animate-spin, animate-pulse, animate-beacon */
|
| 238 |
+
```
|
| 239 |
+
|
| 240 |
+
## OKLCH Color System
|
| 241 |
+
|
| 242 |
+
OKLCH (Oklab LCH) provides perceptually uniform colors with consistent lightness across all hues.
|
| 243 |
+
|
| 244 |
+
### Syntax
|
| 245 |
+
|
| 246 |
+
```css
|
| 247 |
+
oklch(L% C H / A)
|
| 248 |
+
```
|
| 249 |
+
|
| 250 |
+
- **L (Lightness)**: 0% (black) to 100% (white)
|
| 251 |
+
- **C (Chroma)**: 0 (gray) to ~0.4 (vibrant)
|
| 252 |
+
- **H (Hue)**: 0-360 degrees
|
| 253 |
+
- **A (Alpha)**: Optional, 0-1
|
| 254 |
+
|
| 255 |
+
### Hue Wheel
|
| 256 |
+
|
| 257 |
+
```
|
| 258 |
+
0° / 360° - Red
|
| 259 |
+
30° - Orange
|
| 260 |
+
60° - Yellow
|
| 261 |
+
120° - Green
|
| 262 |
+
180° - Cyan
|
| 263 |
+
240° - Blue
|
| 264 |
+
270° - Indigo
|
| 265 |
+
300° - Magenta
|
| 266 |
+
```
|
| 267 |
+
|
| 268 |
+
### Complete Color Palette
|
| 269 |
+
|
| 270 |
+
```css
|
| 271 |
+
@theme {
|
| 272 |
+
/* Blue scale (H ≈ 260) */
|
| 273 |
+
--color-blue-50: oklch(97% 0.014 254.604);
|
| 274 |
+
--color-blue-100: oklch(93.2% 0.032 255.585);
|
| 275 |
+
--color-blue-200: oklch(88.2% 0.059 254.128);
|
| 276 |
+
--color-blue-300: oklch(80.9% 0.105 251.813);
|
| 277 |
+
--color-blue-400: oklch(70.7% 0.165 254.624);
|
| 278 |
+
--color-blue-500: oklch(62.3% 0.214 259.815);
|
| 279 |
+
--color-blue-600: oklch(54.6% 0.245 262.881);
|
| 280 |
+
--color-blue-700: oklch(48.8% 0.243 264.376);
|
| 281 |
+
--color-blue-800: oklch(42.4% 0.199 265.638);
|
| 282 |
+
--color-blue-900: oklch(37.9% 0.146 265.522);
|
| 283 |
+
--color-blue-950: oklch(28.2% 0.091 267.935);
|
| 284 |
+
|
| 285 |
+
/* Slate scale (neutral with slight blue tint) */
|
| 286 |
+
--color-slate-50: oklch(98.4% 0.003 247.858);
|
| 287 |
+
--color-slate-100: oklch(96.8% 0.007 247.896);
|
| 288 |
+
--color-slate-200: oklch(92.9% 0.013 255.508);
|
| 289 |
+
--color-slate-300: oklch(86.9% 0.022 252.894);
|
| 290 |
+
--color-slate-400: oklch(70.4% 0.04 256.788);
|
| 291 |
+
--color-slate-500: oklch(55.4% 0.046 257.417);
|
| 292 |
+
--color-slate-600: oklch(44.6% 0.043 257.281);
|
| 293 |
+
--color-slate-700: oklch(37.2% 0.044 257.287);
|
| 294 |
+
--color-slate-800: oklch(27.9% 0.041 260.031);
|
| 295 |
+
--color-slate-900: oklch(20.8% 0.042 265.755);
|
| 296 |
+
--color-slate-950: oklch(12.9% 0.042 264.695);
|
| 297 |
+
}
|
| 298 |
+
```
|
| 299 |
+
|
| 300 |
+
### Chroma Guidelines
|
| 301 |
+
|
| 302 |
+
- **0**: Pure gray (achromatic)
|
| 303 |
+
- **0.01-0.05**: Subtle tint (slate, zinc)
|
| 304 |
+
- **0.10-0.15**: Muted colors (good for backgrounds)
|
| 305 |
+
- **0.15-0.25**: Vibrant colors (good for UI elements)
|
| 306 |
+
- **0.25-0.40**: Maximum saturation (use sparingly)
|
| 307 |
+
|
| 308 |
+
## Aviation Theme Example
|
| 309 |
+
|
| 310 |
+
Custom color palette for an aviation-themed dashboard:
|
| 311 |
+
|
| 312 |
+
```css
|
| 313 |
+
@theme {
|
| 314 |
+
/* Flight status colors */
|
| 315 |
+
--color-on-time: oklch(72.3% 0.219 149.579); /* Green-600 */
|
| 316 |
+
--color-delayed: oklch(76.9% 0.188 70.08); /* Amber-500 */
|
| 317 |
+
--color-cancelled: oklch(63.7% 0.237 25.331); /* Red-500 */
|
| 318 |
+
--color-diverted: oklch(68.5% 0.169 237.323); /* Sky-500 */
|
| 319 |
+
|
| 320 |
+
/* Navigation colors */
|
| 321 |
+
--color-runway: oklch(87.1% 0.006 286.286); /* Zinc-300 */
|
| 322 |
+
--color-taxiway: oklch(70.5% 0.015 286.067); /* Zinc-400 */
|
| 323 |
+
--color-apron: oklch(55.2% 0.016 285.938); /* Zinc-500 */
|
| 324 |
+
|
| 325 |
+
/* Radar colors */
|
| 326 |
+
--color-primary-radar: oklch(74.6% 0.16 232.661); /* Sky-400 */
|
| 327 |
+
--color-secondary-radar: oklch(76.5% 0.177 163.223); /* Emerald-400 */
|
| 328 |
+
|
| 329 |
+
/* Map layers */
|
| 330 |
+
--color-airspace-class-a: oklch(70.7% 0.165 254.624); /* Blue-400 */
|
| 331 |
+
--color-airspace-class-b: oklch(84.1% 0.238 128.85); /* Lime-400 */
|
| 332 |
+
--color-airspace-class-c: oklch(71.8% 0.202 349.761); /* Pink-400 */
|
| 333 |
+
}
|
| 334 |
+
```
|
| 335 |
+
|
| 336 |
+
## Two-Tier Variable System
|
| 337 |
+
|
| 338 |
+
Separate design tokens from semantic naming:
|
| 339 |
+
|
| 340 |
+
```css
|
| 341 |
+
@theme {
|
| 342 |
+
/* Tier 1: Design tokens (OKLCH primitives) */
|
| 343 |
+
--color-blue-600: oklch(54.6% 0.245 262.881);
|
| 344 |
+
--color-slate-50: oklch(98.4% 0.003 247.858);
|
| 345 |
+
--color-slate-800: oklch(27.9% 0.041 260.031);
|
| 346 |
+
--color-slate-900: oklch(20.8% 0.042 265.755);
|
| 347 |
+
--color-emerald-500: oklch(69.6% 0.17 162.48);
|
| 348 |
+
|
| 349 |
+
/* Tier 2: Semantic mappings */
|
| 350 |
+
--color-primary: var(--color-blue-600);
|
| 351 |
+
--color-surface: var(--color-slate-900);
|
| 352 |
+
--color-surface-raised: var(--color-slate-800);
|
| 353 |
+
--color-text: var(--color-slate-50);
|
| 354 |
+
--color-success: var(--color-emerald-500);
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
/* Usage in components */
|
| 358 |
+
.button-primary {
|
| 359 |
+
background-color: var(--color-primary);
|
| 360 |
+
color: var(--color-text);
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
.card {
|
| 364 |
+
background-color: var(--color-surface-raised);
|
| 365 |
+
}
|
| 366 |
+
```
|
| 367 |
+
|
| 368 |
+
**Benefits**:
|
| 369 |
+
- Design tokens maintain consistency
|
| 370 |
+
- Semantic names convey intent
|
| 371 |
+
- Easy theme switching (just remap tier 2)
|
| 372 |
+
- Clear separation of concerns
|
| 373 |
+
|
| 374 |
+
## Custom Font Configuration
|
| 375 |
+
|
| 376 |
+
Configure custom fonts with variable font features:
|
| 377 |
+
|
| 378 |
+
```css
|
| 379 |
+
@theme {
|
| 380 |
+
/* Font families */
|
| 381 |
+
--font-sans: 'Inter Variable', ui-sans-serif, system-ui, sans-serif;
|
| 382 |
+
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
|
| 383 |
+
--font-display: 'Manrope Variable', system-ui, sans-serif;
|
| 384 |
+
|
| 385 |
+
/* Variable font settings for --font-display */
|
| 386 |
+
--font-display--font-variation-settings: 'wght' 600;
|
| 387 |
+
|
| 388 |
+
/* OpenType features for --font-display */
|
| 389 |
+
--font-display--font-feature-settings: 'ss01', 'ss02', 'cv05';
|
| 390 |
+
|
| 391 |
+
/* Font weights */
|
| 392 |
+
--font-weight-normal: 400;
|
| 393 |
+
--font-weight-medium: 500;
|
| 394 |
+
--font-weight-semibold: 600;
|
| 395 |
+
--font-weight-bold: 700;
|
| 396 |
+
|
| 397 |
+
/* Letter spacing */
|
| 398 |
+
--tracking-tight: -0.025em;
|
| 399 |
+
--tracking-normal: 0em;
|
| 400 |
+
--tracking-wide: 0.025em;
|
| 401 |
+
}
|
| 402 |
+
```
|
| 403 |
+
|
| 404 |
+
**Load fonts in HTML**:
|
| 405 |
+
```html
|
| 406 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 407 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 408 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet">
|
| 409 |
+
```
|
| 410 |
+
|
| 411 |
+
**Usage**:
|
| 412 |
+
```html
|
| 413 |
+
<h1 class="font-display font-semibold tracking-tight">Aviation Dashboard</h1>
|
| 414 |
+
<code class="font-mono text-sm">ATC-1234</code>
|
| 415 |
+
```
|
| 416 |
+
|
| 417 |
+
## Animation Keyframes
|
| 418 |
+
|
| 419 |
+
Define custom animations with `@theme inline` and `@keyframes`:
|
| 420 |
+
|
| 421 |
+
```css
|
| 422 |
+
@theme inline {
|
| 423 |
+
/* Simple animations */
|
| 424 |
+
--animate-fade-in: fade-in 0.3s ease-out;
|
| 425 |
+
--animate-slide-up: slide-up 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
| 426 |
+
|
| 427 |
+
/* Complex animations */
|
| 428 |
+
--animate-beacon: beacon 2s ease-in-out infinite;
|
| 429 |
+
--animate-pulse-glow: pulse-glow 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
| 430 |
+
|
| 431 |
+
/* Keyframe definitions */
|
| 432 |
+
@keyframes fade-in {
|
| 433 |
+
from {
|
| 434 |
+
opacity: 0;
|
| 435 |
+
}
|
| 436 |
+
to {
|
| 437 |
+
opacity: 1;
|
| 438 |
+
}
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
@keyframes slide-up {
|
| 442 |
+
from {
|
| 443 |
+
transform: translateY(10px);
|
| 444 |
+
opacity: 0;
|
| 445 |
+
}
|
| 446 |
+
to {
|
| 447 |
+
transform: translateY(0);
|
| 448 |
+
opacity: 1;
|
| 449 |
+
}
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
@keyframes beacon {
|
| 453 |
+
0%, 100% {
|
| 454 |
+
opacity: 1;
|
| 455 |
+
transform: scale(1);
|
| 456 |
+
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.7);
|
| 457 |
+
}
|
| 458 |
+
50% {
|
| 459 |
+
opacity: 0.9;
|
| 460 |
+
transform: scale(1.05);
|
| 461 |
+
box-shadow: 0 0 0 10px rgba(59, 130, 246, 0);
|
| 462 |
+
}
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
@keyframes pulse-glow {
|
| 466 |
+
0%, 100% {
|
| 467 |
+
box-shadow: 0 0 8px 2px rgba(34, 197, 94, 0.4);
|
| 468 |
+
}
|
| 469 |
+
50% {
|
| 470 |
+
box-shadow: 0 0 16px 4px rgba(34, 197, 94, 0.8);
|
| 471 |
+
}
|
| 472 |
+
}
|
| 473 |
+
}
|
| 474 |
+
```
|
| 475 |
+
|
| 476 |
+
**Usage**:
|
| 477 |
+
```html
|
| 478 |
+
<div class="animate-fade-in">Fades in on mount</div>
|
| 479 |
+
<div class="animate-beacon">Pulsing beacon effect</div>
|
| 480 |
+
<div class="animate-pulse-glow">Glowing status indicator</div>
|
| 481 |
+
```
|
| 482 |
+
|
| 483 |
+
**Respecting prefers-reduced-motion**:
|
| 484 |
+
```css
|
| 485 |
+
@media (prefers-reduced-motion: reduce) {
|
| 486 |
+
* {
|
| 487 |
+
animation-duration: 0.01ms !important;
|
| 488 |
+
animation-iteration-count: 1 !important;
|
| 489 |
+
transition-duration: 0.01ms !important;
|
| 490 |
+
}
|
| 491 |
+
}
|
| 492 |
+
```
|
.gitignore
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Logs
|
| 2 |
+
logs
|
| 3 |
+
*.log
|
| 4 |
+
npm-debug.log*
|
| 5 |
+
yarn-debug.log*
|
| 6 |
+
yarn-error.log*
|
| 7 |
+
pnpm-debug.log*
|
| 8 |
+
lerna-debug.log*
|
| 9 |
+
|
| 10 |
+
node_modules
|
| 11 |
+
dist
|
| 12 |
+
dist-ssr
|
| 13 |
+
*.local
|
| 14 |
+
|
| 15 |
+
# Editor directories and files
|
| 16 |
+
.vscode/*
|
| 17 |
+
!.vscode/extensions.json
|
| 18 |
+
.idea
|
| 19 |
+
.DS_Store
|
| 20 |
+
*.suo
|
| 21 |
+
*.ntvs*
|
| 22 |
+
*.njsproj
|
| 23 |
+
*.sln
|
| 24 |
+
*.sw?
|
.prettierignore
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules
|
| 2 |
+
dist
|
| 3 |
+
build
|
| 4 |
+
coverage
|
| 5 |
+
.idea
|
| 6 |
+
*.log
|
| 7 |
+
package-lock.json
|
.prettierrc
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"singleQuote": false,
|
| 3 |
+
"tabWidth": 2,
|
| 4 |
+
"trailingComma": "es5",
|
| 5 |
+
"printWidth": 80,
|
| 6 |
+
"arrowParens": "always"
|
| 7 |
+
}
|
README.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: TranslateGemma Browser Translator
|
| 3 |
+
emoji: 🌐
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: static
|
| 7 |
+
pinned: false
|
| 8 |
+
license: apache-2.0
|
| 9 |
+
short_description: High-quality translations across 56 languages with Google's TranslateGemma, running entirely in your browser
|
| 10 |
+
app_file: dist/index.html
|
| 11 |
+
header: mini
|
| 12 |
+
models:
|
| 13 |
+
- google/translategemma-4b-it
|
| 14 |
+
- onnx-community/translategemma-text-4b-it-ONNX
|
| 15 |
+
---
|
| 16 |
+
|
| 17 |
+
# TranslateGemma Browser Translator
|
| 18 |
+
|
| 19 |
+
A web-based translation application powered by Google's TranslateGemma model, running entirely in your browser with Transformers.js and ONNX Runtime Web.
|
| 20 |
+
|
| 21 |
+
## ✨ Features
|
| 22 |
+
|
| 23 |
+
- 🌍 **56 Languages** - Translate between 56 different languages
|
| 24 |
+
- 🔒 **Completely Private** - All processing happens in your browser, no data sent to servers
|
| 25 |
+
- 📴 **Offline-Capable** - Works offline after initial model download
|
| 26 |
+
- ⚡ **Real-time Translation** - Auto-translate with 500ms debounce
|
| 27 |
+
- 🔗 **Shareable Links** - Share translations with URL hash parameters
|
| 28 |
+
- 📱 **Mobile Responsive** - Optimized for both desktop and mobile devices
|
| 29 |
+
- 💾 **Local Caching** - Model cached locally for instant subsequent loads
|
| 30 |
+
|
| 31 |
+
## 🤖 TranslateGemma
|
| 32 |
+
|
| 33 |
+
This application uses [Google's TranslateGemma](https://blog.google/technology/developers/gemma-open-models/), a state-of-the-art language model specifically designed for translation tasks. TranslateGemma is part of Google's Gemma family of open models, delivering high-quality translations across 56 languages directly in your browser using [Transformers.js](https://huggingface.co/docs/transformers.js) and ONNX Runtime Web.
|
| 34 |
+
|
| 35 |
+
## 🔒 Completely Private & Offline-Capable
|
| 36 |
+
|
| 37 |
+
Your translations are processed entirely in your browser with **no data sent to any server**. Once the model is downloaded, you can use this translator completely offline. Your text never leaves your device, ensuring complete privacy and security. The model is cached locally, so subsequent visits will load instantly without any downloads.
|
eslint.config.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import js from '@eslint/js'
|
| 2 |
+
import globals from 'globals'
|
| 3 |
+
import reactHooks from 'eslint-plugin-react-hooks'
|
| 4 |
+
import reactRefresh from 'eslint-plugin-react-refresh'
|
| 5 |
+
import tseslint from 'typescript-eslint'
|
| 6 |
+
|
| 7 |
+
export default tseslint.config(
|
| 8 |
+
{ ignores: ['dist'] },
|
| 9 |
+
{
|
| 10 |
+
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
| 11 |
+
files: ['**/*.{ts,tsx}'],
|
| 12 |
+
languageOptions: {
|
| 13 |
+
ecmaVersion: 2020,
|
| 14 |
+
globals: globals.browser,
|
| 15 |
+
},
|
| 16 |
+
plugins: {
|
| 17 |
+
'react-hooks': reactHooks,
|
| 18 |
+
'react-refresh': reactRefresh,
|
| 19 |
+
},
|
| 20 |
+
rules: {
|
| 21 |
+
...reactHooks.configs.recommended.rules,
|
| 22 |
+
'react-refresh/only-export-components': [
|
| 23 |
+
'warn',
|
| 24 |
+
{ allowConstantExport: true },
|
| 25 |
+
],
|
| 26 |
+
},
|
| 27 |
+
}
|
| 28 |
+
)
|
index.html
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
| 7 |
+
|
| 8 |
+
<!-- SEO Meta Tags -->
|
| 9 |
+
<title>
|
| 10 |
+
TranslateGemma Browser Translator - Private & Offline Translation
|
| 11 |
+
</title>
|
| 12 |
+
<meta
|
| 13 |
+
name="description"
|
| 14 |
+
content="High-quality translations across 56 languages powered by Google's TranslateGemma model. Runs entirely in your browser with complete privacy and offline capability using Transformers.js."
|
| 15 |
+
/>
|
| 16 |
+
<meta
|
| 17 |
+
name="keywords"
|
| 18 |
+
content="translation, translator, TranslateGemma, Transformers.js, offline translator, private translation, browser translation, Gemma, ONNX"
|
| 19 |
+
/>
|
| 20 |
+
|
| 21 |
+
<!-- Open Graph Meta Tags -->
|
| 22 |
+
<meta property="og:title" content="TranslateGemma Browser Translator" />
|
| 23 |
+
<meta
|
| 24 |
+
property="og:description"
|
| 25 |
+
content="High-quality translations across 56 languages powered by Google's TranslateGemma model, running entirely in your browser with complete privacy."
|
| 26 |
+
/>
|
| 27 |
+
<meta property="og:type" content="website" />
|
| 28 |
+
<meta property="og:image" content="/favicon.svg" />
|
| 29 |
+
|
| 30 |
+
<!-- Twitter Card Meta Tags -->
|
| 31 |
+
<meta name="twitter:card" content="summary_large_image" />
|
| 32 |
+
<meta name="twitter:title" content="TranslateGemma Browser Translator" />
|
| 33 |
+
<meta
|
| 34 |
+
name="twitter:description"
|
| 35 |
+
content="High-quality translations across 56 languages powered by Google's TranslateGemma model, running entirely in your browser with complete privacy."
|
| 36 |
+
/>
|
| 37 |
+
<meta name="twitter:image" content="/favicon.svg" />
|
| 38 |
+
|
| 39 |
+
<!-- Fonts -->
|
| 40 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 41 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 42 |
+
<link
|
| 43 |
+
href="https://fonts.googleapis.com/css2?family=Google+Sans:wght@400;500;700&display=swap"
|
| 44 |
+
rel="stylesheet"
|
| 45 |
+
/>
|
| 46 |
+
</head>
|
| 47 |
+
<body>
|
| 48 |
+
<div id="root"></div>
|
| 49 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 50 |
+
</body>
|
| 51 |
+
</html>
|
package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "transformersjs-translategemma",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "tsc -b && vite build",
|
| 9 |
+
"lint": "eslint .",
|
| 10 |
+
"preview": "vite preview",
|
| 11 |
+
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css}\""
|
| 12 |
+
},
|
| 13 |
+
"dependencies": {
|
| 14 |
+
"@huggingface/transformers": "^4.0.0-next.3",
|
| 15 |
+
"idb": "^8.0.2",
|
| 16 |
+
"lucide-react": "^0.564.0",
|
| 17 |
+
"react": "^19.0.0",
|
| 18 |
+
"react-dom": "^19.0.0",
|
| 19 |
+
"react-hook-form": "^7.54.2"
|
| 20 |
+
},
|
| 21 |
+
"devDependencies": {
|
| 22 |
+
"@eslint/js": "^9.17.0",
|
| 23 |
+
"@tailwindcss/vite": "^4.1.18",
|
| 24 |
+
"@types/react": "^19.0.7",
|
| 25 |
+
"@types/react-dom": "^19.0.3",
|
| 26 |
+
"@vitejs/plugin-react": "^4.3.4",
|
| 27 |
+
"eslint": "^9.17.0",
|
| 28 |
+
"eslint-plugin-react-hooks": "^5.0.0",
|
| 29 |
+
"eslint-plugin-react-refresh": "^0.4.16",
|
| 30 |
+
"globals": "^15.14.0",
|
| 31 |
+
"prettier": "^3.4.2",
|
| 32 |
+
"tailwindcss": "^4.1.18",
|
| 33 |
+
"typescript": "~5.7.2",
|
| 34 |
+
"typescript-eslint": "^8.18.2",
|
| 35 |
+
"vite": "^6.0.7"
|
| 36 |
+
}
|
| 37 |
+
}
|
public/favicon.svg
ADDED
|
|
public/gemma.svg
ADDED
|
|
public/hf-logo.svg
ADDED
|
|
src/App.tsx
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import Translate from "./Translate";
|
| 2 |
+
import { useState } from "react";
|
| 3 |
+
import Translator from "./ai/Translator.ts";
|
| 4 |
+
import Initialize from "./Initialize.tsx";
|
| 5 |
+
|
| 6 |
+
function App() {
|
| 7 |
+
const [translator, setTranslator] = useState<Translator>(null);
|
| 8 |
+
const [progress, setProgress] = useState<number>(0);
|
| 9 |
+
const [isInitializing, setIsInitializing] = useState<boolean>(false);
|
| 10 |
+
|
| 11 |
+
const init = async () => {
|
| 12 |
+
setIsInitializing(true);
|
| 13 |
+
const t = Translator.getInstance();
|
| 14 |
+
const loaded = new Map<string, number>();
|
| 15 |
+
let newProgress = 0;
|
| 16 |
+
await t.init((e) => {
|
| 17 |
+
if (e.status === "progress") {
|
| 18 |
+
loaded.set(e.file, e.loaded);
|
| 19 |
+
const allLoaded = Array.from(loaded.values()).reduce(
|
| 20 |
+
(acc: number, curr: number) => acc + curr,
|
| 21 |
+
0
|
| 22 |
+
);
|
| 23 |
+
const percentLoaded = Math.round((100 / Translator.size) * allLoaded);
|
| 24 |
+
if (newProgress !== percentLoaded) {
|
| 25 |
+
newProgress = percentLoaded;
|
| 26 |
+
setProgress(newProgress);
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
});
|
| 30 |
+
setTranslator(t);
|
| 31 |
+
setIsInitializing(false);
|
| 32 |
+
};
|
| 33 |
+
|
| 34 |
+
return (
|
| 35 |
+
<div className="min-h-screen bg-neutral-100 flex flex-col justify-between gap-2">
|
| 36 |
+
<header className="bg-white border-b border-border shadow-sm p-4">
|
| 37 |
+
<h1 className="text-md md:text-3xl font-sans text-center flex justify-center items-center gap-2 md:gap-6">
|
| 38 |
+
<span className="font-bold flex items-center justify-center gap-1">
|
| 39 |
+
<img
|
| 40 |
+
src="gemma.svg"
|
| 41 |
+
alt="Gemma Logo"
|
| 42 |
+
className="block"
|
| 43 |
+
style={{
|
| 44 |
+
width: "1.2em",
|
| 45 |
+
height: "1.2em",
|
| 46 |
+
}}
|
| 47 |
+
/>
|
| 48 |
+
<span>
|
| 49 |
+
Translate<span className="text-primary">Gemma</span>
|
| 50 |
+
</span>
|
| 51 |
+
</span>
|
| 52 |
+
<span>//</span>
|
| 53 |
+
<span className="font-bold flex items-center justify-center gap-1">
|
| 54 |
+
<img
|
| 55 |
+
src="hf-logo.svg"
|
| 56 |
+
alt="Gemma Logo"
|
| 57 |
+
className="block"
|
| 58 |
+
style={{
|
| 59 |
+
width: "1.2em",
|
| 60 |
+
height: "1.2em",
|
| 61 |
+
}}
|
| 62 |
+
/>
|
| 63 |
+
Transformers.js
|
| 64 |
+
</span>
|
| 65 |
+
</h1>
|
| 66 |
+
</header>
|
| 67 |
+
{translator ? (
|
| 68 |
+
<Translate className="w-full" translator={translator} />
|
| 69 |
+
) : (
|
| 70 |
+
<Initialize
|
| 71 |
+
onInitialize={init}
|
| 72 |
+
progress={progress}
|
| 73 |
+
isInitializing={isInitializing}
|
| 74 |
+
/>
|
| 75 |
+
)}
|
| 76 |
+
<footer className="p-8 pt-0 text-center text-muted-foreground text-xs md:text-sm">
|
| 77 |
+
<p>
|
| 78 |
+
High-quality translations across 56 languages powered by{" "}
|
| 79 |
+
<a
|
| 80 |
+
href="https://blog.google/technology/developers/gemma-open-models/"
|
| 81 |
+
target="_blank"
|
| 82 |
+
rel="noopener noreferrer"
|
| 83 |
+
className="text-primary hover:underline"
|
| 84 |
+
>
|
| 85 |
+
Google's TranslateGemma
|
| 86 |
+
</a>{" "}
|
| 87 |
+
model, running entirely in your browser with{" "}
|
| 88 |
+
<a
|
| 89 |
+
href="https://huggingface.co/docs/transformers.js"
|
| 90 |
+
target="_blank"
|
| 91 |
+
rel="noopener noreferrer"
|
| 92 |
+
className="text-primary hover:underline"
|
| 93 |
+
>
|
| 94 |
+
Transformers.js
|
| 95 |
+
</a>{" "}
|
| 96 |
+
and complete privacy.
|
| 97 |
+
</p>
|
| 98 |
+
</footer>
|
| 99 |
+
</div>
|
| 100 |
+
);
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
export default App;
|
src/Initialize.tsx
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Button } from "./theme";
|
| 2 |
+
import cn from "./utils/classnames.ts";
|
| 3 |
+
import { formatBytes } from "./utils/format.ts";
|
| 4 |
+
import Translator from "./ai/Translator.ts";
|
| 5 |
+
|
| 6 |
+
interface InitializeProps {
|
| 7 |
+
progress: number;
|
| 8 |
+
onInitialize: () => Promise<void>;
|
| 9 |
+
isInitializing?: boolean;
|
| 10 |
+
className?: string;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export default function Initialize({
|
| 14 |
+
progress,
|
| 15 |
+
onInitialize,
|
| 16 |
+
isInitializing = false,
|
| 17 |
+
className = "",
|
| 18 |
+
}: InitializeProps) {
|
| 19 |
+
return (
|
| 20 |
+
<div className={cn("max-w-2xl mx-auto px-8", className)}>
|
| 21 |
+
<div className="bg-white rounded-lg shadow-md p-8 border border-border">
|
| 22 |
+
<div className="flex flex-col items-center gap-6">
|
| 23 |
+
{/* Description */}
|
| 24 |
+
<div className="space-y-4 text-left w-full">
|
| 25 |
+
<div>
|
| 26 |
+
<h3 className="font-semibold text-base mb-1">
|
| 27 |
+
About TranslateGemma 4B
|
| 28 |
+
</h3>
|
| 29 |
+
<p className="text-sm text-secondary-foreground">
|
| 30 |
+
TranslateGemma is a family of translation models from Google,
|
| 31 |
+
built on top of Gemma 2B. This 4B parameter model supports
|
| 32 |
+
translations across 100+ languages with state-of-the-art
|
| 33 |
+
quality.{" "}
|
| 34 |
+
<a
|
| 35 |
+
href="https://blog.google/innovation-and-ai/technology/developers-tools/translategemma/"
|
| 36 |
+
target="_blank"
|
| 37 |
+
rel="noopener noreferrer"
|
| 38 |
+
className="text-primary hover:underline"
|
| 39 |
+
>
|
| 40 |
+
Learn more
|
| 41 |
+
</a>
|
| 42 |
+
</p>
|
| 43 |
+
</div>
|
| 44 |
+
|
| 45 |
+
<div>
|
| 46 |
+
<h3 className="font-semibold text-base mb-1">
|
| 47 |
+
Completely private & offline-capable
|
| 48 |
+
</h3>
|
| 49 |
+
<p className="text-sm text-secondary-foreground">
|
| 50 |
+
Everything runs entirely in your browser with 🤗 Transformers.js
|
| 51 |
+
and ONNX Runtime Web — no data is ever sent to a server. Once
|
| 52 |
+
loaded, it works offline.
|
| 53 |
+
</p>
|
| 54 |
+
</div>
|
| 55 |
+
|
| 56 |
+
<div>
|
| 57 |
+
<h3 className="font-semibold text-base mb-1">
|
| 58 |
+
Experimental — WebGPU required
|
| 59 |
+
</h3>
|
| 60 |
+
<p className="text-sm text-secondary-foreground">
|
| 61 |
+
This is experimental and requires a browser with WebGPU support
|
| 62 |
+
and enough VRAM to run the model.
|
| 63 |
+
</p>
|
| 64 |
+
</div>
|
| 65 |
+
</div>
|
| 66 |
+
|
| 67 |
+
{/* Initialize button or progress indicator */}
|
| 68 |
+
{isInitializing ? (
|
| 69 |
+
<div className="w-full space-y-2">
|
| 70 |
+
<div className="flex justify-between items-center">
|
| 71 |
+
<span className="text-sm font-medium text-muted-foreground">
|
| 72 |
+
Loading model...
|
| 73 |
+
</span>
|
| 74 |
+
<span className="text-sm font-medium text-primary">
|
| 75 |
+
{progress}%
|
| 76 |
+
</span>
|
| 77 |
+
</div>
|
| 78 |
+
<div className="w-full bg-muted rounded-full h-2 overflow-hidden">
|
| 79 |
+
<div
|
| 80 |
+
className="bg-primary h-full transition-all duration-300 ease-out"
|
| 81 |
+
style={{ width: `${progress}%` }}
|
| 82 |
+
/>
|
| 83 |
+
</div>
|
| 84 |
+
<p className="text-xs text-muted-foreground text-center">
|
| 85 |
+
This may take a few moments. Please don't close the page.
|
| 86 |
+
</p>
|
| 87 |
+
</div>
|
| 88 |
+
) : (
|
| 89 |
+
<Button
|
| 90 |
+
variant="primary"
|
| 91 |
+
onClick={onInitialize}
|
| 92 |
+
disabled={isInitializing}
|
| 93 |
+
className="text-lg mt-8"
|
| 94 |
+
>
|
| 95 |
+
{isInitializing
|
| 96 |
+
? "Initializing..."
|
| 97 |
+
: `Download TranslateGemma (${formatBytes(Translator.size)})`}
|
| 98 |
+
</Button>
|
| 99 |
+
)}
|
| 100 |
+
</div>
|
| 101 |
+
</div>
|
| 102 |
+
</div>
|
| 103 |
+
);
|
| 104 |
+
}
|
src/Translate.tsx
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect, useRef } from "react";
|
| 2 |
+
import { ArrowLeftRight, Copy, Check, Share2, Trash } from "lucide-react";
|
| 3 |
+
import { Textarea, Button, Loader } from "./theme";
|
| 4 |
+
import cn from "./utils/classnames.ts";
|
| 5 |
+
import { type LanguageCode, LANGUAGES } from "./constants";
|
| 6 |
+
import LanguageSelector from "./components/LanguageSelector";
|
| 7 |
+
import Translator from "./ai/Translator.ts";
|
| 8 |
+
import { formatTime, formatNumber } from "./utils/format";
|
| 9 |
+
import { countWords } from "./utils/countWords.ts";
|
| 10 |
+
|
| 11 |
+
const MAX_INPUT_LENGTH = 1000;
|
| 12 |
+
|
| 13 |
+
interface TranslateProps {
|
| 14 |
+
className?: string;
|
| 15 |
+
translator: Translator;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export default function Translate({
|
| 19 |
+
className = "",
|
| 20 |
+
translator,
|
| 21 |
+
}: TranslateProps) {
|
| 22 |
+
// Initialize from URL hash
|
| 23 |
+
const getInitialState = () => {
|
| 24 |
+
const hash = window.location.hash.slice(1); // Remove the # character
|
| 25 |
+
const params = new URLSearchParams(hash);
|
| 26 |
+
|
| 27 |
+
const sourceLang = params.get("sl");
|
| 28 |
+
const targetLang = params.get("tl");
|
| 29 |
+
const text = params.get("text");
|
| 30 |
+
|
| 31 |
+
// Validate language codes
|
| 32 |
+
const isValidLanguage = (code: string | null): code is LanguageCode => {
|
| 33 |
+
if (!code) return false;
|
| 34 |
+
return LANGUAGES.some((lang) => lang.code === code);
|
| 35 |
+
};
|
| 36 |
+
|
| 37 |
+
return {
|
| 38 |
+
sourceLanguage: isValidLanguage(sourceLang)
|
| 39 |
+
? sourceLang
|
| 40 |
+
: ("en" as LanguageCode),
|
| 41 |
+
targetLanguage: isValidLanguage(targetLang)
|
| 42 |
+
? targetLang
|
| 43 |
+
: ("de_DE" as LanguageCode),
|
| 44 |
+
sourceText: text ? decodeURIComponent(text) : "",
|
| 45 |
+
};
|
| 46 |
+
};
|
| 47 |
+
|
| 48 |
+
const initialState = getInitialState();
|
| 49 |
+
|
| 50 |
+
const [sourceText, setSourceText] = useState(initialState.sourceText);
|
| 51 |
+
const [targetText, setTargetText] = useState("");
|
| 52 |
+
const [sourceLanguage, setSourceLanguage] = useState<LanguageCode>(
|
| 53 |
+
initialState.sourceLanguage
|
| 54 |
+
);
|
| 55 |
+
const [targetLanguage, setTargetLanguage] = useState<LanguageCode>(
|
| 56 |
+
initialState.targetLanguage
|
| 57 |
+
);
|
| 58 |
+
const [translating, setTranslating] = useState<boolean>(false);
|
| 59 |
+
const [copied, setCopied] = useState<boolean>(false);
|
| 60 |
+
const [shared, setShared] = useState<boolean>(false);
|
| 61 |
+
const abortControllerRef = useRef<AbortController | null>(null);
|
| 62 |
+
const [translationTime, setTranslationTime] = useState<number>(0);
|
| 63 |
+
const [translationWords, setTranslationWords] = useState<number>(0);
|
| 64 |
+
|
| 65 |
+
const handleSwapLanguages = () => {
|
| 66 |
+
// Swap languages
|
| 67 |
+
setSourceLanguage(targetLanguage);
|
| 68 |
+
setTargetLanguage(sourceLanguage);
|
| 69 |
+
|
| 70 |
+
// Swap text content
|
| 71 |
+
setSourceText(targetText);
|
| 72 |
+
setTargetText(sourceText);
|
| 73 |
+
};
|
| 74 |
+
|
| 75 |
+
const handleCopy = async () => {
|
| 76 |
+
if (!targetText) return;
|
| 77 |
+
|
| 78 |
+
try {
|
| 79 |
+
await navigator.clipboard.writeText(targetText);
|
| 80 |
+
setCopied(true);
|
| 81 |
+
setTimeout(() => setCopied(false), 2000);
|
| 82 |
+
} catch (error) {
|
| 83 |
+
console.error("Failed to copy:", error);
|
| 84 |
+
}
|
| 85 |
+
};
|
| 86 |
+
|
| 87 |
+
const handleShare = async () => {
|
| 88 |
+
try {
|
| 89 |
+
const currentUrl = window.location.href;
|
| 90 |
+
await navigator.clipboard.writeText(currentUrl);
|
| 91 |
+
setShared(true);
|
| 92 |
+
setTimeout(() => setShared(false), 2000);
|
| 93 |
+
} catch (error) {
|
| 94 |
+
console.error("Failed to share:", error);
|
| 95 |
+
}
|
| 96 |
+
};
|
| 97 |
+
|
| 98 |
+
const translate = async (
|
| 99 |
+
text: string,
|
| 100 |
+
sourceLang: LanguageCode,
|
| 101 |
+
targetLang: LanguageCode
|
| 102 |
+
) => {
|
| 103 |
+
if (!text.trim()) {
|
| 104 |
+
setTargetText("");
|
| 105 |
+
setTranslationTime(0);
|
| 106 |
+
setTranslationWords(0);
|
| 107 |
+
return;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
const started = performance.now();
|
| 111 |
+
|
| 112 |
+
if (abortControllerRef.current) {
|
| 113 |
+
abortControllerRef.current.abort();
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
abortControllerRef.current = new AbortController();
|
| 117 |
+
const currentController = abortControllerRef.current;
|
| 118 |
+
|
| 119 |
+
setTranslating(true);
|
| 120 |
+
|
| 121 |
+
try {
|
| 122 |
+
const translation = await translator.translate(
|
| 123 |
+
text,
|
| 124 |
+
sourceLang,
|
| 125 |
+
targetLang
|
| 126 |
+
);
|
| 127 |
+
|
| 128 |
+
if (!currentController.signal.aborted) {
|
| 129 |
+
setTargetText(translation);
|
| 130 |
+
setTranslationTime(Math.round(performance.now() - started));
|
| 131 |
+
setTranslationWords(countWords(text));
|
| 132 |
+
}
|
| 133 |
+
} catch (error) {
|
| 134 |
+
if (!currentController.signal.aborted) {
|
| 135 |
+
console.error("Translation error:", error);
|
| 136 |
+
}
|
| 137 |
+
} finally {
|
| 138 |
+
if (!currentController.signal.aborted) {
|
| 139 |
+
setTranslating(false);
|
| 140 |
+
}
|
| 141 |
+
}
|
| 142 |
+
};
|
| 143 |
+
|
| 144 |
+
// Update URL hash when languages or text change
|
| 145 |
+
useEffect(() => {
|
| 146 |
+
const params = new URLSearchParams();
|
| 147 |
+
params.set("sl", sourceLanguage);
|
| 148 |
+
params.set("tl", targetLanguage);
|
| 149 |
+
if (sourceText) {
|
| 150 |
+
params.set("text", encodeURIComponent(sourceText));
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
const newHash = `#${params.toString()}`;
|
| 154 |
+
window.history.replaceState({}, "", newHash);
|
| 155 |
+
}, [sourceLanguage, targetLanguage, sourceText]);
|
| 156 |
+
|
| 157 |
+
useEffect(() => {
|
| 158 |
+
const timer = setTimeout(() => {
|
| 159 |
+
translate(sourceText, sourceLanguage, targetLanguage);
|
| 160 |
+
}, 500);
|
| 161 |
+
|
| 162 |
+
return () => {
|
| 163 |
+
clearTimeout(timer);
|
| 164 |
+
};
|
| 165 |
+
}, [sourceText, sourceLanguage, targetLanguage]);
|
| 166 |
+
|
| 167 |
+
return (
|
| 168 |
+
<div className={cn("max-w-6xl mx-auto p-2 md:p-4 relative", className)}>
|
| 169 |
+
<div className="flex flex-col md:flex-row w-full gap-4 md:gap-8">
|
| 170 |
+
<div className="flex flex-col gap-3 w-full md:w-1/2 relative">
|
| 171 |
+
<LanguageSelector
|
| 172 |
+
value={sourceLanguage}
|
| 173 |
+
onChange={setSourceLanguage}
|
| 174 |
+
/>
|
| 175 |
+
<Textarea
|
| 176 |
+
value={sourceText}
|
| 177 |
+
onChange={(e) => setSourceText(e.target.value)}
|
| 178 |
+
placeholder="Enter text to translate..."
|
| 179 |
+
className="h-48 md:h-70 pb-10"
|
| 180 |
+
variant="default"
|
| 181 |
+
maxLength={MAX_INPUT_LENGTH}
|
| 182 |
+
/>
|
| 183 |
+
<div className="p-2 flex justify-between items-center -mt-4">
|
| 184 |
+
<p className="text-xs text-muted-foreground opacity-70">
|
| 185 |
+
{formatNumber(sourceText.length)} /{" "}
|
| 186 |
+
{formatNumber(MAX_INPUT_LENGTH)}
|
| 187 |
+
</p>
|
| 188 |
+
<div className="flex gap-2">
|
| 189 |
+
<button
|
| 190 |
+
onClick={handleShare}
|
| 191 |
+
className="p-2 text-muted-foreground hover:text-primary hover:bg-muted rounded-md transition-colors"
|
| 192 |
+
aria-label="Share translation"
|
| 193 |
+
>
|
| 194 |
+
{shared ? (
|
| 195 |
+
<span className="flex text-xs gap-2">
|
| 196 |
+
link copied
|
| 197 |
+
<Check className="w-4 h-4 text-primary" />
|
| 198 |
+
</span>
|
| 199 |
+
) : (
|
| 200 |
+
<Share2 className="w-4 h-4" />
|
| 201 |
+
)}
|
| 202 |
+
</button>
|
| 203 |
+
<button
|
| 204 |
+
onClick={() => setSourceText("")}
|
| 205 |
+
className="p-2 text-muted-foreground hover:text-primary hover:bg-muted rounded-md transition-colors"
|
| 206 |
+
aria-label="clear text"
|
| 207 |
+
>
|
| 208 |
+
<Trash className="w-4 h-4" />
|
| 209 |
+
</button>
|
| 210 |
+
</div>
|
| 211 |
+
</div>
|
| 212 |
+
</div>
|
| 213 |
+
|
| 214 |
+
<div className="hidden md:block absolute left-1/2 top-5 -translate-x-1/2">
|
| 215 |
+
<Button
|
| 216 |
+
variant="ghost"
|
| 217 |
+
icon={ArrowLeftRight}
|
| 218 |
+
onClick={handleSwapLanguages}
|
| 219 |
+
aria-label="Swap languages"
|
| 220 |
+
/>
|
| 221 |
+
</div>
|
| 222 |
+
|
| 223 |
+
<div className="flex md:hidden justify-center -my-2">
|
| 224 |
+
<Button
|
| 225 |
+
variant="ghost"
|
| 226 |
+
icon={ArrowLeftRight}
|
| 227 |
+
onClick={handleSwapLanguages}
|
| 228 |
+
aria-label="Swap languages"
|
| 229 |
+
className="rotate-90"
|
| 230 |
+
/>
|
| 231 |
+
</div>
|
| 232 |
+
|
| 233 |
+
<div className="flex flex-col gap-3 w-full md:w-1/2">
|
| 234 |
+
<div className="flex items-center justify-between">
|
| 235 |
+
<LanguageSelector
|
| 236 |
+
value={targetLanguage}
|
| 237 |
+
onChange={setTargetLanguage}
|
| 238 |
+
/>
|
| 239 |
+
{translating && <Loader size={20} />}
|
| 240 |
+
</div>
|
| 241 |
+
<div>
|
| 242 |
+
<Textarea
|
| 243 |
+
value={targetText}
|
| 244 |
+
disabled
|
| 245 |
+
placeholder="Translation will appear here..."
|
| 246 |
+
className="h-48 md:h-70"
|
| 247 |
+
variant="default"
|
| 248 |
+
/>
|
| 249 |
+
<div className="p-2 flex justify-between items-center -mt-4">
|
| 250 |
+
{translationTime > 0 ? (
|
| 251 |
+
<p className="text-xs text-muted-foreground opacity-70">
|
| 252 |
+
Translated <b>{formatNumber(translationWords)} words</b> in{" "}
|
| 253 |
+
<b>{formatTime(translationTime)}</b>
|
| 254 |
+
</p>
|
| 255 |
+
) : (
|
| 256 |
+
<p />
|
| 257 |
+
)}
|
| 258 |
+
<div>
|
| 259 |
+
<button
|
| 260 |
+
onClick={handleCopy}
|
| 261 |
+
className="p-2 text-muted-foreground hover:text-primary hover:bg-muted rounded-md transition-colors"
|
| 262 |
+
aria-label="Copy translation"
|
| 263 |
+
>
|
| 264 |
+
{copied ? (
|
| 265 |
+
<span className="flex text-xs gap-2">
|
| 266 |
+
translation copied
|
| 267 |
+
<Check className="w-4 h-4 text-primary" />
|
| 268 |
+
</span>
|
| 269 |
+
) : (
|
| 270 |
+
<Copy className="w-4 h-4" />
|
| 271 |
+
)}
|
| 272 |
+
</button>
|
| 273 |
+
</div>
|
| 274 |
+
</div>
|
| 275 |
+
</div>
|
| 276 |
+
</div>
|
| 277 |
+
</div>
|
| 278 |
+
</div>
|
| 279 |
+
);
|
| 280 |
+
}
|
src/ai/Translator.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { pipeline, DataType } from "@huggingface/transformers";
|
| 2 |
+
import { ProgressCallback } from "../types/transformers.ts";
|
| 3 |
+
|
| 4 |
+
class Translator {
|
| 5 |
+
private static instance: Translator | null = null;
|
| 6 |
+
private pipeline: any = null;
|
| 7 |
+
private static modelId: string =
|
| 8 |
+
"onnx-community/translategemma-text-4b-it-ONNX";
|
| 9 |
+
private static dtype: DataType = "q4";
|
| 10 |
+
public static size: number = 3111894696;
|
| 11 |
+
|
| 12 |
+
private constructor() {}
|
| 13 |
+
|
| 14 |
+
public static getInstance(): Translator {
|
| 15 |
+
if (!Translator.instance) {
|
| 16 |
+
Translator.instance = new Translator();
|
| 17 |
+
}
|
| 18 |
+
return Translator.instance;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
public async init(onProgress?: ProgressCallback) {
|
| 22 |
+
if (this.pipeline) return;
|
| 23 |
+
|
| 24 |
+
this.pipeline = await pipeline("text-generation", Translator.modelId, {
|
| 25 |
+
progress_callback: onProgress,
|
| 26 |
+
device: "webgpu",
|
| 27 |
+
dtype: Translator.dtype,
|
| 28 |
+
});
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
public async translate(
|
| 32 |
+
text: string,
|
| 33 |
+
sourceLang: string,
|
| 34 |
+
targetLang: string
|
| 35 |
+
): Promise<string> {
|
| 36 |
+
if (!this.pipeline) {
|
| 37 |
+
throw new Error("Translator not initialized. Call init() first.");
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
console.log(`Translating from ${sourceLang} to ${targetLang}`, text);
|
| 41 |
+
|
| 42 |
+
const messages = [
|
| 43 |
+
{
|
| 44 |
+
role: "user",
|
| 45 |
+
content: [
|
| 46 |
+
{
|
| 47 |
+
type: "text",
|
| 48 |
+
source_lang_code: sourceLang,
|
| 49 |
+
target_lang_code: targetLang,
|
| 50 |
+
text,
|
| 51 |
+
},
|
| 52 |
+
],
|
| 53 |
+
},
|
| 54 |
+
];
|
| 55 |
+
|
| 56 |
+
const output = await this.pipeline(messages, {
|
| 57 |
+
max_new_tokens: 1024,
|
| 58 |
+
});
|
| 59 |
+
|
| 60 |
+
return output[0].generated_text.pop().content;
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
export default Translator;
|
src/components/LanguageSelector.tsx
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useRef, useEffect } from "react";
|
| 2 |
+
import { ChevronDown } from "lucide-react";
|
| 3 |
+
import {
|
| 4 |
+
LANGUAGES,
|
| 5 |
+
type LanguageCode,
|
| 6 |
+
LANGUAGES_WITH_AUTO,
|
| 7 |
+
} from "../constants";
|
| 8 |
+
|
| 9 |
+
interface LanguageSelectorProps {
|
| 10 |
+
value: LanguageCode;
|
| 11 |
+
onChange: (language: LanguageCode) => void;
|
| 12 |
+
includeAuto?: boolean;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export default function LanguageSelector({
|
| 16 |
+
value,
|
| 17 |
+
onChange,
|
| 18 |
+
includeAuto = false,
|
| 19 |
+
}: LanguageSelectorProps) {
|
| 20 |
+
const [isOpen, setIsOpen] = useState(false);
|
| 21 |
+
const [search, setSearch] = useState("");
|
| 22 |
+
const containerRef = useRef<HTMLDivElement>(null);
|
| 23 |
+
const searchInputRef = useRef<HTMLInputElement>(null);
|
| 24 |
+
|
| 25 |
+
const languages = includeAuto ? LANGUAGES_WITH_AUTO : LANGUAGES;
|
| 26 |
+
|
| 27 |
+
const getLanguageDisplay = (code: LanguageCode) => {
|
| 28 |
+
return languages.find((lang) => lang.code === code)?.name || code;
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
const filterLanguages = (searchTerm: string) => {
|
| 32 |
+
const searchLower = searchTerm.toLowerCase();
|
| 33 |
+
return languages.filter(
|
| 34 |
+
(lang) =>
|
| 35 |
+
lang.name.toLowerCase().includes(searchLower) ||
|
| 36 |
+
lang.code.toLowerCase().includes(searchLower)
|
| 37 |
+
);
|
| 38 |
+
};
|
| 39 |
+
|
| 40 |
+
const filteredLanguages = search ? filterLanguages(search) : languages;
|
| 41 |
+
|
| 42 |
+
const handleSelect = (language: LanguageCode) => {
|
| 43 |
+
onChange(language);
|
| 44 |
+
setIsOpen(false);
|
| 45 |
+
setSearch("");
|
| 46 |
+
};
|
| 47 |
+
|
| 48 |
+
useEffect(() => {
|
| 49 |
+
const handleClickOutside = (event: MouseEvent) => {
|
| 50 |
+
if (
|
| 51 |
+
containerRef.current &&
|
| 52 |
+
!containerRef.current.contains(event.target as Node)
|
| 53 |
+
) {
|
| 54 |
+
setIsOpen(false);
|
| 55 |
+
setSearch("");
|
| 56 |
+
}
|
| 57 |
+
};
|
| 58 |
+
|
| 59 |
+
if (isOpen) {
|
| 60 |
+
document.addEventListener("mousedown", handleClickOutside);
|
| 61 |
+
setTimeout(() => searchInputRef.current?.focus(), 0);
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
return () => {
|
| 65 |
+
document.removeEventListener("mousedown", handleClickOutside);
|
| 66 |
+
};
|
| 67 |
+
}, [isOpen]);
|
| 68 |
+
|
| 69 |
+
return (
|
| 70 |
+
<div className="relative" ref={containerRef}>
|
| 71 |
+
<button
|
| 72 |
+
onClick={() => setIsOpen(!isOpen)}
|
| 73 |
+
className="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium text-primary hover:bg-muted rounded-md transition-colors"
|
| 74 |
+
>
|
| 75 |
+
<span>{getLanguageDisplay(value)}</span>
|
| 76 |
+
<ChevronDown
|
| 77 |
+
className={`w-4 h-4 transition-transform ${isOpen ? "rotate-180" : ""}`}
|
| 78 |
+
/>
|
| 79 |
+
</button>
|
| 80 |
+
|
| 81 |
+
{isOpen && (
|
| 82 |
+
<div className="absolute top-full left-0 mt-2 w-64 bg-white border border-border rounded-md shadow-lg z-50">
|
| 83 |
+
<div className="p-2 border-b border-border">
|
| 84 |
+
<input
|
| 85 |
+
ref={searchInputRef}
|
| 86 |
+
type="text"
|
| 87 |
+
placeholder="Search languages..."
|
| 88 |
+
value={search}
|
| 89 |
+
onChange={(e) => setSearch(e.target.value)}
|
| 90 |
+
className="w-full px-3 py-2 text-sm border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
|
| 91 |
+
/>
|
| 92 |
+
</div>
|
| 93 |
+
|
| 94 |
+
<div className="max-h-60 overflow-auto">
|
| 95 |
+
{filteredLanguages.map((lang) => (
|
| 96 |
+
<button
|
| 97 |
+
key={lang.code}
|
| 98 |
+
onClick={() => handleSelect(lang.code)}
|
| 99 |
+
className={`w-full px-3 py-2 text-left text-sm hover:bg-muted transition-colors ${
|
| 100 |
+
value === lang.code ? "bg-primary-50 text-primary-800" : ""
|
| 101 |
+
}`}
|
| 102 |
+
>
|
| 103 |
+
{lang.name}
|
| 104 |
+
</button>
|
| 105 |
+
))}
|
| 106 |
+
{filteredLanguages.length === 0 && (
|
| 107 |
+
<div className="px-3 py-4 text-sm text-muted-foreground text-center">
|
| 108 |
+
No languages found
|
| 109 |
+
</div>
|
| 110 |
+
)}
|
| 111 |
+
</div>
|
| 112 |
+
</div>
|
| 113 |
+
)}
|
| 114 |
+
</div>
|
| 115 |
+
);
|
| 116 |
+
}
|
src/constants.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const LANGUAGES = [
|
| 2 |
+
{ code: "ar_EG", name: "Arabic (Egypt)" },
|
| 3 |
+
{ code: "ar_SA", name: "Arabic (Saudi Arabia)" },
|
| 4 |
+
{ code: "bg_BG", name: "Bulgarian" },
|
| 5 |
+
{ code: "bn_IN", name: "Bengali" },
|
| 6 |
+
{ code: "ca_ES", name: "Catalan" },
|
| 7 |
+
{ code: "cs_CZ", name: "Czech" },
|
| 8 |
+
{ code: "da_DK", name: "Danish" },
|
| 9 |
+
{ code: "de_DE", name: "German" },
|
| 10 |
+
{ code: "el_GR", name: "Greek" },
|
| 11 |
+
{ code: "en", name: "English" },
|
| 12 |
+
{ code: "es_XX", name: "Spanish" },
|
| 13 |
+
{ code: "et_EE", name: "Estonian" },
|
| 14 |
+
{ code: "fa_IR", name: "Persian" },
|
| 15 |
+
{ code: "fi_FI", name: "Finnish" },
|
| 16 |
+
{ code: "fil_PH", name: "Filipino" },
|
| 17 |
+
{ code: "fr_CA", name: "French (Canada)" },
|
| 18 |
+
{ code: "fr_FR", name: "French" },
|
| 19 |
+
{ code: "gu_IN", name: "Gujarati" },
|
| 20 |
+
{ code: "he_IL", name: "Hebrew" },
|
| 21 |
+
{ code: "hi_IN", name: "Hindi" },
|
| 22 |
+
{ code: "hr_HR", name: "Croatian" },
|
| 23 |
+
{ code: "hu_HU", name: "Hungarian" },
|
| 24 |
+
{ code: "id_ID", name: "Indonesian" },
|
| 25 |
+
{ code: "is_IS", name: "Icelandic" },
|
| 26 |
+
{ code: "it_IT", name: "Italian" },
|
| 27 |
+
{ code: "ja_JP", name: "Japanese" },
|
| 28 |
+
{ code: "kn_IN", name: "Kannada" },
|
| 29 |
+
{ code: "ko_KR", name: "Korean" },
|
| 30 |
+
{ code: "lt_LT", name: "Lithuanian" },
|
| 31 |
+
{ code: "lv_LV", name: "Latvian" },
|
| 32 |
+
{ code: "ml_IN", name: "Malayalam" },
|
| 33 |
+
{ code: "mr_IN", name: "Marathi" },
|
| 34 |
+
{ code: "nl_NL", name: "Dutch" },
|
| 35 |
+
{ code: "no_NO", name: "Norwegian" },
|
| 36 |
+
{ code: "pa_IN", name: "Punjabi" },
|
| 37 |
+
{ code: "pl_PL", name: "Polish" },
|
| 38 |
+
{ code: "pt_BR", name: "Portuguese (Brazil)" },
|
| 39 |
+
{ code: "pt_PT", name: "Portuguese (Portugal)" },
|
| 40 |
+
{ code: "ro_RO", name: "Romanian" },
|
| 41 |
+
{ code: "ru_RU", name: "Russian" },
|
| 42 |
+
{ code: "sk_SK", name: "Slovak" },
|
| 43 |
+
{ code: "sl_SI", name: "Slovenian" },
|
| 44 |
+
{ code: "sr_RS", name: "Serbian" },
|
| 45 |
+
{ code: "sv_SE", name: "Swedish" },
|
| 46 |
+
{ code: "sw_KE", name: "Swahili" },
|
| 47 |
+
{ code: "sw_TZ", name: "Swahili (Tanzania)" },
|
| 48 |
+
{ code: "ta_IN", name: "Tamil" },
|
| 49 |
+
{ code: "te_IN", name: "Telugu" },
|
| 50 |
+
{ code: "th_TH", name: "Thai" },
|
| 51 |
+
{ code: "tr_TR", name: "Turkish" },
|
| 52 |
+
{ code: "uk_UA", name: "Ukrainian" },
|
| 53 |
+
{ code: "ur_PK", name: "Urdu" },
|
| 54 |
+
{ code: "vi_VN", name: "Vietnamese" },
|
| 55 |
+
{ code: "zh_CN", name: "Chinese (Simplified)" },
|
| 56 |
+
{ code: "zh_TW", name: "Chinese (Traditional)" },
|
| 57 |
+
{ code: "zu_ZA", name: "Zulu" },
|
| 58 |
+
] as const;
|
| 59 |
+
|
| 60 |
+
export const LANGUAGES_WITH_AUTO = [
|
| 61 |
+
{
|
| 62 |
+
code: "auto",
|
| 63 |
+
name: "Auto",
|
| 64 |
+
},
|
| 65 |
+
...LANGUAGES,
|
| 66 |
+
] as const;
|
| 67 |
+
|
| 68 |
+
export type LanguageCode = (typeof LANGUAGES_WITH_AUTO)[number]["code"];
|
| 69 |
+
|
| 70 |
+
export const getLanguageName = (code: LanguageCode): string => {
|
| 71 |
+
return LANGUAGES.find((lang) => lang.code === code)?.name || code;
|
| 72 |
+
};
|
src/index.css
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import "tailwindcss";
|
| 2 |
+
|
| 3 |
+
@theme {
|
| 4 |
+
/* Custom font family - Google Sans */
|
| 5 |
+
--font-sans: "Google Sans", system-ui, -apple-system, sans-serif;
|
| 6 |
+
|
| 7 |
+
/* Google Translate inspired primary colors */
|
| 8 |
+
--color-primary-50: #e8f0fe;
|
| 9 |
+
--color-primary-100: #d3e3fd;
|
| 10 |
+
--color-primary-200: #aecbfa;
|
| 11 |
+
--color-primary-300: #8ab4f8;
|
| 12 |
+
--color-primary-400: #669df6;
|
| 13 |
+
--color-primary-500: #1a73e8; /* Main primary */
|
| 14 |
+
--color-primary-600: #1557b0;
|
| 15 |
+
--color-primary-700: #0d47a1;
|
| 16 |
+
--color-primary-800: #0842a0;
|
| 17 |
+
--color-primary-900: #0b57d0;
|
| 18 |
+
|
| 19 |
+
/* Semantic colors */
|
| 20 |
+
--color-primary: var(--color-primary-500);
|
| 21 |
+
--color-primary-foreground: #ffffff;
|
| 22 |
+
--color-secondary: oklch(95% 0.01 262);
|
| 23 |
+
--color-secondary-foreground: oklch(20% 0.05 262);
|
| 24 |
+
--color-muted: #f1f3f4;
|
| 25 |
+
--color-muted-foreground: #5f6368;
|
| 26 |
+
--color-accent: oklch(95% 0.01 220);
|
| 27 |
+
--color-accent-foreground: oklch(20% 0.05 220);
|
| 28 |
+
--color-destructive: oklch(58% 0.24 27);
|
| 29 |
+
--color-destructive-foreground: oklch(98% 0.002 27);
|
| 30 |
+
--color-border: #dadce0;
|
| 31 |
+
--color-input: oklch(90% 0.01 262);
|
| 32 |
+
--color-ring: var(--color-primary-500);
|
| 33 |
+
|
| 34 |
+
/* Font sizes - increased base size */
|
| 35 |
+
--font-size-xs: 0.8125rem; /* 13px */
|
| 36 |
+
--font-size-sm: 0.9375rem; /* 15px */
|
| 37 |
+
--font-size-base: 1.0625rem; /* 17px - increased from default 16px */
|
| 38 |
+
--font-size-lg: 1.1875rem; /* 19px */
|
| 39 |
+
--font-size-xl: 1.3125rem; /* 21px */
|
| 40 |
+
--font-size-2xl: 1.5625rem; /* 25px */
|
| 41 |
+
--font-size-3xl: 1.9375rem; /* 31px */
|
| 42 |
+
--font-size-4xl: 2.3125rem; /* 37px */
|
| 43 |
+
--font-size-5xl: 3.0625rem; /* 49px */
|
| 44 |
+
--font-size-6xl: 3.8125rem; /* 61px */
|
| 45 |
+
--font-size-7xl: 4.8125rem; /* 77px */
|
| 46 |
+
--font-size-8xl: 6.0625rem; /* 97px */
|
| 47 |
+
--font-size-9xl: 8.0625rem; /* 129px */
|
| 48 |
+
|
| 49 |
+
/* Spacing base */
|
| 50 |
+
--spacing: 0.25rem;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
body {
|
| 54 |
+
margin: 0;
|
| 55 |
+
padding: 0;
|
| 56 |
+
-webkit-font-smoothing: antialiased;
|
| 57 |
+
-moz-osx-font-smoothing: grayscale;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
* {
|
| 61 |
+
box-sizing: border-box;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
#root {
|
| 65 |
+
min-height: 100vh;
|
| 66 |
+
}
|
src/main.tsx
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { StrictMode } from "react";
|
| 2 |
+
import { createRoot } from "react-dom/client";
|
| 3 |
+
import "./index.css";
|
| 4 |
+
import App from "./App.tsx";
|
| 5 |
+
|
| 6 |
+
createRoot(document.getElementById("root")!).render(
|
| 7 |
+
<StrictMode>
|
| 8 |
+
<App />
|
| 9 |
+
</StrictMode>
|
| 10 |
+
);
|
src/theme/button/Button.tsx
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { ButtonHTMLAttributes, DetailedHTMLProps, ReactNode } from "react";
|
| 2 |
+
import { LucideIcon } from "lucide-react";
|
| 3 |
+
import cn from "../../utils/classnames.ts";
|
| 4 |
+
|
| 5 |
+
interface ButtonProps extends DetailedHTMLProps<
|
| 6 |
+
ButtonHTMLAttributes<HTMLButtonElement>,
|
| 7 |
+
HTMLButtonElement
|
| 8 |
+
> {
|
| 9 |
+
variant?:
|
| 10 |
+
| "primary"
|
| 11 |
+
| "outlined"
|
| 12 |
+
| "pill"
|
| 13 |
+
| "pill-selected"
|
| 14 |
+
| "text"
|
| 15 |
+
| "ghost";
|
| 16 |
+
icon?: LucideIcon;
|
| 17 |
+
iconPosition?: "left" | "right";
|
| 18 |
+
children?: ReactNode;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export default function Button({
|
| 22 |
+
variant = "primary",
|
| 23 |
+
icon: Icon,
|
| 24 |
+
iconPosition = "left",
|
| 25 |
+
children,
|
| 26 |
+
className = "",
|
| 27 |
+
...props
|
| 28 |
+
}: ButtonProps) {
|
| 29 |
+
const baseClasses =
|
| 30 |
+
"inline-flex items-center justify-center font-medium text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2";
|
| 31 |
+
|
| 32 |
+
const variantClasses = {
|
| 33 |
+
primary:
|
| 34 |
+
"bg-primary text-primary-foreground hover:bg-primary-600 active:bg-primary-700 rounded-md shadow-sm hover:shadow focus:ring-primary/50",
|
| 35 |
+
outlined:
|
| 36 |
+
"bg-transparent text-primary hover:bg-muted active:bg-primary-50 border border-border rounded-[48px] focus:ring-primary/30",
|
| 37 |
+
pill: "bg-transparent text-primary hover:bg-primary-50 active:bg-muted border border-border rounded-[48px] focus:ring-primary/30",
|
| 38 |
+
"pill-selected":
|
| 39 |
+
"bg-primary-100 text-primary-800 hover:bg-primary-200 active:bg-primary-200 border border-primary-100 rounded-[48px] shadow-sm focus:ring-primary-800/30",
|
| 40 |
+
text: "bg-transparent text-primary hover:bg-muted active:bg-primary-50 rounded-md focus:ring-primary/30",
|
| 41 |
+
ghost:
|
| 42 |
+
"bg-transparent text-muted-foreground hover:bg-muted hover:text-primary active:bg-primary-50 rounded-md focus:ring-primary/30",
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
const sizeClasses = {
|
| 46 |
+
primary: "[padding:0.625em_1.5em] [gap:0.5em]",
|
| 47 |
+
outlined: "[padding:0.5em_0.75em] [gap:0.5em]",
|
| 48 |
+
pill: "[padding:0.5em_0.75em] [gap:0.5em]",
|
| 49 |
+
"pill-selected": "[padding:0.5em_0.75em] [gap:0.5em]",
|
| 50 |
+
text: "[padding:0.375em_0.5em] [gap:0.5em]",
|
| 51 |
+
ghost: "[padding:0.5em_0.75em] [gap:0.5em]",
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
const disabledClasses =
|
| 55 |
+
"disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-current disabled:hover:shadow-none";
|
| 56 |
+
|
| 57 |
+
return (
|
| 58 |
+
<button
|
| 59 |
+
className={cn(
|
| 60 |
+
"cursor-pointer",
|
| 61 |
+
baseClasses,
|
| 62 |
+
variantClasses[variant],
|
| 63 |
+
sizeClasses[variant],
|
| 64 |
+
disabledClasses,
|
| 65 |
+
className
|
| 66 |
+
)}
|
| 67 |
+
{...props}
|
| 68 |
+
>
|
| 69 |
+
{Icon && iconPosition === "left" && (
|
| 70 |
+
<Icon className="[width:1.25em] [height:1.25em]" />
|
| 71 |
+
)}
|
| 72 |
+
{children}
|
| 73 |
+
{Icon && iconPosition === "right" && (
|
| 74 |
+
<Icon className="[width:1.25em] [height:1.25em]" />
|
| 75 |
+
)}
|
| 76 |
+
</button>
|
| 77 |
+
);
|
| 78 |
+
}
|
src/theme/form/Textarea.tsx
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { DetailedHTMLProps, TextareaHTMLAttributes, useMemo } from "react";
|
| 2 |
+
import cn from "../../utils/classnames";
|
| 3 |
+
|
| 4 |
+
interface TextareaProps extends DetailedHTMLProps<
|
| 5 |
+
TextareaHTMLAttributes<HTMLTextAreaElement>,
|
| 6 |
+
HTMLTextAreaElement
|
| 7 |
+
> {
|
| 8 |
+
variant?: "default" | "minimal";
|
| 9 |
+
error?: boolean;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export default function Textarea({
|
| 13 |
+
variant = "default",
|
| 14 |
+
error = false,
|
| 15 |
+
className = "",
|
| 16 |
+
value,
|
| 17 |
+
...props
|
| 18 |
+
}: TextareaProps) {
|
| 19 |
+
const baseClasses =
|
| 20 |
+
"w-full font-sans transition-all duration-200 resize-none focus:outline-none";
|
| 21 |
+
|
| 22 |
+
const variantClasses = {
|
| 23 |
+
default:
|
| 24 |
+
"px-4 py-3 border border-border rounded-md bg-white hover:border-primary-300 focus:border-primary focus:ring-2 focus:ring-primary/20",
|
| 25 |
+
minimal:
|
| 26 |
+
"px-4 py-3 border-0 bg-transparent hover:bg-muted/30 focus:bg-white focus:shadow-sm rounded-md",
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
const errorClasses = error
|
| 30 |
+
? "border-destructive focus:border-destructive focus:ring-destructive/20"
|
| 31 |
+
: "";
|
| 32 |
+
|
| 33 |
+
const disabledClasses = "disabled:cursor-default";
|
| 34 |
+
|
| 35 |
+
const fontSizeClass = useMemo(() => {
|
| 36 |
+
const length = typeof value === "string" ? value.length : 0;
|
| 37 |
+
console.log(length);
|
| 38 |
+
|
| 39 |
+
if (length === 0) return "text-2xl";
|
| 40 |
+
if (length < 50) return "text-2xl";
|
| 41 |
+
if (length < 200) return "text-xl";
|
| 42 |
+
if (length < 300) return "text-lg";
|
| 43 |
+
return "text-base";
|
| 44 |
+
}, [value]);
|
| 45 |
+
|
| 46 |
+
return (
|
| 47 |
+
<textarea
|
| 48 |
+
className={cn(
|
| 49 |
+
baseClasses,
|
| 50 |
+
variantClasses[variant],
|
| 51 |
+
errorClasses,
|
| 52 |
+
disabledClasses,
|
| 53 |
+
fontSizeClass,
|
| 54 |
+
className
|
| 55 |
+
)}
|
| 56 |
+
value={value}
|
| 57 |
+
{...props}
|
| 58 |
+
/>
|
| 59 |
+
);
|
| 60 |
+
}
|
src/theme/index.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export { default as Button } from "./button/Button";
|
| 2 |
+
export { default as Textarea } from "./form/Textarea";
|
| 3 |
+
export { default as Loader } from "./misc/Loader";
|
src/theme/misc/Loader.tsx
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Loader2 } from "lucide-react";
|
| 2 |
+
|
| 3 |
+
interface LoaderProps {
|
| 4 |
+
size?: number;
|
| 5 |
+
className?: string;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
export default function Loader({ size = 20, className = "" }: LoaderProps) {
|
| 9 |
+
return (
|
| 10 |
+
<Loader2
|
| 11 |
+
className={`animate-spin text-primary ${className}`}
|
| 12 |
+
style={{ width: size, height: size }}
|
| 13 |
+
/>
|
| 14 |
+
);
|
| 15 |
+
}
|
src/types/transformers.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export type InitiateProgressInfo = {
|
| 2 |
+
status: "initiate";
|
| 3 |
+
/**
|
| 4 |
+
* The model id or directory path.
|
| 5 |
+
*/
|
| 6 |
+
name: string;
|
| 7 |
+
/**
|
| 8 |
+
* The name of the file.
|
| 9 |
+
*/
|
| 10 |
+
file: string;
|
| 11 |
+
};
|
| 12 |
+
export type DownloadProgressInfo = {
|
| 13 |
+
status: "download";
|
| 14 |
+
/**
|
| 15 |
+
* The model id or directory path.
|
| 16 |
+
*/
|
| 17 |
+
name: string;
|
| 18 |
+
/**
|
| 19 |
+
* The name of the file.
|
| 20 |
+
*/
|
| 21 |
+
file: string;
|
| 22 |
+
};
|
| 23 |
+
export type ProgressStatusInfo = {
|
| 24 |
+
status: "progress";
|
| 25 |
+
/**
|
| 26 |
+
* The model id or directory path.
|
| 27 |
+
*/
|
| 28 |
+
name: string;
|
| 29 |
+
/**
|
| 30 |
+
* The name of the file.
|
| 31 |
+
*/
|
| 32 |
+
file: string;
|
| 33 |
+
/**
|
| 34 |
+
* A number between 0 and 100.
|
| 35 |
+
*/
|
| 36 |
+
progress: number;
|
| 37 |
+
/**
|
| 38 |
+
* The number of bytes loaded.
|
| 39 |
+
*/
|
| 40 |
+
loaded: number;
|
| 41 |
+
/**
|
| 42 |
+
* The total number of bytes to be loaded.
|
| 43 |
+
*/
|
| 44 |
+
total: number;
|
| 45 |
+
};
|
| 46 |
+
export type DoneProgressInfo = {
|
| 47 |
+
status: "done";
|
| 48 |
+
/**
|
| 49 |
+
* The model id or directory path.
|
| 50 |
+
*/
|
| 51 |
+
name: string;
|
| 52 |
+
/**
|
| 53 |
+
* The name of the file.
|
| 54 |
+
*/
|
| 55 |
+
file: string;
|
| 56 |
+
};
|
| 57 |
+
export type ReadyProgressInfo = {
|
| 58 |
+
status: "ready";
|
| 59 |
+
/**
|
| 60 |
+
* The loaded task.
|
| 61 |
+
*/
|
| 62 |
+
task: string;
|
| 63 |
+
/**
|
| 64 |
+
* The loaded model.
|
| 65 |
+
*/
|
| 66 |
+
model: string;
|
| 67 |
+
};
|
| 68 |
+
export type ProgressInfo =
|
| 69 |
+
| InitiateProgressInfo
|
| 70 |
+
| DownloadProgressInfo
|
| 71 |
+
| ProgressStatusInfo
|
| 72 |
+
| DoneProgressInfo
|
| 73 |
+
| ReadyProgressInfo;
|
| 74 |
+
/**
|
| 75 |
+
* A callback function that is called with progress information.
|
| 76 |
+
*/
|
| 77 |
+
export type ProgressCallback = (progressInfo: ProgressInfo) => void;
|
src/utils/classnames.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const cn = (...classes: Array<Record<string, boolean> | string>): string =>
|
| 2 |
+
classes
|
| 3 |
+
.map((entry) =>
|
| 4 |
+
typeof entry === "string"
|
| 5 |
+
? entry
|
| 6 |
+
: Object.entries(entry || {})
|
| 7 |
+
.filter(([, append]) => append)
|
| 8 |
+
.map(([cl]) => cl)
|
| 9 |
+
.join(" ")
|
| 10 |
+
)
|
| 11 |
+
.filter((e) => e !== "")
|
| 12 |
+
.join(" ");
|
| 13 |
+
export default cn;
|
src/utils/countWords.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export function countWords(text: string): number {
|
| 2 |
+
if (!text || text.trim().length === 0) {
|
| 3 |
+
return 0;
|
| 4 |
+
}
|
| 5 |
+
|
| 6 |
+
// Remove extra whitespace and split by whitespace
|
| 7 |
+
const words = text
|
| 8 |
+
.trim()
|
| 9 |
+
.split(/\s+/)
|
| 10 |
+
.filter((word) => word.length > 0);
|
| 11 |
+
|
| 12 |
+
return words.length;
|
| 13 |
+
}
|
src/utils/format.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export function formatBytes(bytes: number, decimals: number = 1): string {
|
| 2 |
+
if (bytes === 0) return "0 Bytes";
|
| 3 |
+
|
| 4 |
+
const k = 1024;
|
| 5 |
+
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB"];
|
| 6 |
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
| 7 |
+
|
| 8 |
+
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(decimals))}${sizes[i]}`;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export function formatTime(ms: number): string {
|
| 12 |
+
if (ms < 1000) {
|
| 13 |
+
return `${Math.round(ms)}ms`;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
const seconds = ms / 1000;
|
| 17 |
+
if (seconds < 60) {
|
| 18 |
+
return `${seconds.toFixed(1)}s`;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
const minutes = Math.floor(seconds / 60);
|
| 22 |
+
const remainingSeconds = Math.round(seconds % 60);
|
| 23 |
+
return `${minutes}m ${remainingSeconds}s`;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
export function formatNumber(num: number): string {
|
| 27 |
+
return new Intl.NumberFormat("en-US").format(num);
|
| 28 |
+
}
|
src/vite-env.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
/// <reference types="vite/client" />
|
tsconfig.app.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ES2020",
|
| 4 |
+
"useDefineForClassFields": true,
|
| 5 |
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
| 6 |
+
"module": "ESNext",
|
| 7 |
+
"skipLibCheck": true,
|
| 8 |
+
|
| 9 |
+
/* Bundler mode */
|
| 10 |
+
"moduleResolution": "Bundler",
|
| 11 |
+
"allowImportingTsExtensions": true,
|
| 12 |
+
"isolatedModules": true,
|
| 13 |
+
"moduleDetection": "force",
|
| 14 |
+
"noEmit": true,
|
| 15 |
+
"jsx": "react-jsx",
|
| 16 |
+
|
| 17 |
+
/* Linting */
|
| 18 |
+
"strict": true,
|
| 19 |
+
"noUnusedLocals": true,
|
| 20 |
+
"noUnusedParameters": true,
|
| 21 |
+
"noFallthroughCasesInSwitch": true,
|
| 22 |
+
"noUncheckedSideEffectImports": true,
|
| 23 |
+
"strictNullChecks": false
|
| 24 |
+
},
|
| 25 |
+
"include": ["src"]
|
| 26 |
+
}
|
tsconfig.app.tsbuildinfo
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{"root":["./src/app.tsx","./src/initialize.tsx","./src/translate.tsx","./src/constants.ts","./src/main.tsx","./src/vite-env.d.ts","./src/ai/translator.ts","./src/components/languageselector.tsx","./src/theme/index.ts","./src/theme/button/button.tsx","./src/theme/form/textarea.tsx","./src/theme/misc/loader.tsx","./src/types/transformers.ts","./src/utils/classnames.ts","./src/utils/countwords.ts","./src/utils/format.ts"],"version":"5.7.3"}
|
tsconfig.json
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"files": [],
|
| 3 |
+
"references": [
|
| 4 |
+
{ "path": "./tsconfig.app.json" },
|
| 5 |
+
{ "path": "./tsconfig.node.json" }
|
| 6 |
+
]
|
| 7 |
+
}
|
tsconfig.node.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ES2022",
|
| 4 |
+
"lib": ["ES2023"],
|
| 5 |
+
"module": "ESNext",
|
| 6 |
+
"skipLibCheck": true,
|
| 7 |
+
|
| 8 |
+
/* Bundler mode */
|
| 9 |
+
"moduleResolution": "Bundler",
|
| 10 |
+
"allowImportingTsExtensions": true,
|
| 11 |
+
"isolatedModules": true,
|
| 12 |
+
"moduleDetection": "force",
|
| 13 |
+
"noEmit": true,
|
| 14 |
+
|
| 15 |
+
/* Linting */
|
| 16 |
+
"strict": true,
|
| 17 |
+
"noUnusedLocals": true,
|
| 18 |
+
"noUnusedParameters": true,
|
| 19 |
+
"noFallthroughCasesInSwitch": true,
|
| 20 |
+
"noUncheckedSideEffectImports": true
|
| 21 |
+
},
|
| 22 |
+
"include": ["vite.config.ts"]
|
| 23 |
+
}
|
tsconfig.node.tsbuildinfo
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{"root":["./vite.config.ts"],"version":"5.7.3"}
|
vite.config.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from 'vite'
|
| 2 |
+
import react from '@vitejs/plugin-react'
|
| 3 |
+
import tailwindcss from '@tailwindcss/vite'
|
| 4 |
+
|
| 5 |
+
// https://vite.dev/config/
|
| 6 |
+
export default defineConfig({
|
| 7 |
+
plugins: [react(), tailwindcss()],
|
| 8 |
+
})
|