diff --git a/.github/workflows/deploy-nutq.yml b/.github/workflows/deploy-nutq.yml
new file mode 100644
index 00000000..efce6c5a
--- /dev/null
+++ b/.github/workflows/deploy-nutq.yml
@@ -0,0 +1,31 @@
+name: Deploy Nutq to GitHub Pages
+
+on:
+ push:
+ branches: [main, claude/mobile-desktop-app-y1g2sm]
+ paths: ["nutq/**", ".github/workflows/deploy-nutq.yml"]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: pages
+ cancel-in-progress: true
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/configure-pages@v5
+ - uses: actions/upload-pages-artifact@v3
+ with:
+ path: nutq
+ - id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/nutq/README.md b/nutq/README.md
new file mode 100644
index 00000000..343cdf78
--- /dev/null
+++ b/nutq/README.md
@@ -0,0 +1,73 @@
+# نُطق (Nutq) — لوحة المفاتيح الصوتية بالذكاء الاصطناعي 🎙️
+
+**تكلّم بشكل طبيعي، ونُطق يكتب نصاً مصقولاً جاهزاً للإرسال.**
+
+تطبيق ويب تقدمي (PWA) احترافي مستوحى من Wispr Flow — يعمل على **iPhone و Android والحاسوب (Windows / Mac / Linux)** من كود واحد، ويُثبَّت كتطبيق حقيقي بأيقونة على الشاشة الرئيسية وسطح المكتب.
+
+
+
+## المميزات
+
+- 🎙️ **إملاء صوتي فوري** — النص يظهر مباشرة أثناء كلامك (محرك المتصفح المجاني)، أو عبر Whisper API لدقة أعلى.
+- ✨ **صقل بالذكاء الاصطناعي** — إزالة كلمات الحشو، تصحيح علامات الترقيم، وإعادة صياغة بأسلوبك.
+- 📝 **أنماط كتابة** — رسالة، بريد إلكتروني، ملاحظات، رسمي، أو نص خام بلا تعديل.
+- 🌍 **أكثر من 100 لغة** — منها العربية بلهجاتها، مع اكتشاف تلقائي للغة.
+- 📖 **قاموس شخصي** — أضف أسماءً ومصطلحات خاصة ليحترمها التطبيق عند الكتابة.
+- 🕘 **سجل محلي** — كل إملاءاتك محفوظة على جهازك فقط (لا تُرسل لأي خادم).
+- 🌗 **واجهة عربية RTL** كاملة + إنجليزية، مع مظهر فاتح وداكن.
+- 📵 **يعمل دون إنترنت** — واجهة التطبيق تعمل offline بعد أول زيارة.
+
+## التشغيل والنشر
+
+### 1) تجربة محلية سريعة
+
+```bash
+cd nutq
+python3 -m http.server 8080
+# افتح http://localhost:8080
+```
+
+> ملاحظة: الميكروفون يتطلب HTTPS أو localhost.
+
+### 2) النشر المجاني على GitHub Pages (موصى به)
+
+المستودع يتضمن Workflow جاهزاً (`.github/workflows/deploy-nutq.yml`):
+
+1. من إعدادات المستودع على GitHub: **Settings → Pages → Source: GitHub Actions**.
+2. ادفع أي تعديل لمجلد `nutq/` وسيُنشر تلقائياً.
+3. سيكون التطبيق على: `https://<اسم-المستخدم>.github.io/<اسم-المستودع>/`
+
+يمكن أيضاً النشر على Netlify أو Vercel أو Cloudflare Pages بسحب مجلد `nutq/` فقط.
+
+### 3) التثبيت كتطبيق
+
+- **iPhone (Safari):** افتح الرابط → زر المشاركة → **إضافة إلى الشاشة الرئيسية**.
+- **Android (Chrome):** افتح الرابط → قائمة ⋮ → **تثبيت التطبيق**.
+- **الحاسوب (Chrome / Edge):** افتح الرابط → أيقونة التثبيت ⊕ في شريط العنوان → **تثبيت**. يصبح تطبيقاً مستقلاً بنافذته الخاصة.
+
+## إعداد الذكاء الاصطناعي (اختياري لكن موصى به)
+
+بدون مفتاح API يعمل الإملاء الفوري مجاناً عبر محرك المتصفح. لتفعيل **الصقل الذكي** و **Whisper**:
+
+1. افتح **الإعدادات ⚙️** داخل التطبيق.
+2. أدخل مفتاح API من أي خدمة متوافقة مع OpenAI:
+
+| الخدمة | Base URL | ملاحظات |
+|---|---|---|
+| OpenAI | `https://api.openai.com/v1` | `gpt-4o-mini` + `whisper-1` |
+| Groq (مجاني) | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` + `whisper-large-v3` |
+| OpenRouter | `https://openrouter.ai/api/v1` | نماذج متعددة |
+
+> 🔒 **الخصوصية:** المفتاح والسجل والقاموس تُحفظ في متصفحك فقط (localStorage)، ولا يمر أي شيء عبر خوادم وسيطة — الاتصال مباشر من جهازك إلى مزوّد الخدمة الذي تختاره.
+
+## البنية التقنية
+
+- HTML/CSS/JS خالص بدون أي اعتماديات أو خطوة بناء — سهل الصيانة والنشر في أي مكان.
+- `Web Speech API` للإملاء الفوري، و`MediaRecorder` + Whisper للتفريغ عالي الدقة.
+- Service Worker للعمل دون اتصال + Web App Manifest للتثبيت.
+
+---
+
+# Nutq — AI Voice Keyboard (English)
+
+A professional Wispr Flow–style PWA: speak naturally and get polished, ready-to-send text. Installs as a real app on iPhone, Android, and desktop from a single codebase. Live dictation via the free browser speech engine or any OpenAI-compatible Whisper endpoint, AI polishing with writing modes (message / email / notes / formal), 100+ languages with auto-detect, a personal dictionary, local-only history, full RTL Arabic + English UI, and offline support. Serve the `nutq/` folder from any static host (a GitHub Pages workflow is included) and add your API key in Settings to enable AI features.
diff --git a/nutq/app.css b/nutq/app.css
new file mode 100644
index 00000000..5e55670c
--- /dev/null
+++ b/nutq/app.css
@@ -0,0 +1,288 @@
+/* ===== Nutq — AI voice keyboard ===== */
+:root {
+ --bg: #FAF6E9;
+ --bg-soft: #F3EDDA;
+ --card: #FFFDF4;
+ --ink: #17151A;
+ --ink-soft: #5C5850;
+ --line: rgba(23, 21, 26, .1);
+ --accent: #6D4FE0;
+ --accent-soft: #E9DFFF;
+ --accent-ink: #4A32AC;
+ --danger: #C43D3D;
+ --shadow: 0 10px 30px rgba(23, 21, 26, .08);
+ --radius: 22px;
+ --serif: "Noto Naskh Arabic", "Amiri", Georgia, "Times New Roman", serif;
+ --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Tahoma, "Helvetica Neue", Arial, sans-serif;
+}
+[data-theme="dark"] {
+ --bg: #111014;
+ --bg-soft: #1A181F;
+ --card: #1E1C24;
+ --ink: #F3F0E8;
+ --ink-soft: #A5A09A;
+ --line: rgba(243, 240, 232, .12);
+ --accent: #8F75FF;
+ --accent-soft: #2C2542;
+ --accent-ink: #C9BAFF;
+ --shadow: 0 10px 30px rgba(0, 0, 0, .4);
+}
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme="light"]) {
+ --bg: #111014; --bg-soft: #1A181F; --card: #1E1C24;
+ --ink: #F3F0E8; --ink-soft: #A5A09A; --line: rgba(243,240,232,.12);
+ --accent: #8F75FF; --accent-soft: #2C2542; --accent-ink: #C9BAFF;
+ --shadow: 0 10px 30px rgba(0,0,0,.4);
+ }
+}
+
+* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
+[hidden] { display: none !important; }
+html, body { margin: 0; padding: 0; }
+body {
+ background: var(--bg);
+ color: var(--ink);
+ font-family: var(--sans);
+ min-height: 100dvh;
+ padding-bottom: calc(190px + env(safe-area-inset-bottom));
+ transition: background .3s, color .3s;
+}
+
+svg { width: 22px; height: 22px; fill: currentColor; }
+
+/* ===== Top bar ===== */
+.topbar {
+ position: sticky; top: 0; z-index: 20;
+ display: flex; align-items: center; justify-content: space-between;
+ padding: calc(10px + env(safe-area-inset-top)) 18px 10px;
+ background: color-mix(in srgb, var(--bg) 82%, transparent);
+ backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
+}
+.brand { display: flex; align-items: center; gap: 9px; }
+.brand-mark { width: 26px; height: 26px; fill: var(--ink); }
+.brand-name { font-family: var(--serif); font-size: 24px; font-weight: 700; }
+.topbar-actions { display: flex; gap: 8px; }
+.icon-btn {
+ width: 42px; height: 42px; border-radius: 50%;
+ border: 1px solid var(--line); background: var(--card); color: var(--ink);
+ display: grid; place-items: center; cursor: pointer;
+ transition: transform .15s, background .2s;
+}
+.icon-btn:active { transform: scale(.92); }
+
+/* ===== Main ===== */
+.app { max-width: 680px; margin: 0 auto; padding: 8px 18px 0; }
+
+.hero { text-align: center; margin: 18px 0 22px; }
+.hero-title {
+ font-family: var(--serif);
+ font-size: clamp(34px, 9vw, 52px);
+ line-height: 1.15; margin: 0 0 12px; font-weight: 700;
+}
+.hero-sub { color: var(--ink-soft); font-size: 16px; line-height: 1.65; margin: 0 auto; max-width: 46ch; }
+.hero.compact { margin: 6px 0 10px; }
+.hero.compact .hero-title { font-size: 24px; }
+.hero.compact .hero-sub { display: none; }
+
+/* ===== Mode chips ===== */
+.modes {
+ display: flex; gap: 8px; overflow-x: auto; padding: 4px 2px 10px;
+ scrollbar-width: none; -ms-overflow-style: none;
+}
+.modes::-webkit-scrollbar { display: none; }
+.chip {
+ flex: 0 0 auto; padding: 9px 18px; border-radius: 999px;
+ border: 1px solid var(--line); background: var(--card); color: var(--ink);
+ font-size: 14.5px; font-weight: 600; cursor: pointer; font-family: var(--sans);
+ transition: background .2s, color .2s, border-color .2s;
+}
+.chip.active { background: var(--ink); color: var(--bg); border-color: var(--ink); }
+
+/* ===== Language pill ===== */
+.lang-pill {
+ display: inline-flex; align-items: center; gap: 7px;
+ margin: 2px 0 14px; padding: 8px 14px; border-radius: 999px;
+ border: 1px solid var(--line); background: var(--accent-soft); color: var(--accent-ink);
+ font-size: 14px; font-weight: 600; cursor: pointer; font-family: var(--sans);
+}
+.lang-pill svg { width: 17px; height: 17px; }
+
+/* ===== Cards ===== */
+.card {
+ background: var(--card); border: 1px solid var(--line);
+ border-radius: var(--radius); padding: 18px; margin-bottom: 14px;
+ box-shadow: var(--shadow);
+}
+.card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
+.card-title { font-size: 13px; font-weight: 700; letter-spacing: .3px; color: var(--ink-soft); }
+.badge {
+ background: var(--accent-soft); color: var(--accent-ink);
+ padding: 3px 11px; border-radius: 999px; font-size: 12px; font-weight: 700;
+}
+.live-dot { width: 10px; height: 10px; border-radius: 50%; background: #E0483F; animation: blink 1.1s infinite; }
+@keyframes blink { 50% { opacity: .25; } }
+
+.transcript, .result {
+ min-height: 54px; font-size: 17px; line-height: 1.75; outline: none;
+ white-space: pre-wrap; word-break: break-word;
+}
+.transcript:empty::before, .result:empty::before {
+ content: attr(data-placeholder); color: var(--ink-soft); opacity: .6;
+}
+.transcript .interim { color: var(--ink-soft); opacity: .75; }
+.result-card { border-color: color-mix(in srgb, var(--accent) 35%, var(--line)); }
+
+.card-actions { display: flex; gap: 8px; margin-top: 14px; flex-wrap: wrap; }
+.btn {
+ display: inline-flex; align-items: center; gap: 7px;
+ padding: 10px 16px; border-radius: 14px; cursor: pointer;
+ border: 1px solid var(--line); background: var(--bg-soft); color: var(--ink);
+ font-size: 14px; font-weight: 700; font-family: var(--sans);
+ transition: transform .15s, opacity .2s;
+}
+.btn:active { transform: scale(.96); }
+.btn svg { width: 17px; height: 17px; }
+.btn.primary { background: var(--ink); color: var(--bg); border-color: var(--ink); }
+.btn.danger { background: transparent; color: var(--danger); border-color: color-mix(in srgb, var(--danger) 40%, transparent); }
+.btn.small { padding: 7px 13px; font-size: 13px; }
+.btn.wide { width: 100%; justify-content: center; padding: 14px; font-size: 16px; margin-top: 6px; }
+
+.status { text-align: center; color: var(--ink-soft); font-size: 14px; min-height: 22px; margin: 4px 0; }
+.status.error { color: var(--danger); font-weight: 600; }
+
+/* ===== Features ===== */
+.features { display: grid; gap: 12px; margin-top: 10px; }
+.feature {
+ display: flex; gap: 13px; align-items: flex-start;
+ background: var(--card); border: 1px solid var(--line);
+ border-radius: 18px; padding: 15px 16px;
+}
+.f-ico { font-size: 22px; line-height: 1.3; }
+.feature b { font-size: 15.5px; }
+.feature p { margin: 3px 0 0; color: var(--ink-soft); font-size: 14px; line-height: 1.6; }
+
+/* ===== Mic dock ===== */
+.mic-dock {
+ position: fixed; bottom: 0; left: 0; right: 0; z-index: 30;
+ display: flex; flex-direction: column; align-items: center; gap: 8px;
+ padding: 14px 0 calc(18px + env(safe-area-inset-bottom));
+ background: linear-gradient(to top, var(--bg) 62%, transparent);
+ pointer-events: none;
+}
+.mic-dock > * { pointer-events: auto; }
+.mic-btn {
+ position: relative; width: 84px; height: 84px; border-radius: 50%;
+ border: none; cursor: pointer;
+ background: var(--ink); color: var(--bg);
+ display: grid; place-items: center;
+ box-shadow: 0 14px 34px rgba(0,0,0,.25);
+ transition: transform .15s, background .25s;
+}
+.mic-btn:active { transform: scale(.93); }
+.mic-btn svg { width: 34px; height: 34px; }
+.mic-btn.rec { background: #E0483F; color: #fff; }
+.mic-ring {
+ position: absolute; inset: 0; border-radius: 50%;
+ border: 2px solid #E0483F; opacity: 0; pointer-events: none;
+}
+.mic-btn.rec .mic-ring { animation: ripple 1.8s infinite; }
+.mic-btn.rec .r2 { animation-delay: .9s; }
+@keyframes ripple {
+ 0% { transform: scale(1); opacity: .7; }
+ 100% { transform: scale(1.85); opacity: 0; }
+}
+.mic-hint { font-size: 13.5px; color: var(--ink-soft); font-weight: 600; }
+.timer { font-variant-numeric: tabular-nums; font-weight: 700; font-size: 15px; color: #E0483F; }
+
+.waveform { display: flex; align-items: center; gap: 3px; height: 34px; }
+.waveform span {
+ width: 4px; height: 8px; border-radius: 3px; background: var(--accent);
+ animation: wave 1s ease-in-out infinite;
+}
+.waveform span:nth-child(3n) { animation-delay: .15s; }
+.waveform span:nth-child(3n+1){ animation-delay: .35s; }
+.waveform span:nth-child(4n) { animation-delay: .5s; }
+.waveform span:nth-child(5n) { animation-delay: .7s; }
+@keyframes wave { 0%,100% { height: 7px; } 50% { height: 30px; } }
+
+/* ===== Sheets ===== */
+.sheet-backdrop {
+ position: fixed; inset: 0; z-index: 50;
+ background: rgba(0,0,0,.45);
+ display: flex; align-items: flex-end; justify-content: center;
+ animation: fadeIn .2s;
+}
+@keyframes fadeIn { from { opacity: 0; } }
+.sheet {
+ background: var(--bg); width: 100%; max-width: 680px;
+ max-height: 88dvh; overflow-y: auto;
+ border-radius: 26px 26px 0 0; padding: 10px 20px calc(26px + env(safe-area-inset-bottom));
+ animation: slideUp .28s cubic-bezier(.2,.9,.3,1);
+}
+@keyframes slideUp { from { transform: translateY(60px); opacity: .4; } }
+.sheet-grab { width: 42px; height: 5px; border-radius: 3px; background: var(--line); margin: 6px auto 14px; }
+.sheet h2 { font-family: var(--serif); font-size: 24px; margin: 4px 0 16px; }
+.sheet-title-row { display: flex; align-items: center; justify-content: space-between; }
+.sheet-title-row h2 { margin: 4px 0 12px; }
+
+.search {
+ width: 100%; padding: 12px 16px; border-radius: 14px;
+ border: 1px solid var(--line); background: var(--card); color: var(--ink);
+ font-size: 16px; margin-bottom: 12px; font-family: var(--sans);
+}
+.lang-list { display: flex; flex-direction: column; }
+.lang-item {
+ display: flex; align-items: center; justify-content: space-between;
+ padding: 13px 10px; border-radius: 14px; cursor: pointer;
+ font-size: 16px; border: none; background: none; color: var(--ink);
+ text-align: start; font-family: var(--sans); width: 100%;
+}
+.lang-item .flag { margin-inline-end: 10px; }
+.lang-item.selected { background: var(--accent-soft); color: var(--accent-ink); font-weight: 700; }
+.lang-item.selected::after { content: "✓"; font-weight: 900; }
+
+/* ===== History ===== */
+.history-list { display: flex; flex-direction: column; gap: 10px; }
+.history-item {
+ background: var(--card); border: 1px solid var(--line);
+ border-radius: 16px; padding: 14px;
+}
+.history-item .h-meta { display: flex; justify-content: space-between; font-size: 12px; color: var(--ink-soft); margin-bottom: 7px; }
+.history-item .h-text { font-size: 15px; line-height: 1.65; white-space: pre-wrap; word-break: break-word; }
+.history-item .h-actions { display: flex; gap: 6px; margin-top: 10px; }
+.empty { text-align: center; color: var(--ink-soft); padding: 34px 0; font-size: 15px; }
+
+/* ===== Settings fields ===== */
+.field { display: flex; flex-direction: column; gap: 7px; margin-bottom: 15px; }
+.field > span { font-size: 13.5px; font-weight: 700; color: var(--ink-soft); }
+.field input[type="text"], .field input[type="password"], .field input[type="url"],
+.field select, .field textarea {
+ padding: 12px 14px; border-radius: 13px; border: 1px solid var(--line);
+ background: var(--card); color: var(--ink); font-size: 16px; font-family: var(--sans);
+ width: 100%;
+}
+.field.row { flex-direction: row; align-items: center; justify-content: space-between; }
+.field.row input[type="checkbox"] { width: 22px; height: 22px; accent-color: var(--accent); }
+.field-group {
+ border: 1px dashed var(--line); border-radius: 16px;
+ padding: 14px; margin-bottom: 15px;
+}
+.fg-title { font-size: 13.5px; font-weight: 800; margin-bottom: 12px; color: var(--accent-ink); }
+.hint { font-size: 12.5px; color: var(--ink-soft); line-height: 1.6; margin: 4px 0 0; }
+.hint.center { text-align: center; margin-top: 14px; }
+
+/* ===== Toast ===== */
+.toast {
+ position: fixed; bottom: calc(200px + env(safe-area-inset-bottom));
+ left: 50%; transform: translateX(-50%); z-index: 60;
+ background: var(--ink); color: var(--bg);
+ padding: 11px 20px; border-radius: 999px; font-size: 14.5px; font-weight: 700;
+ box-shadow: var(--shadow); animation: toastIn .25s;
+ max-width: 88vw; text-align: center;
+}
+@keyframes toastIn { from { transform: translate(-50%, 14px); opacity: 0; } }
+
+@media (min-width: 720px) {
+ .hero-title { font-size: 56px; }
+ body { padding-bottom: 210px; }
+}
diff --git a/nutq/app.js b/nutq/app.js
new file mode 100644
index 00000000..e8b6c77c
--- /dev/null
+++ b/nutq/app.js
@@ -0,0 +1,586 @@
+/* ===== Nutq — AI voice keyboard =====
+ * Speak naturally → get polished, ready-to-send text.
+ * Engines: Web Speech API (free, live) or any OpenAI-compatible
+ * Whisper endpoint. Polishing via OpenAI-compatible chat API.
+ * Everything is stored locally on the device.
+ */
+"use strict";
+
+/* ---------- i18n ---------- */
+const I18N = {
+ ar: {
+ appName: "نُطق",
+ heroTitle: "إملاء صوتي
بلا أخطاء",
+ heroSub: "تكلّم بشكل طبيعي، ونُطق يحوّل كلامك إلى نص مصقول جاهز للإرسال — في أي تطبيق وبأكثر من 100 لغة.",
+ modeMessage: "رسالة", modeEmail: "بريد إلكتروني", modeNotes: "ملاحظات", modeFormal: "رسمي", modeRaw: "نص خام",
+ rawTranscript: "النص المسموع", polished: "✨ النص المصقول",
+ transcriptPh: "سيظهر كلامك هنا…",
+ copy: "نسخ", copied: "تم النسخ ✓", share: "مشاركة", repolish: "إعادة الصقل",
+ tapToTalk: "اضغط وتكلّم", tapToStop: "اضغط للإيقاف",
+ listening: "أستمع إليك…", transcribing: "جارٍ تفريغ الصوت…", polishing: "جارٍ صقل النص…",
+ history: "السجل", clearAll: "مسح الكل", settings: "الإعدادات",
+ uiLang: "لغة الواجهة", engine: "محرك التعرّف على الصوت",
+ engineAuto: "تلقائي (الأفضل المتاح)", engineWeb: "المتصفح (مجاني، فوري)", engineWhisper: "Whisper عبر API (أدق)",
+ aiSection: "إعدادات الذكاء الاصطناعي (للصقل و Whisper)",
+ apiKey: "مفتاح API", baseUrl: "عنوان الخدمة (Base URL)", model: "نموذج الصقل", whisperModel: "نموذج التفريغ الصوتي",
+ apiHint: "يدعم أي خدمة متوافقة مع OpenAI: OpenAI، Groq (مجاني)، OpenRouter وغيرها. المفتاح يُحفظ على جهازك فقط.",
+ dictionary: "القاموس الشخصي (أسماء ومصطلحات، افصل بينها بفواصل)",
+ dictPh: "مثال: جلال، شركة لُمى، GPT-SoVITS",
+ autoPolish: "صقل تلقائي بعد التوقف",
+ theme: "المظهر", themeAuto: "تلقائي", themeLight: "فاتح", themeDark: "داكن",
+ save: "حفظ", saved: "تم الحفظ ✓",
+ searchLang: "ابحث عن لغة…", autoDetect: "اكتشاف تلقائي",
+ f1t: "يعمل مع كل التطبيقات", f1d: "أملِ النص ثم انسخه إلى واتساب، البريد، أو أي تطبيق آخر.",
+ f2t: "صقل بالذكاء الاصطناعي", f2d: "بلا كلمات حشو ولا أخطاء — نص بأسلوبك، جاهز للإرسال.",
+ f3t: "أكثر من 100 لغة", f3d: "العربية، الإنجليزية، الفرنسية وغيرها — مع اكتشاف تلقائي.",
+ f4t: "قاموسك الشخصي", f4d: "أضف أسماءً ومصطلحات خاصة ليتعلم نُطق أسلوبك.",
+ errNoMic: "تعذّر الوصول إلى الميكروفون. تحقّق من الأذونات.",
+ errNoSpeech: "المتصفح لا يدعم التعرّف على الصوت. أضف مفتاح API في الإعدادات لاستخدام Whisper.",
+ errNoKey: "أضف مفتاح API في الإعدادات أولاً لاستخدام هذه الميزة.",
+ errApi: "خطأ من الخدمة: ", errNet: "تعذّر الاتصال بالخدمة. تحقّق من الإنترنت.",
+ noHistory: "لا يوجد شيء في السجل بعد.", confirmClear: "هل تريد مسح كل السجل؟",
+ deleted: "تم الحذف", noPolishKey: "أُبقي النص كما هو — أضف مفتاح API في الإعدادات لتفعيل الصقل الذكي.",
+ installHint: "لتثبيت التطبيق: شارك ← أضف إلى الشاشة الرئيسية",
+ },
+ en: {
+ appName: "Nutq",
+ heroTitle: "Dictation
without the mistakes",
+ heroSub: "Talk naturally — Nutq turns your speech into polished, ready-to-send text, in any app and 100+ languages.",
+ modeMessage: "Message", modeEmail: "Email", modeNotes: "Notes", modeFormal: "Formal", modeRaw: "Raw",
+ rawTranscript: "Raw transcript", polished: "✨ Polished text",
+ transcriptPh: "Your speech will appear here…",
+ copy: "Copy", copied: "Copied ✓", share: "Share", repolish: "Re-polish",
+ tapToTalk: "Tap & talk", tapToStop: "Tap to stop",
+ listening: "Listening…", transcribing: "Transcribing…", polishing: "Polishing…",
+ history: "History", clearAll: "Clear all", settings: "Settings",
+ uiLang: "Interface language", engine: "Speech engine",
+ engineAuto: "Auto (best available)", engineWeb: "Browser (free, live)", engineWhisper: "Whisper via API (accurate)",
+ aiSection: "AI settings (polish & Whisper)",
+ apiKey: "API key", baseUrl: "Base URL", model: "Polish model", whisperModel: "Transcription model",
+ apiHint: "Works with any OpenAI-compatible service: OpenAI, Groq (free), OpenRouter… Your key never leaves this device.",
+ dictionary: "Personal dictionary (names & terms, comma-separated)",
+ dictPh: "e.g. Jalal, Luma Inc, GPT-SoVITS",
+ autoPolish: "Auto-polish after stopping",
+ theme: "Theme", themeAuto: "Auto", themeLight: "Light", themeDark: "Dark",
+ save: "Save", saved: "Saved ✓",
+ searchLang: "Search languages…", autoDetect: "Auto-detect",
+ f1t: "Works with every app", f1d: "Dictate, then paste into WhatsApp, email, or anywhere else.",
+ f2t: "AI-polished output", f2d: "No filler words, no typos — your voice, cleaned up and ready to send.",
+ f3t: "100+ languages", f3d: "Arabic, English, French and more — with auto-detect.",
+ f4t: "Personal dictionary", f4d: "Add custom names and terms so Nutq learns your lingo.",
+ errNoMic: "Microphone access failed. Check permissions.",
+ errNoSpeech: "This browser has no speech recognition. Add an API key in Settings to use Whisper.",
+ errNoKey: "Add an API key in Settings first.",
+ errApi: "Service error: ", errNet: "Could not reach the service. Check your connection.",
+ noHistory: "Nothing here yet.", confirmClear: "Clear all history?",
+ deleted: "Deleted", noPolishKey: "Kept raw text — add an API key in Settings to enable AI polish.",
+ installHint: "To install: Share → Add to Home Screen",
+ },
+};
+
+/* ---------- Languages (speech recognition targets) ---------- */
+const LANGS = [
+ ["auto", "", ""],
+ ["ar-SA", "🇸🇦", "العربية"], ["ar-EG", "🇪🇬", "العربية (مصر)"], ["ar-MA", "🇲🇦", "العربية (المغرب)"],
+ ["en-US", "🇺🇸", "English (US)"], ["en-GB", "🇬🇧", "English (UK)"], ["en-AU", "🇦🇺", "English (AU)"], ["en-IN", "🇮🇳", "English (India)"],
+ ["fr-FR", "🇫🇷", "Français"], ["fr-CA", "🇨🇦", "Français (Canada)"],
+ ["es-ES", "🇪🇸", "Español"], ["es-MX", "🇲🇽", "Español (México)"], ["es-AR", "🇦🇷", "Español (Argentina)"],
+ ["de-DE", "🇩🇪", "Deutsch"], ["it-IT", "🇮🇹", "Italiano"],
+ ["pt-BR", "🇧🇷", "Português (Brasil)"], ["pt-PT", "🇵🇹", "Português"],
+ ["ru-RU", "🇷🇺", "Русский"], ["uk-UA", "🇺🇦", "Українська"],
+ ["tr-TR", "🇹🇷", "Türkçe"], ["fa-IR", "🇮🇷", "فارسی"], ["ur-PK", "🇵🇰", "اردو"],
+ ["hi-IN", "🇮🇳", "हिन्दी"], ["bn-BD", "🇧🇩", "বাংলা"], ["ta-IN", "🇮🇳", "தமிழ்"], ["te-IN", "🇮🇳", "తెలుగు"],
+ ["ja-JP", "🇯🇵", "日本語"], ["ko-KR", "🇰🇷", "한국어"],
+ ["zh-CN", "🇨🇳", "中文 (简体)"], ["zh-TW", "🇹🇼", "中文 (繁體)"], ["zh-HK", "🇭🇰", "粵語 (香港)"],
+ ["id-ID", "🇮🇩", "Bahasa Indonesia"], ["ms-MY", "🇲🇾", "Bahasa Melayu"],
+ ["th-TH", "🇹🇭", "ไทย"], ["vi-VN", "🇻🇳", "Tiếng Việt"], ["fil-PH", "🇵🇭", "Filipino"],
+ ["nl-NL", "🇳🇱", "Nederlands"], ["sv-SE", "🇸🇪", "Svenska"], ["nb-NO", "🇳🇴", "Norsk"],
+ ["da-DK", "🇩🇰", "Dansk"], ["fi-FI", "🇫🇮", "Suomi"], ["is-IS", "🇮🇸", "Íslenska"],
+ ["pl-PL", "🇵🇱", "Polski"], ["cs-CZ", "🇨🇿", "Čeština"], ["sk-SK", "🇸🇰", "Slovenčina"],
+ ["hu-HU", "🇭🇺", "Magyar"], ["ro-RO", "🇷🇴", "Română"], ["bg-BG", "🇧🇬", "Български"],
+ ["el-GR", "🇬🇷", "Ελληνικά"], ["sr-RS", "🇷🇸", "Српски"], ["hr-HR", "🇭🇷", "Hrvatski"],
+ ["sl-SI", "🇸🇮", "Slovenščina"], ["lt-LT", "🇱🇹", "Lietuvių"], ["lv-LV", "🇱🇻", "Latviešu"], ["et-EE", "🇪🇪", "Eesti"],
+ ["he-IL", "🇮🇱", "עברית"], ["am-ET", "🇪🇹", "አማርኛ"], ["sw-KE", "🇰🇪", "Kiswahili"],
+ ["af-ZA", "🇿🇦", "Afrikaans"], ["zu-ZA", "🇿🇦", "isiZulu"],
+ ["ka-GE", "🇬🇪", "ქართული"], ["hy-AM", "🇦🇲", "Հայերեն"], ["az-AZ", "🇦🇿", "Azərbaycanca"],
+ ["kk-KZ", "🇰🇿", "Қазақша"], ["uz-UZ", "🇺🇿", "Oʻzbekcha"],
+ ["ne-NP", "🇳🇵", "नेपाली"], ["si-LK", "🇱🇰", "සිංහල"], ["km-KH", "🇰🇭", "ខ្មែរ"], ["lo-LA", "🇱🇦", "ລາວ"],
+ ["my-MM", "🇲🇲", "မြန်မာ"], ["mn-MN", "🇲🇳", "Монгол"],
+ ["ca-ES", "🏴", "Català"], ["eu-ES", "🏴", "Euskara"], ["gl-ES", "🏴", "Galego"],
+ ["mr-IN", "🇮🇳", "मराठी"], ["gu-IN", "🇮🇳", "ગુજરાતી"], ["kn-IN", "🇮🇳", "ಕನ್ನಡ"], ["ml-IN", "🇮🇳", "മലയാളം"], ["pa-IN", "🇮🇳", "ਪੰਜਾਬੀ"],
+];
+
+/* ---------- Polish prompts ---------- */
+const MODE_PROMPTS = {
+ message: "Rewrite the dictated text as a natural, friendly chat message. Keep it short and casual, as if sent on WhatsApp or iMessage.",
+ email: "Rewrite the dictated text as a clear, well-structured email body. Add a suitable greeting and sign-off placeholder if appropriate. Use short paragraphs and bullet points where they help.",
+ notes: "Rewrite the dictated text as concise personal notes: short bullet points capturing every idea, no fluff.",
+ formal: "Rewrite the dictated text in polished, formal, professional language suitable for official correspondence.",
+ raw: null,
+};
+
+/* ---------- State ---------- */
+const DEFAULTS = {
+ uiLang: (navigator.language || "ar").startsWith("ar") ? "ar" : "en",
+ lang: "auto",
+ mode: "message",
+ engine: "auto",
+ apiKey: "",
+ baseUrl: "https://api.openai.com/v1",
+ model: "gpt-4o-mini",
+ whisperModel: "whisper-1",
+ dictionary: "",
+ autoPolish: true,
+ theme: "auto",
+};
+let S = loadSettings();
+let history = loadJSON("nutq.history", []);
+let recState = "idle"; // idle | listening | transcribing | polishing
+let recognition = null, mediaRecorder = null, mediaStream = null, chunks = [];
+let finalText = "", timerInt = null, timerStart = 0;
+
+function loadSettings() {
+ return Object.assign({}, DEFAULTS, loadJSON("nutq.settings", {}));
+}
+function loadJSON(k, fb) {
+ try { return JSON.parse(localStorage.getItem(k)) ?? fb; } catch { return fb; }
+}
+function saveJSON(k, v) { localStorage.setItem(k, JSON.stringify(v)); }
+
+/* ---------- DOM helpers ---------- */
+const $ = (id) => document.getElementById(id);
+const t = (key) => (I18N[S.uiLang] || I18N.ar)[key] || key;
+
+function applyI18n() {
+ document.documentElement.lang = S.uiLang;
+ document.documentElement.dir = S.uiLang === "ar" ? "rtl" : "ltr";
+ document.querySelectorAll("[data-i18n]").forEach((el) => {
+ const key = el.dataset.i18n;
+ if (key === "heroTitle") el.innerHTML = t(key);
+ else el.textContent = t(key);
+ });
+ document.querySelectorAll("[data-i18n-placeholder]").forEach((el) => {
+ const v = t(el.dataset.i18nPlaceholder);
+ if ("placeholder" in el) el.placeholder = v;
+ else el.dataset.placeholder = v;
+ });
+ $("transcript").dataset.placeholder = t("transcriptPh");
+ updateLangLabel();
+}
+
+function applyTheme() {
+ const root = document.documentElement;
+ if (S.theme === "auto") root.removeAttribute("data-theme");
+ else root.setAttribute("data-theme", S.theme);
+}
+
+function toast(msg, ms = 2200) {
+ const el = $("toast");
+ el.textContent = msg;
+ el.hidden = false;
+ clearTimeout(el._t);
+ el._t = setTimeout(() => (el.hidden = true), ms);
+}
+
+function setStatus(msg, isErr = false) {
+ const el = $("status");
+ el.textContent = msg || "";
+ el.classList.toggle("error", isErr);
+}
+
+function updateLangLabel() {
+ const item = LANGS.find(([code]) => code === S.lang);
+ $("langLabel").textContent =
+ !item || item[0] === "auto" ? t("autoDetect") : `${item[1]} ${item[2]}`;
+}
+
+/* ---------- Recording UI ---------- */
+function setRecUI(on) {
+ $("btnMic").classList.toggle("rec", on);
+ $("btnMic").querySelector(".mic-ico").hidden = on;
+ $("btnMic").querySelector(".stop-ico").hidden = !on;
+ $("waveform").hidden = !on;
+ $("timer").hidden = !on;
+ $("liveDot").hidden = !on;
+ $("micHint").textContent = on ? t("tapToStop") : t("tapToTalk");
+ $("hero").classList.toggle("compact", on || !!finalText);
+ $("features").hidden = on || !!finalText || !!$("result").textContent;
+ if (on) {
+ $("transcriptCard").hidden = false;
+ timerStart = Date.now();
+ timerInt = setInterval(() => {
+ const s = Math.floor((Date.now() - timerStart) / 1000);
+ $("timer").textContent =
+ String(Math.floor(s / 60)).padStart(2, "0") + ":" + String(s % 60).padStart(2, "0");
+ }, 500);
+ } else {
+ clearInterval(timerInt);
+ $("timer").textContent = "00:00";
+ }
+}
+
+function renderTranscript(interim = "") {
+ const el = $("transcript");
+ el.innerHTML = "";
+ el.appendChild(document.createTextNode(finalText));
+ if (interim) {
+ const span = document.createElement("span");
+ span.className = "interim";
+ span.textContent = (finalText ? " " : "") + interim;
+ el.appendChild(span);
+ }
+}
+
+/* ---------- Engine selection ---------- */
+const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
+function pickEngine() {
+ if (S.engine === "webspeech") return SR ? "webspeech" : null;
+ if (S.engine === "whisper") return S.apiKey ? "whisper" : null;
+ // auto: prefer live browser engine, fall back to whisper
+ if (SR) return "webspeech";
+ if (S.apiKey) return "whisper";
+ return null;
+}
+
+/* ---------- Web Speech engine ---------- */
+function startWebSpeech() {
+ recognition = new SR();
+ recognition.continuous = true;
+ recognition.interimResults = true;
+ if (S.lang !== "auto") recognition.lang = S.lang;
+ else if (S.uiLang === "ar") recognition.lang = "ar-SA";
+
+ recognition.onresult = (e) => {
+ let interim = "";
+ for (let i = e.resultIndex; i < e.results.length; i++) {
+ const txt = e.results[i][0].transcript;
+ if (e.results[i].isFinal) finalText = (finalText + " " + txt).trim();
+ else interim += txt;
+ }
+ renderTranscript(interim);
+ };
+ recognition.onerror = (e) => {
+ if (e.error === "not-allowed" || e.error === "service-not-allowed") {
+ stopDictation(false);
+ setStatus(t("errNoMic"), true);
+ }
+ // "no-speech" and "aborted" are benign; onend handles restart
+ };
+ recognition.onend = () => {
+ // Browsers stop after silence — keep listening until user taps stop.
+ if (recState === "listening") {
+ try { recognition.start(); } catch { /* already started */ }
+ }
+ };
+ recognition.start();
+}
+
+/* ---------- Whisper engine ---------- */
+async function startWhisperRecording() {
+ mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
+ chunks = [];
+ const mime = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg"]
+ .find((m) => window.MediaRecorder && MediaRecorder.isTypeSupported(m)) || "";
+ mediaRecorder = new MediaRecorder(mediaStream, mime ? { mimeType: mime } : undefined);
+ mediaRecorder.ondataavailable = (e) => e.data.size && chunks.push(e.data);
+ mediaRecorder.start(1000);
+}
+
+async function transcribeWithWhisper() {
+ const blob = new Blob(chunks, { type: mediaRecorder.mimeType || "audio/webm" });
+ const ext = (mediaRecorder.mimeType || "audio/webm").includes("mp4") ? "mp4"
+ : (mediaRecorder.mimeType || "").includes("ogg") ? "ogg" : "webm";
+ const form = new FormData();
+ form.append("file", blob, "audio." + ext);
+ form.append("model", S.whisperModel || "whisper-1");
+ if (S.lang !== "auto") form.append("language", S.lang.split("-")[0]);
+ if (S.dictionary.trim()) form.append("prompt", S.dictionary.trim());
+
+ const res = await fetch(S.baseUrl.replace(/\/$/, "") + "/audio/transcriptions", {
+ method: "POST",
+ headers: { Authorization: "Bearer " + S.apiKey },
+ body: form,
+ });
+ if (!res.ok) {
+ const body = await res.text().catch(() => "");
+ throw new Error(t("errApi") + res.status + " " + body.slice(0, 200));
+ }
+ const data = await res.json();
+ return (data.text || "").trim();
+}
+
+/* ---------- AI polish ---------- */
+async function polish(text) {
+ const sysMode = MODE_PROMPTS[S.mode];
+ if (!sysMode) return text; // raw mode
+ if (!S.apiKey) {
+ toast(t("noPolishKey"), 3200);
+ return text;
+ }
+ const dict = S.dictionary.trim()
+ ? `\nPersonal dictionary (respect these exact spellings): ${S.dictionary.trim()}`
+ : "";
+ const sys =
+ "You are Nutq, an AI dictation cleanup engine. The user dictated text by voice. " +
+ "Fix transcription artifacts, remove filler words, add correct punctuation and formatting, " +
+ "and keep the user's own voice and meaning. Always answer in the SAME language as the dictated text. " +
+ "Output ONLY the rewritten text, no explanations." +
+ "\nStyle: " + sysMode + dict;
+
+ const res = await fetch(S.baseUrl.replace(/\/$/, "") + "/chat/completions", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: "Bearer " + S.apiKey,
+ },
+ body: JSON.stringify({
+ model: S.model || "gpt-4o-mini",
+ temperature: 0.3,
+ messages: [
+ { role: "system", content: sys },
+ { role: "user", content: text },
+ ],
+ }),
+ });
+ if (!res.ok) {
+ const body = await res.text().catch(() => "");
+ throw new Error(t("errApi") + res.status + " " + body.slice(0, 200));
+ }
+ const data = await res.json();
+ return (data.choices?.[0]?.message?.content || "").trim() || text;
+}
+
+/* ---------- Dictation flow ---------- */
+async function startDictation() {
+ finalText = "";
+ $("result").textContent = "";
+ $("resultCard").hidden = true;
+ renderTranscript();
+ const engine = pickEngine();
+ if (!engine) {
+ setStatus(SR ? t("errNoKey") : t("errNoSpeech"), true);
+ openSheet("settingsSheet");
+ return;
+ }
+ try {
+ if (engine === "webspeech") startWebSpeech();
+ else await startWhisperRecording();
+ recState = "listening";
+ setRecUI(true);
+ setStatus(t("listening"));
+ } catch (err) {
+ console.error(err);
+ setStatus(t("errNoMic"), true);
+ }
+}
+
+async function stopDictation(process = true) {
+ const wasEngine = recognition ? "webspeech" : "whisper";
+ recState = "idle";
+ if (recognition) { try { recognition.stop(); } catch {} recognition = null; }
+ if (mediaRecorder && mediaRecorder.state !== "inactive") {
+ await new Promise((r) => { mediaRecorder.onstop = r; mediaRecorder.stop(); });
+ }
+ if (mediaStream) { mediaStream.getTracks().forEach((tr) => tr.stop()); mediaStream = null; }
+ setRecUI(false);
+ if (!process) return;
+
+ try {
+ if (wasEngine === "whisper" && chunks.length) {
+ recState = "transcribing";
+ setStatus(t("transcribing"));
+ finalText = await transcribeWithWhisper();
+ renderTranscript();
+ }
+ finalText = $("transcript").textContent.trim();
+ if (!finalText) { setStatus(""); return; }
+ if (S.autoPolish) await runPolish();
+ else setStatus("");
+ } catch (err) {
+ console.error(err);
+ setStatus(err.message.includes("fetch") ? t("errNet") : err.message, true);
+ } finally {
+ recState = "idle";
+ }
+}
+
+async function runPolish() {
+ const text = $("transcript").textContent.trim();
+ if (!text) return;
+ recState = "polishing";
+ setStatus(t("polishing"));
+ try {
+ const out = await polish(text);
+ $("result").textContent = out;
+ $("resultCard").hidden = false;
+ $("resultMode").textContent = t("mode" + S.mode[0].toUpperCase() + S.mode.slice(1));
+ setStatus("");
+ addHistory({ raw: text, polished: out, mode: S.mode, ts: Date.now() });
+ $("result").scrollIntoView({ behavior: "smooth", block: "center" });
+ } catch (err) {
+ console.error(err);
+ setStatus(err.message.includes("fetch") ? t("errNet") : err.message, true);
+ } finally {
+ recState = "idle";
+ }
+}
+
+/* ---------- History ---------- */
+function addHistory(item) {
+ history.unshift(item);
+ history = history.slice(0, 200);
+ saveJSON("nutq.history", history);
+}
+function renderHistory() {
+ const list = $("historyList");
+ list.innerHTML = "";
+ if (!history.length) {
+ list.innerHTML = `
${t("noHistory")}
`;
+ return;
+ }
+ history.forEach((item, i) => {
+ const div = document.createElement("div");
+ div.className = "history-item";
+ const date = new Date(item.ts).toLocaleString(S.uiLang === "ar" ? "ar" : "en", {
+ dateStyle: "medium", timeStyle: "short",
+ });
+ div.innerHTML = `
+ ${date}${t("mode" + item.mode[0].toUpperCase() + item.mode.slice(1))}
+
+
+
+
+
`;
+ div.querySelector(".h-text").textContent = item.polished || item.raw;
+ div.querySelector(".h-copy").onclick = () => copyText(item.polished || item.raw);
+ div.querySelector(".h-del").onclick = () => {
+ history.splice(i, 1);
+ saveJSON("nutq.history", history);
+ renderHistory();
+ toast(t("deleted"));
+ };
+ list.appendChild(div);
+ });
+}
+
+/* ---------- Clipboard / share ---------- */
+async function copyText(text) {
+ try {
+ await navigator.clipboard.writeText(text);
+ } catch {
+ const ta = document.createElement("textarea");
+ ta.value = text;
+ document.body.appendChild(ta);
+ ta.select();
+ document.execCommand("copy");
+ ta.remove();
+ }
+ toast(t("copied"));
+}
+
+/* ---------- Sheets ---------- */
+function openSheet(id) { $(id).hidden = false; document.body.style.overflow = "hidden"; }
+function closeSheet(id) { $(id).hidden = true; document.body.style.overflow = ""; }
+["langSheet", "historySheet", "settingsSheet"].forEach((id) => {
+ $(id).addEventListener("click", (e) => { if (e.target === $(id)) closeSheet(id); });
+});
+
+/* ---------- Language list ---------- */
+function renderLangs(filter = "") {
+ const list = $("langList");
+ list.innerHTML = "";
+ const q = filter.trim().toLowerCase();
+ LANGS.filter(([code, , name]) =>
+ !q || name.toLowerCase().includes(q) || code.toLowerCase().includes(q)
+ ).forEach(([code, flag, name]) => {
+ const btn = document.createElement("button");
+ btn.className = "lang-item" + (S.lang === code ? " selected" : "");
+ btn.innerHTML = code === "auto"
+ ? `🌐${t("autoDetect")}`
+ : `${flag}${name}`;
+ btn.onclick = () => {
+ S.lang = code;
+ saveJSON("nutq.settings", S);
+ updateLangLabel();
+ closeSheet("langSheet");
+ };
+ list.appendChild(btn);
+ });
+}
+
+/* ---------- Settings ---------- */
+function fillSettingsForm() {
+ $("setUiLang").value = S.uiLang;
+ $("setEngine").value = S.engine;
+ $("setApiKey").value = S.apiKey;
+ $("setBaseUrl").value = S.baseUrl;
+ $("setModel").value = S.model;
+ $("setWhisperModel").value = S.whisperModel;
+ $("setDictionary").value = S.dictionary;
+ $("setAutoPolish").checked = S.autoPolish;
+ $("setTheme").value = S.theme;
+}
+function saveSettingsForm() {
+ S.uiLang = $("setUiLang").value;
+ S.engine = $("setEngine").value;
+ S.apiKey = $("setApiKey").value.trim();
+ S.baseUrl = $("setBaseUrl").value.trim() || DEFAULTS.baseUrl;
+ S.model = $("setModel").value.trim() || DEFAULTS.model;
+ S.whisperModel = $("setWhisperModel").value.trim() || DEFAULTS.whisperModel;
+ S.dictionary = $("setDictionary").value;
+ S.autoPolish = $("setAutoPolish").checked;
+ S.theme = $("setTheme").value;
+ saveJSON("nutq.settings", S);
+ applyI18n();
+ applyTheme();
+ closeSheet("settingsSheet");
+ toast(t("saved"));
+}
+
+/* ---------- Wire up ---------- */
+$("btnMic").onclick = () => {
+ if (recState === "listening") stopDictation();
+ else if (recState === "idle") startDictation();
+};
+$("btnCopy").onclick = () => copyText($("result").textContent);
+$("btnShare").onclick = async () => {
+ const text = $("result").textContent;
+ if (navigator.share) { try { await navigator.share({ text }); } catch {} }
+ else copyText(text);
+};
+$("btnRepolish").onclick = () => { if (recState === "idle") runPolish(); };
+$("btnLang").onclick = () => { renderLangs(); openSheet("langSheet"); $("langSearch").value = ""; };
+$("langSearch").oninput = (e) => renderLangs(e.target.value);
+$("btnHistory").onclick = () => { renderHistory(); openSheet("historySheet"); };
+$("btnClearHistory").onclick = () => {
+ if (confirm(t("confirmClear"))) {
+ history = [];
+ saveJSON("nutq.history", history);
+ renderHistory();
+ }
+};
+$("btnSettings").onclick = () => { fillSettingsForm(); openSheet("settingsSheet"); };
+$("btnSaveSettings").onclick = saveSettingsForm;
+
+document.querySelectorAll(".chip").forEach((chip) => {
+ chip.onclick = () => {
+ document.querySelectorAll(".chip").forEach((c) => c.classList.remove("active"));
+ chip.classList.add("active");
+ S.mode = chip.dataset.mode;
+ saveJSON("nutq.settings", S);
+ };
+});
+// restore active mode chip
+document.querySelectorAll(".chip").forEach((c) =>
+ c.classList.toggle("active", c.dataset.mode === S.mode));
+
+/* ---------- Init ---------- */
+applyI18n();
+applyTheme();
+$("verInfo").textContent = "Nutq v1.0";
+
+if ("serviceWorker" in navigator) {
+ window.addEventListener("load", () => {
+ navigator.serviceWorker.register("sw.js").catch(() => {});
+ });
+}
diff --git a/nutq/icons/apple-touch-icon.png b/nutq/icons/apple-touch-icon.png
new file mode 100644
index 00000000..608b4c8b
Binary files /dev/null and b/nutq/icons/apple-touch-icon.png differ
diff --git a/nutq/icons/icon-192.png b/nutq/icons/icon-192.png
new file mode 100644
index 00000000..6e6cd446
Binary files /dev/null and b/nutq/icons/icon-192.png differ
diff --git a/nutq/icons/icon-512.png b/nutq/icons/icon-512.png
new file mode 100644
index 00000000..a04f8dff
Binary files /dev/null and b/nutq/icons/icon-512.png differ
diff --git a/nutq/icons/icon-maskable-512.png b/nutq/icons/icon-maskable-512.png
new file mode 100644
index 00000000..3d1f6f14
Binary files /dev/null and b/nutq/icons/icon-maskable-512.png differ
diff --git a/nutq/index.html b/nutq/index.html
new file mode 100644
index 00000000..61433f30
--- /dev/null
+++ b/nutq/index.html
@@ -0,0 +1,216 @@
+
+
+
+
+
+نُطق — لوحة المفاتيح الصوتية بالذكاء الاصطناعي
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ نُطق
+
+
+
+
+
+
+
+
+ إملاء صوتي
بلا أخطاء
+ تكلّم بشكل طبيعي، ونُطق يحوّل كلامك إلى نص مصقول جاهز للإرسال — في أي تطبيق وبأكثر من 100 لغة.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ✨ النص المصقول
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ⌨️يعمل مع كل التطبيقاتأملِ النص ثم انسخه إلى واتساب، البريد، أو أي تطبيق آخر.
+ ✨صقل بالذكاء الاصطناعيبلا كلمات حشو ولا أخطاء — نص بأسلوبك، جاهز للإرسال.
+ 🌍أكثر من 100 لغةالعربية، الإنجليزية، الفرنسية وغيرها — مع اكتشاف تلقائي.
+ 📖قاموسك الشخصيأضف أسماءً ومصطلحات خاصة ليتعلم نُطق أسلوبك.
+
+
+
+
+
+
+
+
+
00:00
+
+
اضغط وتكلّم
+
+
+
+
+
+
+
+
+
+
+
السجل
+
+
+
+
+
+
+
+
+
+
+
الإعدادات
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/nutq/manifest.webmanifest b/nutq/manifest.webmanifest
new file mode 100644
index 00000000..189c0af9
--- /dev/null
+++ b/nutq/manifest.webmanifest
@@ -0,0 +1,18 @@
+{
+ "name": "نُطق — لوحة المفاتيح الصوتية بالذكاء الاصطناعي",
+ "short_name": "نُطق",
+ "description": "تكلّم بشكل طبيعي، ونُطق يكتب نصاً مصقولاً جاهزاً للإرسال. يدعم أكثر من 100 لغة.",
+ "lang": "ar",
+ "dir": "rtl",
+ "start_url": "./",
+ "scope": "./",
+ "display": "standalone",
+ "orientation": "portrait",
+ "background_color": "#FAF6E9",
+ "theme_color": "#FAF6E9",
+ "icons": [
+ { "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
+ { "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
+ { "src": "icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
+ ]
+}
diff --git a/nutq/sw.js b/nutq/sw.js
new file mode 100644
index 00000000..842ee9e8
--- /dev/null
+++ b/nutq/sw.js
@@ -0,0 +1,43 @@
+/* Nutq service worker — offline-first app shell */
+const CACHE = "nutq-v1";
+const SHELL = [
+ "./",
+ "./index.html",
+ "./app.css",
+ "./app.js",
+ "./manifest.webmanifest",
+ "./icons/icon-192.png",
+ "./icons/icon-512.png",
+ "./icons/apple-touch-icon.png",
+];
+
+self.addEventListener("install", (e) => {
+ e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)));
+ self.skipWaiting();
+});
+
+self.addEventListener("activate", (e) => {
+ e.waitUntil(
+ caches.keys().then((keys) =>
+ Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
+ )
+ );
+ self.clients.claim();
+});
+
+self.addEventListener("fetch", (e) => {
+ const url = new URL(e.request.url);
+ // Never intercept API calls — only cache same-origin static assets.
+ if (url.origin !== location.origin || e.request.method !== "GET") return;
+ e.respondWith(
+ caches.match(e.request).then(
+ (hit) =>
+ hit ||
+ fetch(e.request).then((res) => {
+ const copy = res.clone();
+ caches.open(CACHE).then((c) => c.put(e.request, copy));
+ return res;
+ })
+ )
+ );
+});