Refactor tips and index layout for improved readability and user experience; add .gitignore for cache files
Browse files- .gitignore +1 -0
- app/tips.py +2 -1
- static/index.html +45 -59
.gitignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
*__pycache__**/
|
app/tips.py
CHANGED
|
@@ -64,4 +64,5 @@ def tip_for(expected: str, heard: str | None) -> dict | None:
|
|
| 64 |
return {"title": name, "text": text, "key": f"{expected}>{heard}"}
|
| 65 |
if heard == "_":
|
| 66 |
return {"title": name, "text": "ืื ื ืฉืืขื ืืืืจืืจ.", "key": f"{expected}>_"}
|
| 67 |
-
|
|
|
|
|
|
| 64 |
return {"title": name, "text": text, "key": f"{expected}>{heard}"}
|
| 65 |
if heard == "_":
|
| 66 |
return {"title": name, "text": "ืื ื ืฉืืขื ืืืืจืืจ.", "key": f"{expected}>_"}
|
| 67 |
+
text = f"ื ืฉืืขื ืืื {NAMES[heard]}." if heard in NAMES else "ื ืฉืืขื ืืืจืช ืืืฆืคืื."
|
| 68 |
+
return {"title": name, "text": text, "key": f"{expected}>{heard}"}
|
static/index.html
CHANGED
|
@@ -7,7 +7,7 @@
|
|
| 7 |
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 8 |
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Assistant:wght@400;600;700&family=Secular+One&family=Noto+Naskh+Arabic:wght@500;700&family=Noto+Sans+Mono&display=swap">
|
| 9 |
<style>
|
| 10 |
-
/* Layout: one practice card
|
| 11 |
:root{
|
| 12 |
--bg:#EEF0E8; --surface:#FAFBF6; --ink:#1C241D; --muted:#5E685C; --line:#D5DACB;
|
| 13 |
--olive:#4B6A2C; --on-olive:#FFFFFF; --olive-soft:#E3EAD6;
|
|
@@ -23,54 +23,54 @@
|
|
| 23 |
--sumac:#E77B87; --sumac-bg:#3A1E23; --ok:#6CC08A; --ok-bg:#1C3324; --amber:#E0B45A; --amber-bg:#3A2F17;
|
| 24 |
color-scheme:dark}}
|
| 25 |
*{box-sizing:border-box}
|
|
|
|
| 26 |
html,body{margin:0}
|
| 27 |
-
body{background:var(--bg);color:var(--ink);font:400 17px/1.55 var(--f-body);padding:
|
| 28 |
-
.wrap{max-width:680px;margin:0 auto;display:flex;flex-direction:column;gap:
|
| 29 |
-
|
|
|
|
| 30 |
.brand .ar{font-family:var(--f-ar);font-weight:700;color:var(--olive);margin-inline-start:8px}
|
| 31 |
.sub{color:var(--muted);font-size:14px}
|
| 32 |
.chips{display:flex;gap:6px;flex-wrap:wrap}
|
| 33 |
-
.chip{border:1px solid var(--line);background:transparent;color:var(--ink);font:600 14px var(--f-body);padding:
|
| 34 |
.chip[aria-pressed="true"]{background:var(--olive);border-color:var(--olive);color:var(--on-olive)}
|
| 35 |
-
.card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:
|
| 36 |
.meta{display:flex;justify-content:space-between;color:var(--muted);font-size:13px;letter-spacing:.04em;font-variant-numeric:tabular-nums}
|
| 37 |
-
.prompt{font:400 clamp(
|
| 38 |
-
.task{color:var(--muted);font-size:
|
| 39 |
-
.hint{display:flex;flex-
|
| 40 |
-
.hint .tr{font-size:
|
| 41 |
-
.hint .arline{font:700
|
| 42 |
.row{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
|
| 43 |
-
.btn{font:600 15px var(--f-body);border-radius:10px;padding:
|
| 44 |
.btn.primary{background:var(--olive);border-color:var(--olive);color:var(--on-olive)}
|
| 45 |
.btn:disabled{opacity:.45;cursor:default}
|
| 46 |
:focus-visible{outline:3px solid var(--olive);outline-offset:2px}
|
| 47 |
-
.mic-zone{display:flex;
|
| 48 |
-
.mic{width:
|
| 49 |
-
.mic svg{width:
|
| 50 |
.mic.live{background:var(--sumac)}
|
| 51 |
-
.mic.live::after{content:"";position:absolute;inset:-
|
| 52 |
.mic:disabled{opacity:.5;cursor:progress}
|
| 53 |
@keyframes pulse{from{transform:scale(.9);opacity:.6}to{transform:scale(1.15);opacity:0}}
|
| 54 |
@media (prefers-reduced-motion:reduce){.mic.live::after{animation:none}}
|
| 55 |
-
.status{color:var(--muted);font-size:14px
|
| 56 |
-
.result{display:flex;flex-direction:column;gap:
|
| 57 |
-
.verdict{display:flex;align-items:center;gap:
|
| 58 |
-
.score{font:400
|
| 59 |
.pill{font:700 13px var(--f-body);padding:4px 10px;border-radius:999px}
|
| 60 |
.good{background:var(--ok-bg);color:var(--ok)} .mid{background:var(--amber-bg);color:var(--amber)} .bad{background:var(--sumac-bg);color:var(--sumac)}
|
| 61 |
-
.
|
| 62 |
-
.
|
| 63 |
-
.word{
|
| 64 |
-
.word .
|
| 65 |
.word .w.bad{background:none;color:var(--sumac)} .word .w.mid{background:none;color:var(--amber)} .word .w.good{background:none;color:var(--ink)}
|
| 66 |
-
.
|
| 67 |
-
.
|
| 68 |
-
.
|
| 69 |
-
.tips{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:10px}
|
| 70 |
-
.tips li{padding:10px 12px;border-radius:10px;border:1px solid var(--line);display:flex;flex-direction:column;gap:2px}
|
| 71 |
.tips .what{font-weight:700}
|
| 72 |
-
.tips .what .ar{font-family:var(--f-ar);font-size:
|
| 73 |
-
.note{font-size:
|
| 74 |
.warn{background:var(--amber-bg);color:var(--amber);border-radius:10px;padding:10px 12px;font-size:14px}
|
| 75 |
</style>
|
| 76 |
</head>
|
|
@@ -96,35 +96,27 @@ body{background:var(--bg);color:var(--ink);font:400 17px/1.55 var(--f-body);padd
|
|
| 96 |
<div class="status" id="status">ืืืฆื, ืืืจื, ืืืืฆื ืฉืื ืืกืืื</div>
|
| 97 |
</div>
|
| 98 |
|
| 99 |
-
<div class="row">
|
| 100 |
-
<button class="btn" id="hintBtn">ืจืื</button>
|
| 101 |
-
<button class="btn" id="answerBtn">ืืฆื ืชืฉืืื</button>
|
| 102 |
-
<button class="btn" id="speakBtn">๐ ืืฉืืข</button>
|
| 103 |
-
<button class="btn" id="playMine" disabled>โถ ืืืงืืื ืฉืื</button>
|
| 104 |
-
<span style="flex:1"></span>
|
| 105 |
-
<button class="btn primary" id="nextBtn">ืืืฉืคื ืืื โ</button>
|
| 106 |
-
</div>
|
| 107 |
-
|
| 108 |
<div class="hint" id="hint" hidden>
|
| 109 |
<div class="tr" id="hintTr"></div>
|
| 110 |
<div class="arline" id="hintAr" lang="ar" hidden></div>
|
| 111 |
</div>
|
| 112 |
|
| 113 |
<section class="result" id="result" hidden>
|
| 114 |
-
<div class="verdict"><span class="score" id="score"></span><span class="pill" id="pill"></span></div>
|
| 115 |
-
<div>
|
| 116 |
-
<div class="label">ืฆืืื ืืื ืืืื ืืืื ืืื</div>
|
| 117 |
-
<div class="words" id="words"></div>
|
| 118 |
-
</div>
|
| 119 |
<ul class="tips" id="tips"></ul>
|
| 120 |
-
<div>
|
| 121 |
-
<div class="label">ืืืืืื ืฉืืืืื ืืืื</div>
|
| 122 |
-
<div class="heard" id="heard"></div>
|
| 123 |
-
</div>
|
| 124 |
</section>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
</main>
|
| 126 |
|
| 127 |
-
<p class="note">ืื ืืชืื ื ืขืฉื ืืจืืช ืืืื
|
| 128 |
</div>
|
| 129 |
|
| 130 |
<script>
|
|
@@ -133,7 +125,6 @@ const store={get(k,d){try{const v=localStorage.getItem(k);return v?JSON.parse(v)
|
|
| 133 |
let CATS=[], PHRASES=[], cat=store.get("ihki.cat","meet"), best=store.get("ihki.best2",{}), order=[], pos=0, cur=null;
|
| 134 |
let myAudioUrl=null;
|
| 135 |
|
| 136 |
-
const VOWELS=new Set(["a","i","u","e","o","aห","iห","uห","eห","oห"]);
|
| 137 |
const band=s=>s>=75?"good":s>=50?"mid":"bad";
|
| 138 |
|
| 139 |
function shuffle(a){for(let i=a.length-1;i>0;i--){const j=Math.random()*(i+1)|0;[a[i],a[j]]=[a[j],a[i]]}return a}
|
|
@@ -170,13 +161,8 @@ function render(r){
|
|
| 170 |
for(const w of r.words){
|
| 171 |
const box=document.createElement("div"); box.className="word";
|
| 172 |
const ar=document.createElement("div"); ar.className="w "+band(w.score); ar.lang="ar"; ar.textContent=w.ar;
|
| 173 |
-
const
|
| 174 |
-
|
| 175 |
-
const c=document.createElement("span"); c.className="ph "+band(p.score); c.textContent=p.ipa;
|
| 176 |
-
c.title=`${p.ipa}: ${Math.round(p.score)}`+(p.heard?` (ื ืฉืืข: ${p.heard})`:"");
|
| 177 |
-
phs.appendChild(c);
|
| 178 |
-
}
|
| 179 |
-
box.append(ar,phs); words.appendChild(box);
|
| 180 |
}
|
| 181 |
|
| 182 |
const tips=$("tips"); tips.innerHTML="";
|
|
@@ -197,8 +183,8 @@ function render(r){
|
|
| 197 |
const li=document.createElement("li"); li.innerHTML='<span class="what">ืื ืืขืืฆืืจืื ื ืฉืืขื ื ืืื</span><span>ื ืกื ืืืืจ ืืช ืืืฉืคื ืืงืฆื ืืืืืจ ืืืขื.</span>';
|
| 198 |
tips.appendChild(li);
|
| 199 |
}
|
| 200 |
-
$("heard").textContent="/"+r.heard_ipa+"/";
|
| 201 |
$("result").hidden=false;
|
|
|
|
| 202 |
if(best[cur.id]==null||s>best[cur.id]){best[cur.id]=s; store.set("ihki.best2",best);}
|
| 203 |
}
|
| 204 |
|
|
|
|
| 7 |
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 8 |
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Assistant:wght@400;600;700&family=Secular+One&family=Noto+Naskh+Arabic:wght@500;700&family=Noto+Sans+Mono&display=swap">
|
| 9 |
<style>
|
| 10 |
+
/* Layout: one compact practice card that fits the first screen: prompt, mic, hint, per-word feedback, actions. RTL. */
|
| 11 |
:root{
|
| 12 |
--bg:#EEF0E8; --surface:#FAFBF6; --ink:#1C241D; --muted:#5E685C; --line:#D5DACB;
|
| 13 |
--olive:#4B6A2C; --on-olive:#FFFFFF; --olive-soft:#E3EAD6;
|
|
|
|
| 23 |
--sumac:#E77B87; --sumac-bg:#3A1E23; --ok:#6CC08A; --ok-bg:#1C3324; --amber:#E0B45A; --amber-bg:#3A2F17;
|
| 24 |
color-scheme:dark}}
|
| 25 |
*{box-sizing:border-box}
|
| 26 |
+
[hidden]{display:none!important}
|
| 27 |
html,body{margin:0}
|
| 28 |
+
body{background:var(--bg);color:var(--ink);font:400 17px/1.55 var(--f-body);padding:10px 16px 24px;min-height:100vh}
|
| 29 |
+
.wrap{max-width:680px;margin:0 auto;display:flex;flex-direction:column;gap:10px}
|
| 30 |
+
header{display:flex;align-items:baseline;gap:4px 12px;flex-wrap:wrap}
|
| 31 |
+
.brand{font:400 24px/1 var(--f-display)}
|
| 32 |
.brand .ar{font-family:var(--f-ar);font-weight:700;color:var(--olive);margin-inline-start:8px}
|
| 33 |
.sub{color:var(--muted);font-size:14px}
|
| 34 |
.chips{display:flex;gap:6px;flex-wrap:wrap}
|
| 35 |
+
.chip{border:1px solid var(--line);background:transparent;color:var(--ink);font:600 14px var(--f-body);padding:4px 12px;border-radius:999px;cursor:pointer}
|
| 36 |
.chip[aria-pressed="true"]{background:var(--olive);border-color:var(--olive);color:var(--on-olive)}
|
| 37 |
+
.card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:14px 16px;display:flex;flex-direction:column;gap:10px}
|
| 38 |
.meta{display:flex;justify-content:space-between;color:var(--muted);font-size:13px;letter-spacing:.04em;font-variant-numeric:tabular-nums}
|
| 39 |
+
.prompt{font:400 clamp(22px,5vw,30px)/1.2 var(--f-display);text-wrap:balance;margin:0}
|
| 40 |
+
.task{color:var(--muted);font-size:14px;margin:-6px 0 0}
|
| 41 |
+
.hint{display:flex;flex-wrap:wrap;align-items:baseline;gap:2px 16px;padding:6px 12px;border-radius:10px;background:var(--olive-soft)}
|
| 42 |
+
.hint .tr{font-size:18px;font-weight:600}
|
| 43 |
+
.hint .arline{font:700 24px/1.4 var(--f-ar)}
|
| 44 |
.row{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
|
| 45 |
+
.btn{font:600 15px var(--f-body);border-radius:10px;padding:6px 12px;cursor:pointer;border:1px solid var(--line);background:transparent;color:var(--ink)}
|
| 46 |
.btn.primary{background:var(--olive);border-color:var(--olive);color:var(--on-olive)}
|
| 47 |
.btn:disabled{opacity:.45;cursor:default}
|
| 48 |
:focus-visible{outline:3px solid var(--olive);outline-offset:2px}
|
| 49 |
+
.mic-zone{display:flex;align-items:center;gap:14px}
|
| 50 |
+
.mic{flex:none;width:60px;height:60px;border-radius:50%;border:0;background:var(--olive);color:var(--on-olive);cursor:pointer;display:grid;place-items:center;position:relative}
|
| 51 |
+
.mic svg{width:26px;height:26px}
|
| 52 |
.mic.live{background:var(--sumac)}
|
| 53 |
+
.mic.live::after{content:"";position:absolute;inset:-6px;border-radius:50%;border:3px solid var(--sumac);opacity:.5;animation:pulse 1.2s ease-out infinite}
|
| 54 |
.mic:disabled{opacity:.5;cursor:progress}
|
| 55 |
@keyframes pulse{from{transform:scale(.9);opacity:.6}to{transform:scale(1.15);opacity:0}}
|
| 56 |
@media (prefers-reduced-motion:reduce){.mic.live::after{animation:none}}
|
| 57 |
+
.status{color:var(--muted);font-size:14px}
|
| 58 |
+
.result{display:flex;flex-direction:column;gap:8px;border-top:1px solid var(--line);padding-top:10px}
|
| 59 |
+
.verdict{display:flex;align-items:center;gap:8px 14px;flex-wrap:wrap}
|
| 60 |
+
.score{font:400 28px/1 var(--f-display);font-variant-numeric:tabular-nums}
|
| 61 |
.pill{font:700 13px var(--f-body);padding:4px 10px;border-radius:999px}
|
| 62 |
.good{background:var(--ok-bg);color:var(--ok)} .mid{background:var(--amber-bg);color:var(--amber)} .bad{background:var(--sumac-bg);color:var(--sumac)}
|
| 63 |
+
.words{display:flex;flex-wrap:wrap;gap:4px 14px;direction:rtl}
|
| 64 |
+
.word{display:flex;flex-direction:column;align-items:center}
|
| 65 |
+
.word .w{font:700 26px/1.3 var(--f-ar)}
|
| 66 |
+
.word .n{font-size:12px;line-height:1;color:var(--muted);font-variant-numeric:tabular-nums}
|
| 67 |
.word .w.bad{background:none;color:var(--sumac)} .word .w.mid{background:none;color:var(--amber)} .word .w.good{background:none;color:var(--ink)}
|
| 68 |
+
.tips{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px}
|
| 69 |
+
.tips li{padding:6px 10px;border-radius:10px;border:1px solid var(--line);font-size:15px;line-height:1.4}
|
| 70 |
+
.tips .what::after{content:" ยท "}
|
|
|
|
|
|
|
| 71 |
.tips .what{font-weight:700}
|
| 72 |
+
.tips .what .ar{font-family:var(--f-ar);font-size:18px}
|
| 73 |
+
.note{font-size:12px;margin:0;color:var(--muted);max-width:65ch}
|
| 74 |
.warn{background:var(--amber-bg);color:var(--amber);border-radius:10px;padding:10px 12px;font-size:14px}
|
| 75 |
</style>
|
| 76 |
</head>
|
|
|
|
| 96 |
<div class="status" id="status">ืืืฆื, ืืืจื, ืืืืฆื ืฉืื ืืกืืื</div>
|
| 97 |
</div>
|
| 98 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
<div class="hint" id="hint" hidden>
|
| 100 |
<div class="tr" id="hintTr"></div>
|
| 101 |
<div class="arline" id="hintAr" lang="ar" hidden></div>
|
| 102 |
</div>
|
| 103 |
|
| 104 |
<section class="result" id="result" hidden>
|
| 105 |
+
<div class="verdict"><span class="score" id="score"></span><span class="pill" id="pill"></span><div class="words" id="words"></div></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
<ul class="tips" id="tips"></ul>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
</section>
|
| 108 |
+
|
| 109 |
+
<div class="row">
|
| 110 |
+
<button class="btn" id="hintBtn">ืจืื</button>
|
| 111 |
+
<button class="btn" id="answerBtn">ืืฆื ืชืฉืืื</button>
|
| 112 |
+
<button class="btn" id="speakBtn">๐ ืืฉืืข</button>
|
| 113 |
+
<button class="btn" id="playMine" disabled>โถ ืืืงืืื ืฉืื</button>
|
| 114 |
+
<span style="flex:1"></span>
|
| 115 |
+
<button class="btn primary" id="nextBtn">ืืืฉืคื ืืื โ</button>
|
| 116 |
+
</div>
|
| 117 |
</main>
|
| 118 |
|
| 119 |
+
<p class="note">ืื ืืชืื ื ืขืฉื ืืจืืช ืืืื. ืืืฉืืขื ืืฉืชืืฉืช ืืงืื ืขืจืื ืฉื ืืืืฉืืจ, ืฉืืืจื ืืื ืืื ื ืืืืื ืคืืกืืื ื.</p>
|
| 120 |
</div>
|
| 121 |
|
| 122 |
<script>
|
|
|
|
| 125 |
let CATS=[], PHRASES=[], cat=store.get("ihki.cat","meet"), best=store.get("ihki.best2",{}), order=[], pos=0, cur=null;
|
| 126 |
let myAudioUrl=null;
|
| 127 |
|
|
|
|
| 128 |
const band=s=>s>=75?"good":s>=50?"mid":"bad";
|
| 129 |
|
| 130 |
function shuffle(a){for(let i=a.length-1;i>0;i--){const j=Math.random()*(i+1)|0;[a[i],a[j]]=[a[j],a[i]]}return a}
|
|
|
|
| 161 |
for(const w of r.words){
|
| 162 |
const box=document.createElement("div"); box.className="word";
|
| 163 |
const ar=document.createElement("div"); ar.className="w "+band(w.score); ar.lang="ar"; ar.textContent=w.ar;
|
| 164 |
+
const n=document.createElement("div"); n.className="n"; n.textContent=Math.round(w.score);
|
| 165 |
+
box.append(ar,n); words.appendChild(box);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
}
|
| 167 |
|
| 168 |
const tips=$("tips"); tips.innerHTML="";
|
|
|
|
| 183 |
const li=document.createElement("li"); li.innerHTML='<span class="what">ืื ืืขืืฆืืจืื ื ืฉืืขื ื ืืื</span><span>ื ืกื ืืืืจ ืืช ืืืฉืคื ืืงืฆื ืืืืืจ ืืืขื.</span>';
|
| 184 |
tips.appendChild(li);
|
| 185 |
}
|
|
|
|
| 186 |
$("result").hidden=false;
|
| 187 |
+
$("result").scrollIntoView({block:"nearest",behavior:"smooth"});
|
| 188 |
if(best[cur.id]==null||s>best[cur.id]){best[cur.id]=s; store.set("ihki.best2",best);}
|
| 189 |
}
|
| 190 |
|