feat: add hint functionality to prompter for user feedback on actions
Browse files- docs/project.prd +2 -2
- static/prompter.js +29 -3
- tests/test_phone_browser.py +4 -0
docs/project.prd
CHANGED
|
@@ -75,7 +75,7 @@ Teacher Screen:
|
|
| 75 |
- assignments
|
| 76 |
- give an assignment. load it from:
|
| 77 |
- the computer (file upload)
|
| 78 |
-
- the board
|
| 79 |
- a picture (camera)
|
| 80 |
- check assignments: review each student's submitted answers and give feedback/grade
|
| 81 |
- tests
|
|
@@ -84,4 +84,4 @@ Teacher Screen:
|
|
| 84 |
- class heat map
|
| 85 |
- rows: students, columns: skills
|
| 86 |
- each cell is colored by the skill state (not started / learning / mastered / needs review)
|
| 87 |
-
- period filter: sep-dec,
|
|
|
|
| 75 |
- assignments
|
| 76 |
- give an assignment. load it from:
|
| 77 |
- the computer (file upload)
|
| 78 |
+
- the classroom smart board
|
| 79 |
- a picture (camera)
|
| 80 |
- check assignments: review each student's submitted answers and give feedback/grade
|
| 81 |
- tests
|
|
|
|
| 84 |
- class heat map
|
| 85 |
- rows: students, columns: skills
|
| 86 |
- each cell is colored by the skill state (not started / learning / mastered / needs review)
|
| 87 |
+
- period filter: sep-dec, jan-jun
|
static/prompter.js
CHANGED
|
@@ -26,9 +26,13 @@ const CSS=`
|
|
| 26 |
.mxp-fade{position:absolute;inset-inline:0;height:22%;pointer-events:none}
|
| 27 |
.mxp-fade.top{top:0;background:linear-gradient(#000,transparent)}.mxp-fade.bot{bottom:0;background:linear-gradient(transparent,#000)}
|
| 28 |
.mxp-progress{height:3px;background:#222}.mxp-progress i{display:block;height:100%;width:0;background:#f5c518}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
.mxp-count{position:absolute;inset:0;display:grid;place-items:center;font-size:30vh;font-weight:700;color:#f5c518;background:rgba(0,0,0,.6);pointer-events:none}
|
| 30 |
`;
|
| 31 |
-
let wheel=0,keepOpen=false,root,scroller,textEl,guide,bar,raf=0,last=0,playing=false,speed=40,size=64,mirror=false,acc=0,countTimer=0,back=null;
|
| 32 |
|
| 33 |
function save(){try{localStorage.setItem("mathly.prompter",JSON.stringify({speed,size,mirror}))}catch{}}
|
| 34 |
function load(){try{const s=JSON.parse(localStorage.getItem("mathly.prompter")||"{}");if(s.speed)speed=s.speed;if(s.size)size=s.size;mirror=!!s.mirror}catch{}}
|
|
@@ -40,6 +44,7 @@ function style(){
|
|
| 40 |
|
| 41 |
function sync(){
|
| 42 |
textEl.style.fontSize=size+"px"; guide.style.height=(size*1.45)+"px";
|
|
|
|
| 43 |
root.classList.toggle("mirror",mirror); root.classList.toggle("playing",playing);
|
| 44 |
bar.querySelector(".mxp-play").textContent=playing?"โธ ืขืฆืืจื":"โถ ืืคืขืื";
|
| 45 |
bar.querySelector("[data-p=mirror]").setAttribute("aria-pressed",String(mirror));
|
|
@@ -75,7 +80,26 @@ function countdown(){
|
|
| 75 |
countTimer=setInterval(()=>{n--; if(n>0) el.textContent=n; else play(true);},700);
|
| 76 |
}
|
| 77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
function act(p){
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
switch(p){
|
| 80 |
case "play": return countdown();
|
| 81 |
case "slower": speed=Math.max(5,speed-5); break;
|
|
@@ -124,14 +148,15 @@ function open(text){
|
|
| 124 |
<button data-p="close" title="ืกืืืจื (Esc)" aria-label="ืกืืืจื">โ</button></div>
|
| 125 |
<div class="mxp-progress"><i></i></div>
|
| 126 |
<div class="mxp-view"><div class="mxp-scroll"><div class="mxp-text" dir="auto"></div></div>
|
| 127 |
-
<div class="mxp-fade top"></div><div class="mxp-fade bot"></div><div class="mxp-guide"></div>
|
|
|
|
| 128 |
scroller=root.querySelector(".mxp-scroll"); textEl=root.querySelector(".mxp-text"); guide=root.querySelector(".mxp-guide"); bar=root.querySelector(".mxp-bar");
|
| 129 |
textEl.textContent=text;
|
| 130 |
bar.addEventListener("click",e=>{const b=e.target.closest("[data-p]"); if(b) act(b.dataset.p);});
|
| 131 |
bar.querySelector(".mxp-speed").addEventListener("input",e=>{speed=+e.target.value; save(); sync();});
|
| 132 |
scroller.addEventListener("scroll",progress,{passive:true});
|
| 133 |
const view=root.querySelector(".mxp-view");
|
| 134 |
-
view.addEventListener("click",()=>
|
| 135 |
view.addEventListener("wheel",e=>{ // while playing the wheel is a speed knob: down faster, up slower
|
| 136 |
if(!playing){acc=0; return;}
|
| 137 |
e.preventDefault(); wheel+=e.deltaMode?e.deltaY*40:e.deltaY;
|
|
@@ -154,6 +179,7 @@ function open(text){
|
|
| 154 |
|
| 155 |
function close(){
|
| 156 |
if(!root) return;
|
|
|
|
| 157 |
play(false); document.removeEventListener("keydown",onKey,true); document.removeEventListener("fullscreenchange",onFull);
|
| 158 |
if(document.fullscreenElement) document.exitFullscreen?.();
|
| 159 |
root.remove(); root=null; back?.focus?.(); back=null;
|
|
|
|
| 26 |
.mxp-fade{position:absolute;inset-inline:0;height:22%;pointer-events:none}
|
| 27 |
.mxp-fade.top{top:0;background:linear-gradient(#000,transparent)}.mxp-fade.bot{bottom:0;background:linear-gradient(transparent,#000)}
|
| 28 |
.mxp-progress{height:3px;background:#222}.mxp-progress i{display:block;height:100%;width:0;background:#f5c518}
|
| 29 |
+
.mxp-hint{position:absolute;left:50%;bottom:8%;translate:-50% 0;z-index:2;display:flex;align-items:center;gap:10px;padding:12px 22px;border-radius:999px;background:rgba(20,20,20,.95);border:1px solid rgba(245,197,24,.6);color:#fff;font-size:22px;font-weight:600;white-space:nowrap;pointer-events:none;opacity:0;scale:.92;transition:opacity .25s ease,scale .25s ease}
|
| 30 |
+
.mxp-hint.on{opacity:1;scale:1}
|
| 31 |
+
.mxp-hint b{color:#f5c518;font-size:28px;line-height:1}
|
| 32 |
+
@media (prefers-reduced-motion:reduce){.mxp-hint{transition:opacity .15s linear;scale:1}}
|
| 33 |
.mxp-count{position:absolute;inset:0;display:grid;place-items:center;font-size:30vh;font-weight:700;color:#f5c518;background:rgba(0,0,0,.6);pointer-events:none}
|
| 34 |
`;
|
| 35 |
+
let hintTimer=0,wheel=0,keepOpen=false,root,scroller,textEl,guide,bar,raf=0,last=0,playing=false,speed=40,size=64,mirror=false,acc=0,countTimer=0,back=null;
|
| 36 |
|
| 37 |
function save(){try{localStorage.setItem("mathly.prompter",JSON.stringify({speed,size,mirror}))}catch{}}
|
| 38 |
function load(){try{const s=JSON.parse(localStorage.getItem("mathly.prompter")||"{}");if(s.speed)speed=s.speed;if(s.size)size=s.size;mirror=!!s.mirror}catch{}}
|
|
|
|
| 44 |
|
| 45 |
function sync(){
|
| 46 |
textEl.style.fontSize=size+"px"; guide.style.height=(size*1.45)+"px";
|
| 47 |
+
textEl.style.paddingTop=guide.offsetTop+"px"; // the first line starts on the reading line
|
| 48 |
root.classList.toggle("mirror",mirror); root.classList.toggle("playing",playing);
|
| 49 |
bar.querySelector(".mxp-play").textContent=playing?"โธ ืขืฆืืจื":"โถ ืืคืขืื";
|
| 50 |
bar.querySelector("[data-p=mirror]").setAttribute("aria-pressed",String(mirror));
|
|
|
|
| 80 |
countTimer=setInterval(()=>{n--; if(n>0) el.textContent=n; else play(true);},700);
|
| 81 |
}
|
| 82 |
|
| 83 |
+
// a short note at the bottom centre that fades in, stays a moment and fades out, so a mouse or clicker action is seen
|
| 84 |
+
function hint(icon,text){
|
| 85 |
+
const el=root?.querySelector(".mxp-hint"); if(!el) return;
|
| 86 |
+
el.innerHTML=""; const b=document.createElement("b"); b.textContent=icon; const span=document.createElement("span"); span.textContent=text;
|
| 87 |
+
el.append(b,span); el.classList.add("on");
|
| 88 |
+
clearTimeout(hintTimer); hintTimer=setTimeout(()=>el.classList.remove("on"),1100);
|
| 89 |
+
}
|
| 90 |
+
const HINTS={
|
| 91 |
+
play:()=>playing||root.querySelector(".mxp-count")?["โถ","ืืคืขืื"]:["โธ","ืขืฆืืจื"],
|
| 92 |
+
faster:()=>["โฉ",`ืืืืจืืช ${speed}`+(speed>=300?" (ืืงืกืืืื)":"")], slower:()=>["โช",`ืืืืจืืช ${speed}`+(speed<=5?" (ืืื ืืืื)":"")],
|
| 93 |
+
bigger:()=>["A+",`ืืืื ${size}`], smaller:()=>["Aโ",`ืืืื ${size}`],
|
| 94 |
+
mirror:()=>["โ",mirror?"ืืจืื: ืืืคืขืืช":"ืืจืื: ืืืืื"], top:()=>["โฎ","ืืืชืืื"], back:()=>["โถ","ืืื ืฉืืจืืช ืืืืจื"],
|
| 95 |
+
};
|
| 96 |
+
|
| 97 |
function act(p){
|
| 98 |
+
actOnly(p);
|
| 99 |
+
if(root&&HINTS[p]) hint(...HINTS[p]());
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
function actOnly(p){
|
| 103 |
switch(p){
|
| 104 |
case "play": return countdown();
|
| 105 |
case "slower": speed=Math.max(5,speed-5); break;
|
|
|
|
| 148 |
<button data-p="close" title="ืกืืืจื (Esc)" aria-label="ืกืืืจื">โ</button></div>
|
| 149 |
<div class="mxp-progress"><i></i></div>
|
| 150 |
<div class="mxp-view"><div class="mxp-scroll"><div class="mxp-text" dir="auto"></div></div>
|
| 151 |
+
<div class="mxp-fade top"></div><div class="mxp-fade bot"></div><div class="mxp-guide"></div>
|
| 152 |
+
<div class="mxp-hint" role="status" aria-live="polite"></div></div>`;
|
| 153 |
scroller=root.querySelector(".mxp-scroll"); textEl=root.querySelector(".mxp-text"); guide=root.querySelector(".mxp-guide"); bar=root.querySelector(".mxp-bar");
|
| 154 |
textEl.textContent=text;
|
| 155 |
bar.addEventListener("click",e=>{const b=e.target.closest("[data-p]"); if(b) act(b.dataset.p);});
|
| 156 |
bar.querySelector(".mxp-speed").addEventListener("input",e=>{speed=+e.target.value; save(); sync();});
|
| 157 |
scroller.addEventListener("scroll",progress,{passive:true});
|
| 158 |
const view=root.querySelector(".mxp-view");
|
| 159 |
+
view.addEventListener("click",()=>act("play"));
|
| 160 |
view.addEventListener("wheel",e=>{ // while playing the wheel is a speed knob: down faster, up slower
|
| 161 |
if(!playing){acc=0; return;}
|
| 162 |
e.preventDefault(); wheel+=e.deltaMode?e.deltaY*40:e.deltaY;
|
|
|
|
| 179 |
|
| 180 |
function close(){
|
| 181 |
if(!root) return;
|
| 182 |
+
clearTimeout(hintTimer);
|
| 183 |
play(false); document.removeEventListener("keydown",onKey,true); document.removeEventListener("fullscreenchange",onFull);
|
| 184 |
if(document.fullscreenElement) document.exitFullscreen?.();
|
| 185 |
root.remove(); root=null; back?.focus?.(); back=null;
|
tests/test_phone_browser.py
CHANGED
|
@@ -1228,6 +1228,10 @@ def test_prompter_from_management(phone_server):
|
|
| 1228 |
page.locator(".mxp-view").hover()
|
| 1229 |
page.mouse.wheel(0, 120) # while playing, the wheel is the speed
|
| 1230 |
playwright.expect(page.locator(".mxp-speed-v")).not_to_have_text(speed)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1231 |
page.locator(".mxp-view").click() # a click pauses
|
| 1232 |
playwright.expect(prompter).to_have_class("mxp mirror")
|
| 1233 |
page.keyboard.press("PageDown") # a presentation clicker plays
|
|
|
|
| 1228 |
page.locator(".mxp-view").hover()
|
| 1229 |
page.mouse.wheel(0, 120) # while playing, the wheel is the speed
|
| 1230 |
playwright.expect(page.locator(".mxp-speed-v")).not_to_have_text(speed)
|
| 1231 |
+
hint = prompter.locator(".mxp-hint") # the effect is shown at the bottom, then fades
|
| 1232 |
+
playwright.expect(hint).to_have_class("mxp-hint on")
|
| 1233 |
+
playwright.expect(hint).to_contain_text("ืืืืจืืช 45")
|
| 1234 |
+
playwright.expect(hint).to_have_class("mxp-hint", timeout=3000)
|
| 1235 |
page.locator(".mxp-view").click() # a click pauses
|
| 1236 |
playwright.expect(prompter).to_have_class("mxp mirror")
|
| 1237 |
page.keyboard.press("PageDown") # a presentation clicker plays
|