Skip to main content
You are currently in a sub-section of washk12.org
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
  <meta name="theme-color" content="#12304a">
  <meta name="description" content="An ad-free woodshop measurement game for learning to read an inch ruler and solve practical cut-list problems.">
  <title>Ruler Lab — Woodshop Measurement Practice</title>
  <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect x='5' y='13' width='54' height='38' rx='7' fill='%23f6c453'/%3E%3Cpath d='M13 13v15m8-15v9m8-9v15m8-15v9m8-9v15m8-15v9' stroke='%2312304a' stroke-width='4'/%3E%3C/svg%3E">
  <style>
    :root{--ink:#102a43;--muted:#62758a;--navy:#12304a;--blue:#1675a9;--sky:#e8f6fb;--paper:#fffdfa;--yellow:#f6c453;--green:#16866f;--red:#d44949;--line:#d6e1e8;--shadow:0 18px 50px rgba(18,48,74,.14)}
    *{box-sizing:border-box}
    html,body{min-height:100%;margin:0}
    body{font-family:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;color:var(--ink);background:linear-gradient(180deg,#e9f7fb 0,#f7fbfc 52%,#edf4f7 100%)}
    button,select,input{font:inherit}
    button{cursor:pointer}
    .app{min-height:100vh;display:flex;flex-direction:column}
    header{height:72px;display:flex;align-items:center;justify-content:space-between;padding:0 clamp(18px,4vw,64px);background:var(--navy);color:white}
    .brand{display:flex;align-items:center;gap:12px;font-weight:850;font-size:1.25rem;letter-spacing:.01em}
    .brand-icon{width:42px;height:30px;border-radius:7px;background:var(--yellow);position:relative;box-shadow:inset 0 -3px rgba(0,0,0,.08)}
    .brand-icon:after{content:"";position:absolute;inset:0;background:repeating-linear-gradient(90deg,transparent 0 7px,var(--navy) 7px 9px,transparent 9px 14px);clip-path:polygon(0 0,100% 0,100% 58%,0 58%)}
    .header-actions{display:flex;gap:8px}
    .icon-btn,.settings-btn{border:1px solid rgba(255,255,255,.35);background:rgba(255,255,255,.08);color:#fff;border-radius:10px;min-height:42px;padding:0 14px;font-weight:750}
    .icon-btn:hover,.settings-btn:hover{background:rgba(255,255,255,.16)}
    main{width:min(1500px,100%);margin:auto;padding:clamp(18px,3vw,40px) clamp(14px,4vw,58px) 36px;flex:1;display:flex;flex-direction:column;justify-content:center;gap:18px}
    .session-picker{align-self:center;display:grid;grid-template-columns:repeat(3,1fr);gap:5px;background:#dce9ee;border:1px solid #c8d9e1;padding:5px;border-radius:15px}
    .session-btn{border:0;background:transparent;color:#50687a;border-radius:10px;padding:11px 18px;font-weight:850;min-width:150px}
    .session-btn[aria-selected="true"]{background:white;color:var(--navy);box-shadow:0 3px 12px rgba(18,48,74,.12)}
    .session-btn small{display:block;font-size:.7rem;font-weight:700;color:#718495;margin-top:2px}
    .challenge-levels{align-self:center;display:flex;align-items:center;gap:8px;padding:6px;background:#fff;border:1px solid var(--line);border-radius:13px;box-shadow:0 4px 16px rgba(18,48,74,.07)}
    .challenge-levels[hidden]{display:none}.challenge-label{font-size:.76rem;color:var(--muted);text-transform:uppercase;letter-spacing:.08em;font-weight:900;padding:0 7px}.level-btn{border:1px solid transparent;background:transparent;color:#536c7e;border-radius:9px;padding:8px 13px;font-weight:850}.level-btn[aria-selected="true"]{background:var(--blue);color:white}.difficulty-note{font-size:.78rem;color:var(--muted);font-weight:700;padding:0 8px;max-width:280px}
    .topline{display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:18px}
    .mode-pill{justify-self:start;display:inline-flex;align-items:center;gap:8px;background:#d9f3ed;color:#116653;padding:8px 12px;border-radius:999px;font-weight:800;font-size:.85rem;text-transform:uppercase;letter-spacing:.06em}
    .mode-pill:before{content:"";width:8px;height:8px;border-radius:50%;background:var(--green)}
    .prompt{text-align:center;min-width:min(430px,60vw)}
    .eyebrow{font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:.12em;font-size:.82rem;margin:0 0 4px}
    #target{font-size:clamp(2.1rem,5vw,4.8rem);line-height:1;font-weight:900;letter-spacing:-.045em;margin:0;color:var(--navy);font-variant-numeric:tabular-nums}
    #target small{font-size:.45em;letter-spacing:0;color:var(--muted);font-weight:800}
    .best{justify-self:end;text-align:right;color:var(--muted);font-weight:700}
    .best strong{display:block;color:var(--ink);font-size:1.15rem}
    .game-card{background:var(--paper);border:1px solid rgba(18,48,74,.12);border-radius:22px;box-shadow:var(--shadow);overflow:hidden}
    .ruler-legend{display:flex;align-items:center;justify-content:center;gap:18px;flex-wrap:wrap;margin:0 0 10px;color:#526b7d;font-size:.8rem;font-weight:800}
    .legend-item{display:inline-flex;align-items:center;gap:6px}.legend-line{display:inline-block;width:24px;border-top:4px solid;border-radius:2px}.legend-quarter{border-color:#2463eb}.legend-eighth{border-color:#0f8a72}.legend-sixteenth{border-color:#d97706}
    .ruler-shell{padding:34px 20px 18px;position:relative;overflow:hidden}
    #ruler{width:100%;height:260px;display:block;touch-action:none;user-select:none}
    .hint{text-align:center;color:var(--muted);font-weight:650;margin:0 0 18px;min-height:24px;padding:0 14px}
    .learn-note{display:none;width:min(660px,calc(100% - 28px));margin:-4px auto 18px;padding:12px 16px;border-radius:12px;background:#e8f6fb;border:1px solid #b9ddea;color:#24526c;text-align:center;font-weight:750}
    .learn-note.show{display:block}
    .type-row{display:none;align-items:center;justify-content:center;gap:10px;margin:0 auto 22px;padding:0 16px}
    .type-row.show{display:flex}
    #answer{width:min(320px,70vw);height:52px;border:2px solid var(--line);border-radius:12px;padding:0 16px;font-size:1.2rem;font-weight:750;color:var(--ink);background:white}
    #answer:focus{outline:3px solid rgba(22,117,169,.2);border-color:var(--blue)}
    .primary{height:52px;border:0;border-radius:12px;background:var(--blue);color:white;padding:0 22px;font-weight:850;box-shadow:0 6px 16px rgba(22,117,169,.22)}
    .primary:hover{background:#0e668f}
    .stats{display:grid;grid-template-columns:repeat(4,1fr);background:#f7fafb;border-top:1px solid var(--line)}
    .stat{padding:18px 22px;text-align:center;border-right:1px solid var(--line)}
    .stat:last-child{border:0}
    .stat-label{display:block;color:var(--muted);font-size:.75rem;text-transform:uppercase;letter-spacing:.1em;font-weight:850;margin-bottom:5px}
    .stat-value{font-size:1.45rem;font-weight:900;font-variant-numeric:tabular-nums}
    .timer-track{height:7px;background:#dfe8ec;border-radius:5px;overflow:hidden;margin:7px auto 0;max-width:120px}
    #timerBar{height:100%;width:100%;background:var(--green);transition:width .08s linear,background .2s}
    .strikes{display:flex;justify-content:center;gap:7px;margin-top:5px}
    .strike{width:12px;height:12px;border:2px solid #c6d2d9;border-radius:50%}
    .strike.on{border-color:var(--red);background:var(--red)}
    .controls{display:flex;justify-content:center;gap:10px}
    .start{min-width:190px;height:54px;border:0;border-radius:13px;background:var(--yellow);color:#352808;font-weight:900;font-size:1.05rem;box-shadow:0 7px 0 #dca62e}
    .start:active{transform:translateY(4px);box-shadow:0 3px 0 #dca62e}
    .secondary{height:54px;border:1px solid #bfcfd7;border-radius:13px;background:#fff;color:var(--navy);padding:0 18px;font-weight:800}
    .footer-note{text-align:center;color:#718495;font-size:.84rem;margin-top:4px}
    dialog{border:0;border-radius:20px;padding:0;width:min(620px,calc(100vw - 28px));color:var(--ink);box-shadow:0 28px 90px rgba(4,25,44,.32)}
    dialog::backdrop{background:rgba(6,28,45,.62);backdrop-filter:blur(3px)}
    .modal-head{display:flex;align-items:center;justify-content:space-between;padding:22px 24px 16px;border-bottom:1px solid var(--line)}
    .modal-head h2{margin:0;font-size:1.4rem}
    .close{border:0;background:#edf3f6;color:var(--ink);width:38px;height:38px;border-radius:10px;font-size:1.4rem}
    .settings-grid{padding:22px 24px;display:grid;grid-template-columns:1fr 1fr;gap:18px}
    label{display:grid;gap:7px;font-weight:800;font-size:.88rem;color:#334e68}
    select,input[type="text"]{height:46px;border:1px solid #bfcfd7;border-radius:10px;background:white;padding:0 12px;color:var(--ink)}
    .span-2{grid-column:1/-1}.teacher-tools{grid-column:1/-1;background:#f1f7f9;border:1px solid var(--line);border-radius:13px;padding:14px;display:flex;align-items:center;justify-content:space-between;gap:14px}.teacher-tools p{margin:0;color:var(--muted);font-size:.84rem;line-height:1.35}.compact{height:42px;white-space:nowrap}
    .modal-foot{display:flex;justify-content:flex-end;gap:10px;padding:16px 24px 24px}
    .result-overlay{position:fixed;inset:0;z-index:20;display:none;place-items:center;background:rgba(7,28,45,.62);padding:16px}
    .result-overlay.show{display:grid}
    .result-card{background:#fff;border-radius:22px;padding:30px;width:min(520px,100%);text-align:center;box-shadow:var(--shadow)}
    .result-card h2{font-size:2rem;margin:0 0 8px}.result-card p{color:var(--muted);font-size:1.05rem;margin:0 0 18px}.result-actions{display:grid;grid-template-columns:1fr 1fr;gap:10px}.result-actions .primary{width:100%}.mastery-badge{display:inline-block;border-radius:999px;padding:8px 14px;background:#dff5ed;color:#126b57;font-weight:900;margin:4px 0 16px}.mastery-badge.not-yet{background:#fff1d2;color:#855d08}.result-grid{display:grid;grid-template-columns:1fr 1fr;gap:1px;background:var(--line);border:1px solid var(--line);border-radius:13px;overflow:hidden;margin:0 0 20px}.result-item{background:#f8fbfc;padding:12px}.result-item span{display:block;font-size:.72rem;color:var(--muted);text-transform:uppercase;letter-spacing:.08em;font-weight:850}.result-item strong{display:block;margin-top:3px;font-size:1.15rem}.result-name{font-weight:850!important;color:var(--ink)!important;margin-bottom:6px!important}
    .toast{position:fixed;left:50%;top:90px;transform:translate(-50%,-20px);opacity:0;pointer-events:none;padding:12px 18px;border-radius:999px;color:#fff;font-weight:850;z-index:30;transition:.22s;background:var(--green);box-shadow:0 10px 30px rgba(0,0,0,.18)}
    .toast.show{opacity:1;transform:translate(-50%,0)}.toast.bad{background:var(--red)}
    @media(max-width:760px){header{height:62px}.settings-btn span{display:none}.session-picker{width:100%}.session-btn{min-width:0;padding:10px 5px;font-size:.83rem}.session-btn small{display:none}.challenge-levels{width:100%;justify-content:center;flex-wrap:wrap}.challenge-label{width:100%;text-align:center}.difficulty-note{width:100%;max-width:none;text-align:center}.topline{grid-template-columns:1fr auto}.prompt{grid-column:1/-1;grid-row:1}.mode-pill{grid-row:2}.best{grid-row:2}.ruler-shell{padding:24px 6px 12px}#ruler{height:220px}.stats{grid-template-columns:1fr 1fr}.stat:nth-child(2){border-right:0}.stat:nth-child(-n+2){border-bottom:1px solid var(--line)}.settings-grid{grid-template-columns:1fr}.span-2,.teacher-tools{grid-column:auto}.teacher-tools{align-items:stretch;flex-direction:column}.type-row{flex-direction:column}.type-row .primary,#answer{width:100%}.result-actions{grid-template-columns:1fr}}
    @media print{body{background:white}.app,.toast{display:none!important}.result-overlay{position:static;display:block!important;background:white;padding:0}.result-card{box-shadow:none;border:2px solid #12304a;width:100%;max-width:720px;margin:20px auto}.result-actions{display:none}.result-card:after{content:"Ruler Lab • CTE Makes School Matter";display:block;margin-top:24px;color:#62758a;font-size:.82rem}}
    @media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important}}
  </style>
</head>
<body>
  <div class="app">
    <header>
      <div class="brand"><span class="brand-icon" aria-hidden="true"></span>Ruler Lab <span style="font-weight:600;opacity:.65">| Woodshop</span></div>
      <div class="header-actions">
        <button class="icon-btn" id="fullscreen" aria-label="Enter full screen" title="Full screen">⛶</button>
        <button class="settings-btn" id="openSettings" aria-haspopup="dialog">⚙ <span>Settings</span></button>
      </div>
    </header>
    <main>
      <nav class="session-picker" aria-label="Learning mode">
        <button class="session-btn" data-session="learn" aria-selected="false">Learn<small>Guided help</small></button>
        <button class="session-btn" data-session="practice" aria-selected="true">Practice<small>Build accuracy</small></button>
        <button class="session-btn" data-session="challenge" aria-selected="false">Shop Challenge<small>20 questions</small></button>
      </nav>
      <div class="challenge-levels" id="challengeLevels" hidden>
        <span class="challenge-label">Difficulty</span>
        <button class="level-btn" data-level="easy" aria-selected="false">Easy</button>
        <button class="level-btn" data-level="medium" aria-selected="true">Medium</button>
        <button class="level-btn" data-level="hard" aria-selected="false">Hard</button>
        <span class="difficulty-note" id="difficultyNote">Sixteenths and one-step shop math</span>
      </div>
      <div class="topline">
        <div class="mode-pill" id="modeLabel">Find the mark</div>
        <div class="prompt" aria-live="polite">
          <p class="eyebrow" id="promptLabel">Ready to measure?</p>
          <p id="target">Press Start</p>
        </div>
        <div class="best">Session best<strong id="best">0</strong></div>
      </div>
      <div class="controls">
        <button class="start" id="start">Start game</button>
        <button class="secondary" id="newQuestion" hidden>Skip</button>
      </div>
      <section class="game-card" aria-label="Ruler game">
        <div class="ruler-shell">
          <div class="ruler-legend" id="rulerLegend" aria-label="Ruler mark color key"></div>
          <canvas id="ruler" tabindex="0" role="slider" aria-label="Interactive inch ruler. Use arrow keys and Enter, or move and click or tap, to choose a measurement." aria-valuemin="0" aria-valuemax="64" aria-valuenow="0"></canvas>
        </div>
        <p class="hint" id="hint">Choose your settings, then press Start.</p>
        <div class="learn-note" id="learnNote"></div>
        <div class="type-row" id="typeRow">
          <input id="answer" type="text" inputmode="text" autocomplete="off" placeholder='Example: 2 3/8 or 2.375' aria-label="Measurement answer">
          <button class="primary" id="submit">Check answer</button>
        </div>
        <div class="stats">
          <div class="stat"><span class="stat-label">Score</span><span class="stat-value" id="score">0</span></div>
          <div class="stat"><span class="stat-label">Time</span><span class="stat-value" id="time">—</span><div class="timer-track"><div id="timerBar"></div></div></div>
          <div class="stat"><span class="stat-label" id="levelLabel">Level</span><span class="stat-value" id="level">1</span></div>
          <div class="stat"><span class="stat-label" id="strikeLabel">Strikes</span><div class="strikes" id="strikes"><i class="strike"></i><i class="strike"></i><i class="strike"></i></div><span class="stat-value" id="mistakeCount" hidden>0</span></div>
        </div>
      </section>
      <p class="footer-note">Ad-free • No sign-in • CTE Makes School Matter</p>
    </main>
  </div>

  <dialog id="settingsDialog">
    <form method="dialog" id="settingsForm">
      <div class="modal-head"><h2>Game settings</h2><button class="close" value="cancel" aria-label="Close settings">×</button></div>
      <div class="settings-grid">
        <label class="span-2">Student name <input id="studentName" type="text" maxlength="40" placeholder="Optional — appears on results"></label>
        <label>Game mode<select id="mode"><option value="find">Find the mark</option><option value="type">Read and type</option></select></label>
        <label>Question precision<select id="questions"><option value="1">Whole inches</option><option value="2">Halves</option><option value="4">Quarters</option><option value="8">Eighths</option><option value="16" selected>Sixteenths</option><option value="32">Thirty-seconds</option><option value="64">Sixty-fourths</option></select></label>
        <label>Ruler marks<select id="marks"><option value="2">Halves</option><option value="4">Quarters</option><option value="8">Eighths</option><option value="16" selected>Sixteenths</option><option value="32">Thirty-seconds</option><option value="64">Sixty-fourths</option></select></label>
        <label>Ruler length<select id="length"><option value="auto">Automatic</option><option value="1">1 inch</option><option value="2">2 inches</option><option value="3">3 inches</option><option value="4" selected>4 inches</option><option value="6">6 inches</option><option value="8">8 inches</option><option value="10">10 inches</option><option value="12">12 inches</option></select></label>
        <label>Answer notation<select id="notation"><option value="fraction">Fractions</option><option value="decimal">Decimals</option></select></label>
        <label>Fraction style<select id="fractionStyle"><option value="both">Mixed practice</option><option value="simplified">Simplified</option><option value="unsimplified">Unsimplified</option></select></label>
        <label>Timer<select id="timer"><option value="on">On</option><option value="off">Off</option></select></label>
        <label>Sounds<select id="sound"><option value="on">On</option><option value="off">Off</option></select></label>
        <label>Measure twice<select id="measureTwice"><option value="off">Off</option><option value="on">On</option></select></label>
        <div class="teacher-tools"><p><strong>Teacher preset link</strong><br>Opens with these settings already selected.</p><button type="button" class="secondary compact" id="copyPreset">Copy link</button></div>
      </div>
      <div class="modal-foot"><button class="secondary" value="cancel">Cancel</button><button class="primary" id="saveSettings" value="default">Save settings</button></div>
    </form>
  </dialog>
  <div class="result-overlay" id="gameOver"><div class="result-card">
    <p class="eyebrow" id="resultEyebrow">Session complete</p><h2 id="finalScore">Score: 0</h2><p class="result-name" id="resultName"></p><div class="mastery-badge" id="masteryBadge">Keep practicing</div>
    <div class="result-grid"><div class="result-item"><span>Accuracy</span><strong id="resultAccuracy">0%</strong></div><div class="result-item"><span>Correct</span><strong id="resultCorrect">0</strong></div><div class="result-item"><span>Precision</span><strong id="resultPrecision">1/16″</strong></div><div class="result-item"><span>Date</span><strong id="resultDate">—</strong></div></div>
    <p id="finalMessage">Nice work. Every mark gets easier with practice.</p><div class="result-actions"><button class="secondary" id="printResults">Print / Save PDF</button><button class="primary" id="playAgain">Try again</button></div>
  </div></div>
  <div class="toast" id="toast" role="status" aria-live="polite"></div>

  <script>
  (()=>{
    const $=s=>document.querySelector(s), canvas=$('#ruler'), ctx=canvas.getContext('2d');
    const els={target:$('#target'),prompt:$('#promptLabel'),hint:$('#hint'),note:$('#learnNote'),score:$('#score'),level:$('#level'),levelLabel:$('#levelLabel'),time:$('#time'),timerBar:$('#timerBar'),strikes:$('#strikes'),mistakes:$('#mistakeCount'),strikeLabel:$('#strikeLabel'),start:$('#start'),skip:$('#newQuestion'),typeRow:$('#typeRow'),answer:$('#answer'),modeLabel:$('#modeLabel'),toast:$('#toast'),best:$('#best')};
    const inputs=['mode','questions','marks','length','notation','fractionStyle','timer','sound','measureTwice'];
    const defaults={session:'practice',challengeLevel:'medium',mode:'find',questions:'16',marks:'16',length:'4',notation:'fraction',fractionStyle:'both',timer:'on',sound:'on',measureTwice:'off',studentName:''};
    let settings={...defaults,...JSON.parse(localStorage.getItem('rulerLabSettings')||'{}')};
    const preset=new URLSearchParams(location.search);['session','challengeLevel',...inputs].forEach(k=>{if(preset.has(k))settings[k]=preset.get(k)});if(+settings.questions>+settings.marks)settings.marks=settings.questions;
    let state={playing:false,score:0,correct:0,answered:0,strikes:0,level:1,answerUnits:0,answerDen:+settings.questions,hoverUnits:null,shownUnits:null,firstMeasure:null,questionKind:'find',expectsFind:true,startTime:0,duration:10,raf:0,locked:false};
    let geom={x:0,y:0,w:0,h:0,inch:0,length:4};
    inputs.forEach(id=>$('#'+id).value=settings[id]??defaults[id]);$('#studentName').value=settings.studentName||'';
    function gcd(a,b){while(b)[a,b]=[b,a%b];return a||1}
    function autoLength(){const width=canvas.clientWidth;return width<520?2:width<820?3:width<1120?4:6}
    function rulerLength(){return settings.length==='auto'?autoLength():+settings.length}
    function challengeDen(){return settings.challengeLevel==='easy'?8:16}
    function rulerMarks(){return settings.session==='challenge'?16:+settings.marks}
    function format(units,denom=+settings.questions,forceStyle=settings.fractionStyle){
      const value=units/denom;if(settings.notation==='decimal')return value.toFixed(4).replace(/0+$/,'').replace(/\.$/,'')+'″';
      const whole=Math.floor(units/denom),n=units%denom;if(!n)return whole+'″';let num=n,d=denom;
      const style=forceStyle==='both'?(Math.random()<.5?'simplified':'unsimplified'):forceStyle;if(style==='simplified'){const g=gcd(num,d);num/=g;d/=g}
      return(whole?whole+' ':'')+num+'/'+d+'″';
    }
    function precisionName(){if(settings.session==='challenge')return settings.challengeLevel[0].toUpperCase()+settings.challengeLevel.slice(1)+' · 1/'+challengeDen()+'″';return({1:'Whole',2:'1/2″',4:'1/4″',8:'1/8″',16:'1/16″',32:'1/32″',64:'1/64″'})[settings.questions]}
    function unitsAtMarks(units,den=state.answerDen){return Math.round(units*rulerMarks()/den)}
    function tickFamily(frac,marks){if(frac===0||frac===marks/2)return'major';const pos=frac/marks;if(Number.isInteger(pos*4))return'quarter';if(Number.isInteger(pos*8))return'eighth';if(Number.isInteger(pos*16))return'sixteenth';return'fine'}
    function tickColor(family){const colors={major:'#12304a',quarter:'#2463eb',eighth:'#0f8a72',sixteenth:'#d97706',fine:'#8797a4'};return settings.session==='learn'?colors[family]:'#12304a'}
    function updateLegend(){const marks=rulerMarks(),items=[];if(settings.session==='learn'&&marks>=4)items.push(['quarter','¼-inch']);if(settings.session==='learn'&&marks>=8)items.push(['eighth','⅛-inch']);if(settings.session==='learn'&&marks>=16)items.push(['sixteenth','¹⁄₁₆-inch']);const legend=$('#rulerLegend');legend.hidden=!items.length;legend.innerHTML=items.map(([c,t])=>'<span class="legend-item"><i class="legend-line legend-'+c+'"></i>'+t+'</span>').join('')}
    function draw(){
      const dpr=Math.min(devicePixelRatio||1,2),rect=canvas.getBoundingClientRect();
      if(canvas.width!==Math.round(rect.width*dpr)||canvas.height!==Math.round(rect.height*dpr)){canvas.width=Math.round(rect.width*dpr);canvas.height=Math.round(rect.height*dpr)}
      ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,rect.width,rect.height);const len=rulerLength(),pad=Math.max(20,rect.width*.025),top=26,base=rect.height-35,usable=rect.width-pad*2;geom={x:pad,y:top,w:usable,h:base-top,inch:usable/len,length:len};
      ctx.fillStyle='#fff9df';ctx.strokeStyle='#12304a';ctx.lineWidth=2.5;roundRect(ctx,pad,top,usable,base-top,8);ctx.fill();ctx.stroke();const marks=rulerMarks(),total=len*marks;
      for(let i=0;i<=total;i++){const x=pad+i/total*usable,frac=i%marks,family=tickFamily(frac,marks);let tick;if(i===0||i===total||frac===0)tick=78;else{let pow=0,n=frac;while(n%2===0){pow++;n/=2}tick=Math.min(20+pow*12,64)}ctx.beginPath();ctx.moveTo(x,top);ctx.lineTo(x,top+tick);ctx.strokeStyle=tickColor(family);ctx.lineWidth=family==='major'?2.8:2.2;ctx.stroke();if(frac===0&&i>0){ctx.fillStyle='#12304a';ctx.font='800 25px Inter,system-ui';ctx.textAlign='center';ctx.fillText(String(i/marks),x,top+104)}}
      const active=state.playing&&!state.locked&&state.expectsFind?state.hoverUnits:null;if(active!==null){const x=pad+active/(marks*len)*usable;ctx.fillStyle='rgba(22,134,111,.22)';ctx.fillRect(Math.max(pad,x-geom.inch/marks/2),top+2,Math.max(4,geom.inch/marks),base-top-4);ctx.beginPath();ctx.moveTo(x,top);ctx.lineTo(x,base);ctx.strokeStyle='#16866f';ctx.lineWidth=4;ctx.stroke();canvas.setAttribute('aria-valuenow',String(active));canvas.setAttribute('aria-valuetext',format(active,marks,'simplified'))}
      canvas.setAttribute('aria-valuemax',String(marks*len));if(state.shownUnits!==null){const x=pad+state.shownUnits/(marks*len)*usable;ctx.beginPath();ctx.moveTo(x,top-5);ctx.lineTo(x,base+2);ctx.strokeStyle='#d44949';ctx.lineWidth=4;ctx.stroke();ctx.fillStyle='#d44949';ctx.beginPath();ctx.moveTo(x-8,top-10);ctx.lineTo(x+8,top-10);ctx.lineTo(x,top+1);ctx.fill()}
      if(state.questionKind==='read'&&state.playing){const m=unitsAtMarks(state.answerUnits),x=pad+m/(marks*len)*usable;ctx.fillStyle='#a11d48';ctx.beginPath();ctx.moveTo(x-11,top-15);ctx.lineTo(x+11,top-15);ctx.lineTo(x,top+1);ctx.fill();ctx.beginPath();ctx.moveTo(x,top);ctx.lineTo(x,base);ctx.strokeStyle='#a11d48';ctx.lineWidth=3;ctx.stroke()}updateLegend()
    }
    function roundRect(c,x,y,w,h,r){c.beginPath();c.roundRect?c.roundRect(x,y,w,h,r):c.rect(x,y,w,h)}
    function randomUnits(max,avoid=0){let u;do{u=Math.max(1,Math.floor(Math.random()*max))}while(u===avoid);return u}
    function learningHelp(units,den){const whole=Math.floor(units/den),n=units%den;if(!n)return'Find the long line for '+whole+' whole inch'+(whole===1?'':'es')+'.';const g=gcd(n,den),sn=n/g,sd=den/g;return'Break it down: '+(whole?whole+' whole inch'+(whole===1?'':'es')+' plus ':'')+sn+' of '+sd+' equal parts.'}
    function showType(on){els.typeRow.classList.toggle('show',on);if(on)setTimeout(()=>els.answer.focus(),60)}
    function newQuestion(){
      state.locked=false;state.shownUnits=null;state.hoverUnits=null;state.firstMeasure=null;els.answer.value='';els.note.classList.remove('show');const den=settings.session==='challenge'?challengeDen():+settings.questions,len=rulerLength();state.answerDen=den;
      let kind=settings.mode==='find'?'find':'read';if(settings.session==='challenge'){const sets={easy:['find','read','cutlist','find','midpoint'],medium:['find','read','cutlist','midpoint','remaining'],hard:['read','cutlist','remaining','midpoint','multiCut','difference']};const kinds=sets[settings.challengeLevel];kind=kinds[state.answered%kinds.length]}
      state.questionKind=kind;state.expectsFind=kind==='find'||kind==='cutlist';
      if(kind==='find'||kind==='read'||kind==='cutlist'){state.answerUnits=randomUnits(len*den,state.answerUnits)}
      if(kind==='find'){els.prompt.textContent='Click the measurement';els.target.textContent=format(state.answerUnits,den);els.hint.textContent=settings.measureTwice==='on'?'Measure it twice. Both selections must match.':'Move over the ruler, then click or tap the correct mark.';showType(false)}
      if(kind==='read'){els.prompt.textContent='Read the marked line';els.target.innerHTML='<small>Type your answer below</small>';els.hint.textContent='Enter a fraction or decimal measurement.';showType(true)}
      if(kind==='cutlist'){const parts=['Side rail','Shelf support','Face-frame piece','Drawer spacer'];els.prompt.textContent='Cut list: '+parts[state.answered%parts.length];els.target.textContent=format(state.answerUnits,den,'simplified');els.hint.textContent='Select this cut length on the ruler.';showType(false)}
      if(kind==='midpoint'){state.answerUnits=randomUnits(Math.max(2,Math.floor(6*den)));const boardUnits=state.answerUnits*2;els.prompt.textContent='Find the midpoint';els.target.textContent=format(boardUnits,den,'simplified')+' board';els.hint.textContent='What measurement marks the exact center?';showType(true)}
      if(kind==='remaining'){const totalInches=6+Math.floor(Math.random()*7),totalUnits=totalInches*den,cutUnits=randomUnits(totalUnits-1);state.answerUnits=totalUnits-cutUnits;els.prompt.textContent='Board remaining';els.target.style.fontSize='clamp(1.55rem,3.4vw,3.2rem)';els.target.textContent=format(totalUnits,den,'simplified')+' − '+format(cutUnits,den,'simplified');els.hint.textContent='Type the board length left after the cut.';showType(true)}else els.target.style.fontSize='';
      if(kind==='multiCut'){const totalInches=10+Math.floor(Math.random()*3),totalUnits=totalInches*den,maxPiece=Math.floor((totalUnits-den)/3),pieceUnits=den+Math.floor(Math.random()*Math.max(1,maxPiece-den));state.answerUnits=totalUnits-pieceUnits*2;els.prompt.textContent='Two pieces from one board';els.target.style.fontSize='clamp(1.45rem,3vw,2.8rem)';els.target.textContent=format(totalUnits,den,'simplified')+' − 2 × '+format(pieceUnits,den,'simplified');els.hint.textContent='Ignore saw kerf. How much stock remains?';showType(true)}
      if(kind==='difference'){const planned=2*den+randomUnits(4*den),gap=randomUnits(den),actual=planned-gap;state.answerUnits=gap;els.prompt.textContent='Measurement difference';els.target.style.fontSize='clamp(1.45rem,3vw,2.8rem)';els.target.textContent=format(planned,den,'simplified')+' planned · '+format(actual,den,'simplified')+' actual';els.hint.textContent='How much shorter is the actual piece?';showType(true)}
      if(settings.session==='learn'){els.note.textContent=learningHelp(state.answerUnits,den);els.note.classList.add('show')}
      setStatMode();startClock();draw();
    }
    function startGame(){cancelAnimationFrame(state.raf);state={...state,playing:true,score:0,correct:0,answered:0,strikes:0,level:1,locked:false,shownUnits:null,firstMeasure:null};els.score.textContent='0';els.start.textContent='Restart';els.skip.hidden=settings.session==='learn';$('#gameOver').classList.remove('show');updateStrikes();newQuestion()}
    function startClock(){cancelAnimationFrame(state.raf);state.duration=state.expectsFind?Math.max(3,11-state.level):Math.max(6,20-(state.level-1)*2);if(settings.session==='challenge')state.duration+=settings.challengeLevel==='easy'?6:settings.challengeLevel==='hard'?-2:0;state.startTime=performance.now();if(settings.timer==='off'||settings.session==='learn'){els.time.textContent='∞';els.timerBar.style.width='100%';els.timerBar.style.background='#16866f';return}const tick=now=>{if(!state.playing||state.locked)return;const left=Math.max(0,state.duration-(now-state.startTime)/1000),pct=left/state.duration*100;els.time.textContent=Math.ceil(left)+'s';els.timerBar.style.width=pct+'%';els.timerBar.style.background=pct<25?'#d44949':pct<55?'#e7ad29':'#16866f';if(left<=0){check(null);return}state.raf=requestAnimationFrame(tick)};state.raf=requestAnimationFrame(tick)}
    function parseAnswer(v){v=v.trim().replace(/[″"]/g,'');if(!v)return null;if(/^\d*\.?\d+$/.test(v))return+v;const m=v.match(/^(?:(\d+)\s+)?(\d+)\/(\d+)$/);if(!m)return null;return+(m[1]||0)+(+m[2]/+m[3])}
    function check(selectedMark){
      if(!state.playing||state.locked)return;if(state.expectsFind&&selectedMark!==null&&settings.measureTwice==='on'){if(state.firstMeasure===null){state.firstMeasure=selectedMark;els.hint.textContent='First measurement saved. Move away, then measure it again.';toast('First measurement saved',false);return}if(state.firstMeasure!==selectedMark){state.firstMeasure=null;els.hint.textContent='Those measurements did not match. Start again.';toast('Measurements did not match',true);return}}
      state.locked=true;cancelAnimationFrame(state.raf);let ok=false;if(state.expectsFind)ok=selectedMark!==null&&selectedMark===unitsAtMarks(state.answerUnits);else{const v=parseAnswer(els.answer.value);ok=v!==null&&Math.abs(v-state.answerUnits/state.answerDen)<1e-7}
      state.answered++;if(ok){state.correct++;state.score+=state.level*10;els.score.textContent=state.score;toast('Correct! +'+state.level*10,false);sound(true)}else{state.strikes++;const correct=format(state.answerUnits,state.answerDen,'simplified');toast(selectedMark===null?'Time’s up — '+correct:'Correct answer: '+correct,true);sound(false);if(state.expectsFind)state.shownUnits=unitsAtMarks(state.answerUnits);draw()}
      if(settings.session!=='challenge'&&state.correct>0&&state.correct%10===0){state.level=Math.min(10,Math.floor(state.correct/10)+1);els.level.textContent=state.level}
      updateStrikes();if(settings.session==='learn'&&!ok)els.note.textContent='The correct mark is highlighted. '+learningHelp(state.answerUnits,state.answerDen);
      const done=settings.session==='challenge'&&state.answered>=20||settings.session==='practice'&&state.strikes>=3;if(done)setTimeout(endGame,1100);else setTimeout(newQuestion,ok?700:1500)
    }
    function setStatMode(){const challenge=settings.session==='challenge';els.levelLabel.textContent=challenge?'Question':'Level';els.level.textContent=challenge?(Math.min(20,state.answered+1)+'/20'):state.level;els.strikeLabel.textContent=challenge?'Missed':'Strikes';els.strikes.hidden=challenge;els.mistakes.hidden=!challenge;if(challenge)els.mistakes.textContent=state.strikes}
    function updateStrikes(){[...els.strikes.children].forEach((x,i)=>x.classList.toggle('on',i<state.strikes));els.mistakes.textContent=state.strikes;setStatMode()}
    function endGame(){state.playing=false;cancelAnimationFrame(state.raf);const total=Math.max(1,state.answered),accuracy=Math.round(state.correct/total*100),mastered=settings.session==='challenge'&&accuracy>=90,best=Math.max(state.score,+localStorage.getItem('rulerLabBest')||0);localStorage.setItem('rulerLabBest',best);els.best.textContent=best;$('#resultEyebrow').textContent=settings.session==='challenge'?'Shop challenge complete':'Practice complete';$('#finalScore').textContent=settings.session==='challenge'?(mastered?'Measurement mastered':'Keep building accuracy'):'Score: '+state.score;$('#resultName').textContent=settings.studentName||'Ruler Lab Student';$('#masteryBadge').textContent=settings.session==='challenge'?(mastered?'Mastered • 90%+':'Not yet mastered • Goal: 90%'):'Practice session';$('#masteryBadge').classList.toggle('not-yet',!mastered);$('#resultAccuracy').textContent=accuracy+'%';$('#resultCorrect').textContent=state.correct+' / '+state.answered;$('#resultPrecision').textContent=precisionName();$('#resultDate').textContent=new Date().toLocaleDateString();$('#finalMessage').textContent=mastered?'Ready to apply this skill in the shop.':accuracy>=75?'Very close—review the marks you missed and try again.':'Slow down, count the spaces, and measure twice.';$('#gameOver').classList.add('show');els.start.textContent='Start '+sessionName();els.skip.hidden=true}
    function sessionName(){return settings.session==='challenge'?'challenge':settings.session}
    function updateSessionUI(){document.querySelectorAll('.session-btn').forEach(b=>b.setAttribute('aria-selected',String(b.dataset.session===settings.session)));const challenge=settings.session==='challenge',notes={easy:'Eighth-inch questions on a full sixteenth-inch ruler',medium:'Sixteenths, midpoints, and one-step remaining length',hard:'Sixteenths, multi-cut stock, differences, and mixed calculations'};$('#challengeLevels').hidden=!challenge;document.querySelectorAll('.level-btn').forEach(b=>b.setAttribute('aria-selected',String(b.dataset.level===settings.challengeLevel)));$('#difficultyNote').textContent=notes[settings.challengeLevel];els.modeLabel.textContent=settings.session==='learn'?'Color-guided learning':challenge?'Shop Challenge · '+settings.challengeLevel[0].toUpperCase()+settings.challengeLevel.slice(1):'Independent practice';els.start.textContent='Start '+sessionName();setStatMode();if(!state.playing){els.prompt.textContent=challenge?'20-question '+settings.challengeLevel+' challenge':settings.session==='learn'?'Learn each ruler mark':'Build speed and accuracy';els.target.textContent='Press Start';els.hint.textContent=challenge?notes[settings.challengeLevel]+'.':settings.session==='learn'?'Colors identify quarters, eighths, and sixteenths on the ruler.':'Practice on a standard ruler without color clues.';showType(false);draw()}}
    let toastTimer;function toast(msg,bad){els.toast.textContent=msg;els.toast.className='toast show'+(bad?' bad':'');clearTimeout(toastTimer);toastTimer=setTimeout(()=>els.toast.className='toast',1100)}
    function sound(good){if(settings.sound!=='on')return;try{const ac=new(window.AudioContext||window.webkitAudioContext)(),o=ac.createOscillator(),g=ac.createGain();o.frequency.value=good?660:190;o.type=good?'sine':'triangle';g.gain.setValueAtTime(.08,ac.currentTime);g.gain.exponentialRampToValueAtTime(.001,ac.currentTime+.16);o.connect(g);g.connect(ac.destination);o.start();o.stop(ac.currentTime+.17)}catch(e){}}
    function pointer(e){const rect=canvas.getBoundingClientRect(),x=Math.max(geom.x,Math.min(geom.x+geom.w,e.clientX-rect.left)),marks=rulerMarks();return Math.round((x-geom.x)/geom.w*(marks*geom.length))}
    canvas.addEventListener('pointermove',e=>{if(state.playing&&!state.locked&&state.expectsFind){state.hoverUnits=pointer(e);draw()}});canvas.addEventListener('pointerleave',()=>{if(state.expectsFind){state.hoverUnits=null;draw()}});canvas.addEventListener('pointerdown',e=>{if(state.playing&&!state.locked&&state.expectsFind){e.preventDefault();const u=pointer(e);state.hoverUnits=u;draw();check(u)}});
    canvas.addEventListener('keydown',e=>{if(!state.playing||state.locked||!state.expectsFind)return;const max=rulerMarks()*rulerLength();if(e.key==='ArrowRight'||e.key==='ArrowUp'){e.preventDefault();state.hoverUnits=Math.min(max,(state.hoverUnits??0)+1);draw()}else if(e.key==='ArrowLeft'||e.key==='ArrowDown'){e.preventDefault();state.hoverUnits=Math.max(0,(state.hoverUnits??0)-1);draw()}else if(e.key==='Enter'||e.key===' '){e.preventDefault();check(state.hoverUnits??0)}});
    $('#submit').onclick=()=>check(0);els.answer.addEventListener('keydown',e=>{if(e.key==='Enter')check(0)});els.start.onclick=startGame;$('#playAgain').onclick=startGame;$('#printResults').onclick=()=>window.print();els.skip.onclick=()=>check(null);
    document.querySelectorAll('.session-btn').forEach(b=>b.onclick=()=>{settings.session=b.dataset.session;localStorage.setItem('rulerLabSettings',JSON.stringify(settings));if(state.playing)startGame();else updateSessionUI()});
    document.querySelectorAll('.level-btn').forEach(b=>b.onclick=()=>{settings.challengeLevel=b.dataset.level;localStorage.setItem('rulerLabSettings',JSON.stringify(settings));if(state.playing)startGame();else updateSessionUI()});
    $('#openSettings').onclick=()=>$('#settingsDialog').showModal();$('#saveSettings').onclick=e=>{e.preventDefault();inputs.forEach(id=>settings[id]=$('#'+id).value);settings.studentName=$('#studentName').value.trim();if(+settings.questions>+settings.marks){settings.marks=settings.questions;$('#marks').value=settings.marks;toast('Ruler marks adjusted to match questions',false)}localStorage.setItem('rulerLabSettings',JSON.stringify(settings));$('#settingsDialog').close();if(state.playing)startGame();else updateSessionUI()};
    $('#copyPreset').onclick=async()=>{inputs.forEach(id=>settings[id]=$('#'+id).value);const url=new URL(location.href);url.search='';url.searchParams.set('session',settings.session);url.searchParams.set('challengeLevel',settings.challengeLevel);inputs.forEach(k=>url.searchParams.set(k,settings[k]));try{await navigator.clipboard.writeText(url.href);toast('Teacher preset link copied',false)}catch(e){prompt('Copy this teacher preset link:',url.href)}};
    $('#questions').onchange=()=>{if(+$('#questions').value>+$('#marks').value)$('#marks').value=$('#questions').value};$('#marks').onchange=()=>{if(+$('#marks').value<+$('#questions').value)$('#questions').value=$('#marks').value};$('#fullscreen').onclick=()=>{if(!document.fullscreenElement)document.documentElement.requestFullscreen?.();else document.exitFullscreen?.()};window.addEventListener('resize',draw);
    els.best.textContent=localStorage.getItem('rulerLabBest')||'0';updateSessionUI();
    if(document.modelContext?.registerTool){const register=t=>Promise.resolve(document.modelContext.registerTool(t)).catch(()=>{});register({name:'start_ruler_game',title:'Start ruler game',description:'Start or restart the visible ruler learning, practice, or shop challenge session.',inputSchema:{type:'object',properties:{session:{enum:['learn','practice','challenge']},challengeLevel:{enum:['easy','medium','hard']}},additionalProperties:false},annotations:{readOnlyHint:false,untrustedContentHint:false},execute:v=>{if(v?.session)settings.session=v.session;if(v?.challengeLevel)settings.challengeLevel=v.challengeLevel;updateSessionUI();startGame();return{status:'started',session:settings.session,challengeLevel:settings.challengeLevel,precision:settings.session==='challenge'?challengeDen():settings.questions}}});register({name:'configure_ruler_game',title:'Configure ruler game',description:'Change the visible ruler game settings.',inputSchema:{type:'object',properties:{session:{enum:['learn','practice','challenge']},challengeLevel:{enum:['easy','medium','hard']},mode:{enum:['find','type']},questions:{enum:['1','2','4','8','16','32','64']},marks:{enum:['2','4','8','16','32','64']},length:{enum:['auto','1','2','3','4','6','8','10','12']},notation:{enum:['fraction','decimal']},timer:{enum:['on','off']},measureTwice:{enum:['on','off']}},additionalProperties:false},annotations:{readOnlyHint:false,untrustedContentHint:false},execute:v=>{settings={...settings,...v};if(+settings.questions>+settings.marks)settings.marks=settings.questions;inputs.forEach(id=>$('#'+id).value=settings[id]);localStorage.setItem('rulerLabSettings',JSON.stringify(settings));updateSessionUI();return{status:'configured',settings}}})}
  })();
  </script>
</body>
</html><!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
  <meta name="theme-color" content="#12304a">
  <meta name="description" content="An ad-free woodshop measurement game for learning to read an inch ruler and solve practical cut-list problems.">
  <title>Ruler Lab — Woodshop Measurement Practice</title>
  <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect x='5' y='13' width='54' height='38' rx='7' fill='%23f6c453'/%3E%3Cpath d='M13 13v15m8-15v9m8-9v15m8-15v9m8-9v15m8-15v9' stroke='%2312304a' stroke-width='4'/%3E%3C/svg%3E">
  <style>
    :root{--ink:#102a43;--muted:#62758a;--navy:#12304a;--blue:#1675a9;--sky:#e8f6fb;--paper:#fffdfa;--yellow:#f6c453;--green:#16866f;--red:#d44949;--line:#d6e1e8;--shadow:0 18px 50px rgba(18,48,74,.14)}
    *{box-sizing:border-box}
    html,body{min-height:100%;margin:0}
    body{font-family:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;color:var(--ink);background:linear-gradient(180deg,#e9f7fb 0,#f7fbfc 52%,#edf4f7 100%)}
    button,select,input{font:inherit}
    button{cursor:pointer}
    .app{min-height:100vh;display:flex;flex-direction:column}
    header{height:72px;display:flex;align-items:center;justify-content:space-between;padding:0 clamp(18px,4vw,64px);background:var(--navy);color:white}
    .brand{display:flex;align-items:center;gap:12px;font-weight:850;font-size:1.25rem;letter-spacing:.01em}
    .brand-icon{width:42px;height:30px;border-radius:7px;background:var(--yellow);position:relative;box-shadow:inset 0 -3px rgba(0,0,0,.08)}
    .brand-icon:after{content:"";position:absolute;inset:0;background:repeating-linear-gradient(90deg,transparent 0 7px,var(--navy) 7px 9px,transparent 9px 14px);clip-path:polygon(0 0,100% 0,100% 58%,0 58%)}
    .header-actions{display:flex;gap:8px}
    .icon-btn,.settings-btn{border:1px solid rgba(255,255,255,.35);background:rgba(255,255,255,.08);color:#fff;border-radius:10px;min-height:42px;padding:0 14px;font-weight:750}
    .icon-btn:hover,.settings-btn:hover{background:rgba(255,255,255,.16)}
    main{width:min(1500px,100%);margin:auto;padding:clamp(18px,3vw,40px) clamp(14px,4vw,58px) 36px;flex:1;display:flex;flex-direction:column;justify-content:center;gap:18px}
    .session-picker{align-self:center;display:grid;grid-template-columns:repeat(3,1fr);gap:5px;background:#dce9ee;border:1px solid #c8d9e1;padding:5px;border-radius:15px}
    .session-btn{border:0;background:transparent;color:#50687a;border-radius:10px;padding:11px 18px;font-weight:850;min-width:150px}
    .session-btn[aria-selected="true"]{background:white;color:var(--navy);box-shadow:0 3px 12px rgba(18,48,74,.12)}
    .session-btn small{display:block;font-size:.7rem;font-weight:700;color:#718495;margin-top:2px}
    .challenge-levels{align-self:center;display:flex;align-items:center;gap:8px;padding:6px;background:#fff;border:1px solid var(--line);border-radius:13px;box-shadow:0 4px 16px rgba(18,48,74,.07)}
    .challenge-levels[hidden]{display:none}.challenge-label{font-size:.76rem;color:var(--muted);text-transform:uppercase;letter-spacing:.08em;font-weight:900;padding:0 7px}.level-btn{border:1px solid transparent;background:transparent;color:#536c7e;border-radius:9px;padding:8px 13px;font-weight:850}.level-btn[aria-selected="true"]{background:var(--blue);color:white}.difficulty-note{font-size:.78rem;color:var(--muted);font-weight:700;padding:0 8px;max-width:280px}
    .topline{display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:18px}
    .mode-pill{justify-self:start;display:inline-flex;align-items:center;gap:8px;background:#d9f3ed;color:#116653;padding:8px 12px;border-radius:999px;font-weight:800;font-size:.85rem;text-transform:uppercase;letter-spacing:.06em}
    .mode-pill:before{content:"";width:8px;height:8px;border-radius:50%;background:var(--green)}
    .prompt{text-align:center;min-width:min(430px,60vw)}
    .eyebrow{font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:.12em;font-size:.82rem;margin:0 0 4px}
    #target{font-size:clamp(2.1rem,5vw,4.8rem);line-height:1;font-weight:900;letter-spacing:-.045em;margin:0;color:var(--navy);font-variant-numeric:tabular-nums}
    #target small{font-size:.45em;letter-spacing:0;color:var(--muted);font-weight:800}
    .best{justify-self:end;text-align:right;color:var(--muted);font-weight:700}
    .best strong{display:block;color:var(--ink);font-size:1.15rem}
    .game-card{background:var(--paper);border:1px solid rgba(18,48,74,.12);border-radius:22px;box-shadow:var(--shadow);overflow:hidden}
    .ruler-legend{display:flex;align-items:center;justify-content:center;gap:18px;flex-wrap:wrap;margin:0 0 10px;color:#526b7d;font-size:.8rem;font-weight:800}
    .legend-item{display:inline-flex;align-items:center;gap:6px}.legend-line{display:inline-block;width:24px;border-top:4px solid;border-radius:2px}.legend-quarter{border-color:#2463eb}.legend-eighth{border-color:#0f8a72}.legend-sixteenth{border-color:#d97706}
    .ruler-shell{padding:34px 20px 18px;position:relative;overflow:hidden}
    #ruler{width:100%;height:260px;display:block;touch-action:none;user-select:none}
    .hint{text-align:center;color:var(--muted);font-weight:650;margin:0 0 18px;min-height:24px;padding:0 14px}
    .learn-note{display:none;width:min(660px,calc(100% - 28px));margin:-4px auto 18px;padding:12px 16px;border-radius:12px;background:#e8f6fb;border:1px solid #b9ddea;color:#24526c;text-align:center;font-weight:750}
    .learn-note.show{display:block}
    .type-row{display:none;align-items:center;justify-content:center;gap:10px;margin:0 auto 22px;padding:0 16px}
    .type-row.show{display:flex}
    #answer{width:min(320px,70vw);height:52px;border:2px solid var(--line);border-radius:12px;padding:0 16px;font-size:1.2rem;font-weight:750;color:var(--ink);background:white}
    #answer:focus{outline:3px solid rgba(22,117,169,.2);border-color:var(--blue)}
    .primary{height:52px;border:0;border-radius:12px;background:var(--blue);color:white;padding:0 22px;font-weight:850;box-shadow:0 6px 16px rgba(22,117,169,.22)}
    .primary:hover{background:#0e668f}
    .stats{display:grid;grid-template-columns:repeat(4,1fr);background:#f7fafb;border-top:1px solid var(--line)}
    .stat{padding:18px 22px;text-align:center;border-right:1px solid var(--line)}
    .stat:last-child{border:0}
    .stat-label{display:block;color:var(--muted);font-size:.75rem;text-transform:uppercase;letter-spacing:.1em;font-weight:850;margin-bottom:5px}
    .stat-value{font-size:1.45rem;font-weight:900;font-variant-numeric:tabular-nums}
    .timer-track{height:7px;background:#dfe8ec;border-radius:5px;overflow:hidden;margin:7px auto 0;max-width:120px}
    #timerBar{height:100%;width:100%;background:var(--green);transition:width .08s linear,background .2s}
    .strikes{display:flex;justify-content:center;gap:7px;margin-top:5px}
    .strike{width:12px;height:12px;border:2px solid #c6d2d9;border-radius:50%}
    .strike.on{border-color:var(--red);background:var(--red)}
    .controls{display:flex;justify-content:center;gap:10px}
    .start{min-width:190px;height:54px;border:0;border-radius:13px;background:var(--yellow);color:#352808;font-weight:900;font-size:1.05rem;box-shadow:0 7px 0 #dca62e}
    .start:active{transform:translateY(4px);box-shadow:0 3px 0 #dca62e}
    .secondary{height:54px;border:1px solid #bfcfd7;border-radius:13px;background:#fff;color:var(--navy);padding:0 18px;font-weight:800}
    .footer-note{text-align:center;color:#718495;font-size:.84rem;margin-top:4px}
    dialog{border:0;border-radius:20px;padding:0;width:min(620px,calc(100vw - 28px));color:var(--ink);box-shadow:0 28px 90px rgba(4,25,44,.32)}
    dialog::backdrop{background:rgba(6,28,45,.62);backdrop-filter:blur(3px)}
    .modal-head{display:flex;align-items:center;justify-content:space-between;padding:22px 24px 16px;border-bottom:1px solid var(--line)}
    .modal-head h2{margin:0;font-size:1.4rem}
    .close{border:0;background:#edf3f6;color:var(--ink);width:38px;height:38px;border-radius:10px;font-size:1.4rem}
    .settings-grid{padding:22px 24px;display:grid;grid-template-columns:1fr 1fr;gap:18px}
    label{display:grid;gap:7px;font-weight:800;font-size:.88rem;color:#334e68}
    select,input[type="text"]{height:46px;border:1px solid #bfcfd7;border-radius:10px;background:white;padding:0 12px;color:var(--ink)}
    .span-2{grid-column:1/-1}.teacher-tools{grid-column:1/-1;background:#f1f7f9;border:1px solid var(--line);border-radius:13px;padding:14px;display:flex;align-items:center;justify-content:space-between;gap:14px}.teacher-tools p{margin:0;color:var(--muted);font-size:.84rem;line-height:1.35}.compact{height:42px;white-space:nowrap}
    .modal-foot{display:flex;justify-content:flex-end;gap:10px;padding:16px 24px 24px}
    .result-overlay{position:fixed;inset:0;z-index:20;display:none;place-items:center;background:rgba(7,28,45,.62);padding:16px}
    .result-overlay.show{display:grid}
    .result-card{background:#fff;border-radius:22px;padding:30px;width:min(520px,100%);text-align:center;box-shadow:var(--shadow)}
    .result-card h2{font-size:2rem;margin:0 0 8px}.result-card p{color:var(--muted);font-size:1.05rem;margin:0 0 18px}.result-actions{display:grid;grid-template-columns:1fr 1fr;gap:10px}.result-actions .primary{width:100%}.mastery-badge{display:inline-block;border-radius:999px;padding:8px 14px;background:#dff5ed;color:#126b57;font-weight:900;margin:4px 0 16px}.mastery-badge.not-yet{background:#fff1d2;color:#855d08}.result-grid{display:grid;grid-template-columns:1fr 1fr;gap:1px;background:var(--line);border:1px solid var(--line);border-radius:13px;overflow:hidden;margin:0 0 20px}.result-item{background:#f8fbfc;padding:12px}.result-item span{display:block;font-size:.72rem;color:var(--muted);text-transform:uppercase;letter-spacing:.08em;font-weight:850}.result-item strong{display:block;margin-top:3px;font-size:1.15rem}.result-name{font-weight:850!important;color:var(--ink)!important;margin-bottom:6px!important}
    .toast{position:fixed;left:50%;top:90px;transform:translate(-50%,-20px);opacity:0;pointer-events:none;padding:12px 18px;border-radius:999px;color:#fff;font-weight:850;z-index:30;transition:.22s;background:var(--green);box-shadow:0 10px 30px rgba(0,0,0,.18)}
    .toast.show{opacity:1;transform:translate(-50%,0)}.toast.bad{background:var(--red)}
    @media(max-width:760px){header{height:62px}.settings-btn span{display:none}.session-picker{width:100%}.session-btn{min-width:0;padding:10px 5px;font-size:.83rem}.session-btn small{display:none}.challenge-levels{width:100%;justify-content:center;flex-wrap:wrap}.challenge-label{width:100%;text-align:center}.difficulty-note{width:100%;max-width:none;text-align:center}.topline{grid-template-columns:1fr auto}.prompt{grid-column:1/-1;grid-row:1}.mode-pill{grid-row:2}.best{grid-row:2}.ruler-shell{padding:24px 6px 12px}#ruler{height:220px}.stats{grid-template-columns:1fr 1fr}.stat:nth-child(2){border-right:0}.stat:nth-child(-n+2){border-bottom:1px solid var(--line)}.settings-grid{grid-template-columns:1fr}.span-2,.teacher-tools{grid-column:auto}.teacher-tools{align-items:stretch;flex-direction:column}.type-row{flex-direction:column}.type-row .primary,#answer{width:100%}.result-actions{grid-template-columns:1fr}}
    @media print{body{background:white}.app,.toast{display:none!important}.result-overlay{position:static;display:block!important;background:white;padding:0}.result-card{box-shadow:none;border:2px solid #12304a;width:100%;max-width:720px;margin:20px auto}.result-actions{display:none}.result-card:after{content:"Ruler Lab • CTE Makes School Matter";display:block;margin-top:24px;color:#62758a;font-size:.82rem}}
    @media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important}}
  </style>
</head>
<body>
  <div class="app">
    <header>
      <div class="brand"><span class="brand-icon" aria-hidden="true"></span>Ruler Lab <span style="font-weight:600;opacity:.65">| Woodshop</span></div>
      <div class="header-actions">
        <button class="icon-btn" id="fullscreen" aria-label="Enter full screen" title="Full screen">⛶</button>
        <button class="settings-btn" id="openSettings" aria-haspopup="dialog">⚙ <span>Settings</span></button>
      </div>
    </header>
    <main>
      <nav class="session-picker" aria-label="Learning mode">
        <button class="session-btn" data-session="learn" aria-selected="false">Learn<small>Guided help</small></button>
        <button class="session-btn" data-session="practice" aria-selected="true">Practice<small>Build accuracy</small></button>
        <button class="session-btn" data-session="challenge" aria-selected="false">Shop Challenge<small>20 questions</small></button>
      </nav>
      <div class="challenge-levels" id="challengeLevels" hidden>
        <span class="challenge-label">Difficulty</span>
        <button class="level-btn" data-level="easy" aria-selected="false">Easy</button>
        <button class="level-btn" data-level="medium" aria-selected="true">Medium</button>
        <button class="level-btn" data-level="hard" aria-selected="false">Hard</button>
        <span class="difficulty-note" id="difficultyNote">Sixteenths and one-step shop math</span>
      </div>
      <div class="topline">
        <div class="mode-pill" id="modeLabel">Find the mark</div>
        <div class="prompt" aria-live="polite">
          <p class="eyebrow" id="promptLabel">Ready to measure?</p>
          <p id="target">Press Start</p>
        </div>
        <div class="best">Session best<strong id="best">0</strong></div>
      </div>
      <div class="controls">
        <button class="start" id="start">Start game</button>
        <button class="secondary" id="newQuestion" hidden>Skip</button>
      </div>
      <section class="game-card" aria-label="Ruler game">
        <div class="ruler-shell">
          <div class="ruler-legend" id="rulerLegend" aria-label="Ruler mark color key"></div>
          <canvas id="ruler" tabindex="0" role="slider" aria-label="Interactive inch ruler. Use arrow keys and Enter, or move and click or tap, to choose a measurement." aria-valuemin="0" aria-valuemax="64" aria-valuenow="0"></canvas>
        </div>
        <p class="hint" id="hint">Choose your settings, then press Start.</p>
        <div class="learn-note" id="learnNote"></div>
        <div class="type-row" id="typeRow">
          <input id="answer" type="text" inputmode="text" autocomplete="off" placeholder='Example: 2 3/8 or 2.375' aria-label="Measurement answer">
          <button class="primary" id="submit">Check answer</button>
        </div>
        <div class="stats">
          <div class="stat"><span class="stat-label">Score</span><span class="stat-value" id="score">0</span></div>
          <div class="stat"><span class="stat-label">Time</span><span class="stat-value" id="time">—</span><div class="timer-track"><div id="timerBar"></div></div></div>
          <div class="stat"><span class="stat-label" id="levelLabel">Level</span><span class="stat-value" id="level">1</span></div>
          <div class="stat"><span class="stat-label" id="strikeLabel">Strikes</span><div class="strikes" id="strikes"><i class="strike"></i><i class="strike"></i><i class="strike"></i></div><span class="stat-value" id="mistakeCount" hidden>0</span></div>
        </div>
      </section>
      <p class="footer-note">Ad-free • No sign-in • CTE Makes School Matter</p>
    </main>
  </div>

  <dialog id="settingsDialog">
    <form method="dialog" id="settingsForm">
      <div class="modal-head"><h2>Game settings</h2><button class="close" value="cancel" aria-label="Close settings">×</button></div>
      <div class="settings-grid">
        <label class="span-2">Student name <input id="studentName" type="text" maxlength="40" placeholder="Optional — appears on results"></label>
        <label>Game mode<select id="mode"><option value="find">Find the mark</option><option value="type">Read and type</option></select></label>
        <label>Question precision<select id="questions"><option value="1">Whole inches</option><option value="2">Halves</option><option value="4">Quarters</option><option value="8">Eighths</option><option value="16" selected>Sixteenths</option><option value="32">Thirty-seconds</option><option value="64">Sixty-fourths</option></select></label>
        <label>Ruler marks<select id="marks"><option value="2">Halves</option><option value="4">Quarters</option><option value="8">Eighths</option><option value="16" selected>Sixteenths</option><option value="32">Thirty-seconds</option><option value="64">Sixty-fourths</option></select></label>
        <label>Ruler length<select id="length"><option value="auto">Automatic</option><option value="1">1 inch</option><option value="2">2 inches</option><option value="3">3 inches</option><option value="4" selected>4 inches</option><option value="6">6 inches</option><option value="8">8 inches</option><option value="10">10 inches</option><option value="12">12 inches</option></select></label>
        <label>Answer notation<select id="notation"><option value="fraction">Fractions</option><option value="decimal">Decimals</option></select></label>
        <label>Fraction style<select id="fractionStyle"><option value="both">Mixed practice</option><option value="simplified">Simplified</option><option value="unsimplified">Unsimplified</option></select></label>
        <label>Timer<select id="timer"><option value="on">On</option><option value="off">Off</option></select></label>
        <label>Sounds<select id="sound"><option value="on">On</option><option value="off">Off</option></select></label>
        <label>Measure twice<select id="measureTwice"><option value="off">Off</option><option value="on">On</option></select></label>
        <div class="teacher-tools"><p><strong>Teacher preset link</strong><br>Opens with these settings already selected.</p><button type="button" class="secondary compact" id="copyPreset">Copy link</button></div>
      </div>
      <div class="modal-foot"><button class="secondary" value="cancel">Cancel</button><button class="primary" id="saveSettings" value="default">Save settings</button></div>
    </form>
  </dialog>
  <div class="result-overlay" id="gameOver"><div class="result-card">
    <p class="eyebrow" id="resultEyebrow">Session complete</p><h2 id="finalScore">Score: 0</h2><p class="result-name" id="resultName"></p><div class="mastery-badge" id="masteryBadge">Keep practicing</div>
    <div class="result-grid"><div class="result-item"><span>Accuracy</span><strong id="resultAccuracy">0%</strong></div><div class="result-item"><span>Correct</span><strong id="resultCorrect">0</strong></div><div class="result-item"><span>Precision</span><strong id="resultPrecision">1/16″</strong></div><div class="result-item"><span>Date</span><strong id="resultDate">—</strong></div></div>
    <p id="finalMessage">Nice work. Every mark gets easier with practice.</p><div class="result-actions"><button class="secondary" id="printResults">Print / Save PDF</button><button class="primary" id="playAgain">Try again</button></div>
  </div></div>
  <div class="toast" id="toast" role="status" aria-live="polite"></div>

  <script>
  (()=>{
    const $=s=>document.querySelector(s), canvas=$('#ruler'), ctx=canvas.getContext('2d');
    const els={target:$('#target'),prompt:$('#promptLabel'),hint:$('#hint'),note:$('#learnNote'),score:$('#score'),level:$('#level'),levelLabel:$('#levelLabel'),time:$('#time'),timerBar:$('#timerBar'),strikes:$('#strikes'),mistakes:$('#mistakeCount'),strikeLabel:$('#strikeLabel'),start:$('#start'),skip:$('#newQuestion'),typeRow:$('#typeRow'),answer:$('#answer'),modeLabel:$('#modeLabel'),toast:$('#toast'),best:$('#best')};
    const inputs=['mode','questions','marks','length','notation','fractionStyle','timer','sound','measureTwice'];
    const defaults={session:'practice',challengeLevel:'medium',mode:'find',questions:'16',marks:'16',length:'4',notation:'fraction',fractionStyle:'both',timer:'on',sound:'on',measureTwice:'off',studentName:''};
    let settings={...defaults,...JSON.parse(localStorage.getItem('rulerLabSettings')||'{}')};
    const preset=new URLSearchParams(location.search);['session','challengeLevel',...inputs].forEach(k=>{if(preset.has(k))settings[k]=preset.get(k)});if(+settings.questions>+settings.marks)settings.marks=settings.questions;
    let state={playing:false,score:0,correct:0,answered:0,strikes:0,level:1,answerUnits:0,answerDen:+settings.questions,hoverUnits:null,shownUnits:null,firstMeasure:null,questionKind:'find',expectsFind:true,startTime:0,duration:10,raf:0,locked:false};
    let geom={x:0,y:0,w:0,h:0,inch:0,length:4};
    inputs.forEach(id=>$('#'+id).value=settings[id]??defaults[id]);$('#studentName').value=settings.studentName||'';
    function gcd(a,b){while(b)[a,b]=[b,a%b];return a||1}
    function autoLength(){const width=canvas.clientWidth;return width<520?2:width<820?3:width<1120?4:6}
    function rulerLength(){return settings.length==='auto'?autoLength():+settings.length}
    function challengeDen(){return settings.challengeLevel==='easy'?8:16}
    function rulerMarks(){return settings.session==='challenge'?16:+settings.marks}
    function format(units,denom=+settings.questions,forceStyle=settings.fractionStyle){
      const value=units/denom;if(settings.notation==='decimal')return value.toFixed(4).replace(/0+$/,'').replace(/\.$/,'')+'″';
      const whole=Math.floor(units/denom),n=units%denom;if(!n)return whole+'″';let num=n,d=denom;
      const style=forceStyle==='both'?(Math.random()<.5?'simplified':'unsimplified'):forceStyle;if(style==='simplified'){const g=gcd(num,d);num/=g;d/=g}
      return(whole?whole+' ':'')+num+'/'+d+'″';
    }
    function precisionName(){if(settings.session==='challenge')return settings.challengeLevel[0].toUpperCase()+settings.challengeLevel.slice(1)+' · 1/'+challengeDen()+'″';return({1:'Whole',2:'1/2″',4:'1/4″',8:'1/8″',16:'1/16″',32:'1/32″',64:'1/64″'})[settings.questions]}
    function unitsAtMarks(units,den=state.answerDen){return Math.round(units*rulerMarks()/den)}
    function tickFamily(frac,marks){if(frac===0||frac===marks/2)return'major';const pos=frac/marks;if(Number.isInteger(pos*4))return'quarter';if(Number.isInteger(pos*8))return'eighth';if(Number.isInteger(pos*16))return'sixteenth';return'fine'}
    function tickColor(family){const colors={major:'#12304a',quarter:'#2463eb',eighth:'#0f8a72',sixteenth:'#d97706',fine:'#8797a4'};return settings.session==='learn'?colors[family]:'#12304a'}
    function updateLegend(){const marks=rulerMarks(),items=[];if(settings.session==='learn'&&marks>=4)items.push(['quarter','¼-inch']);if(settings.session==='learn'&&marks>=8)items.push(['eighth','⅛-inch']);if(settings.session==='learn'&&marks>=16)items.push(['sixteenth','¹⁄₁₆-inch']);const legend=$('#rulerLegend');legend.hidden=!items.length;legend.innerHTML=items.map(([c,t])=>'<span class="legend-item"><i class="legend-line legend-'+c+'"></i>'+t+'</span>').join('')}
    function draw(){
      const dpr=Math.min(devicePixelRatio||1,2),rect=canvas.getBoundingClientRect();
      if(canvas.width!==Math.round(rect.width*dpr)||canvas.height!==Math.round(rect.height*dpr)){canvas.width=Math.round(rect.width*dpr);canvas.height=Math.round(rect.height*dpr)}
      ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,rect.width,rect.height);const len=rulerLength(),pad=Math.max(20,rect.width*.025),top=26,base=rect.height-35,usable=rect.width-pad*2;geom={x:pad,y:top,w:usable,h:base-top,inch:usable/len,length:len};
      ctx.fillStyle='#fff9df';ctx.strokeStyle='#12304a';ctx.lineWidth=2.5;roundRect(ctx,pad,top,usable,base-top,8);ctx.fill();ctx.stroke();const marks=rulerMarks(),total=len*marks;
      for(let i=0;i<=total;i++){const x=pad+i/total*usable,frac=i%marks,family=tickFamily(frac,marks);let tick;if(i===0||i===total||frac===0)tick=78;else{let pow=0,n=frac;while(n%2===0){pow++;n/=2}tick=Math.min(20+pow*12,64)}ctx.beginPath();ctx.moveTo(x,top);ctx.lineTo(x,top+tick);ctx.strokeStyle=tickColor(family);ctx.lineWidth=family==='major'?2.8:2.2;ctx.stroke();if(frac===0&&i>0){ctx.fillStyle='#12304a';ctx.font='800 25px Inter,system-ui';ctx.textAlign='center';ctx.fillText(String(i/marks),x,top+104)}}
      const active=state.playing&&!state.locked&&state.expectsFind?state.hoverUnits:null;if(active!==null){const x=pad+active/(marks*len)*usable;ctx.fillStyle='rgba(22,134,111,.22)';ctx.fillRect(Math.max(pad,x-geom.inch/marks/2),top+2,Math.max(4,geom.inch/marks),base-top-4);ctx.beginPath();ctx.moveTo(x,top);ctx.lineTo(x,base);ctx.strokeStyle='#16866f';ctx.lineWidth=4;ctx.stroke();canvas.setAttribute('aria-valuenow',String(active));canvas.setAttribute('aria-valuetext',format(active,marks,'simplified'))}
      canvas.setAttribute('aria-valuemax',String(marks*len));if(state.shownUnits!==null){const x=pad+state.shownUnits/(marks*len)*usable;ctx.beginPath();ctx.moveTo(x,top-5);ctx.lineTo(x,base+2);ctx.strokeStyle='#d44949';ctx.lineWidth=4;ctx.stroke();ctx.fillStyle='#d44949';ctx.beginPath();ctx.moveTo(x-8,top-10);ctx.lineTo(x+8,top-10);ctx.lineTo(x,top+1);ctx.fill()}
      if(state.questionKind==='read'&&state.playing){const m=unitsAtMarks(state.answerUnits),x=pad+m/(marks*len)*usable;ctx.fillStyle='#a11d48';ctx.beginPath();ctx.moveTo(x-11,top-15);ctx.lineTo(x+11,top-15);ctx.lineTo(x,top+1);ctx.fill();ctx.beginPath();ctx.moveTo(x,top);ctx.lineTo(x,base);ctx.strokeStyle='#a11d48';ctx.lineWidth=3;ctx.stroke()}updateLegend()
    }
    function roundRect(c,x,y,w,h,r){c.beginPath();c.roundRect?c.roundRect(x,y,w,h,r):c.rect(x,y,w,h)}
    function randomUnits(max,avoid=0){let u;do{u=Math.max(1,Math.floor(Math.random()*max))}while(u===avoid);return u}
    function learningHelp(units,den){const whole=Math.floor(units/den),n=units%den;if(!n)return'Find the long line for '+whole+' whole inch'+(whole===1?'':'es')+'.';const g=gcd(n,den),sn=n/g,sd=den/g;return'Break it down: '+(whole?whole+' whole inch'+(whole===1?'':'es')+' plus ':'')+sn+' of '+sd+' equal parts.'}
    function showType(on){els.typeRow.classList.toggle('show',on);if(on)setTimeout(()=>els.answer.focus(),60)}
    function newQuestion(){
      state.locked=false;state.shownUnits=null;state.hoverUnits=null;state.firstMeasure=null;els.answer.value='';els.note.classList.remove('show');const den=settings.session==='challenge'?challengeDen():+settings.questions,len=rulerLength();state.answerDen=den;
      let kind=settings.mode==='find'?'find':'read';if(settings.session==='challenge'){const sets={easy:['find','read','cutlist','find','midpoint'],medium:['find','read','cutlist','midpoint','remaining'],hard:['read','cutlist','remaining','midpoint','multiCut','difference']};const kinds=sets[settings.challengeLevel];kind=kinds[state.answered%kinds.length]}
      state.questionKind=kind;state.expectsFind=kind==='find'||kind==='cutlist';
      if(kind==='find'||kind==='read'||kind==='cutlist'){state.answerUnits=randomUnits(len*den,state.answerUnits)}
      if(kind==='find'){els.prompt.textContent='Click the measurement';els.target.textContent=format(state.answerUnits,den);els.hint.textContent=settings.measureTwice==='on'?'Measure it twice. Both selections must match.':'Move over the ruler, then click or tap the correct mark.';showType(false)}
      if(kind==='read'){els.prompt.textContent='Read the marked line';els.target.innerHTML='<small>Type your answer below</small>';els.hint.textContent='Enter a fraction or decimal measurement.';showType(true)}
      if(kind==='cutlist'){const parts=['Side rail','Shelf support','Face-frame piece','Drawer spacer'];els.prompt.textContent='Cut list: '+parts[state.answered%parts.length];els.target.textContent=format(state.answerUnits,den,'simplified');els.hint.textContent='Select this cut length on the ruler.';showType(false)}
      if(kind==='midpoint'){state.answerUnits=randomUnits(Math.max(2,Math.floor(6*den)));const boardUnits=state.answerUnits*2;els.prompt.textContent='Find the midpoint';els.target.textContent=format(boardUnits,den,'simplified')+' board';els.hint.textContent='What measurement marks the exact center?';showType(true)}
      if(kind==='remaining'){const totalInches=6+Math.floor(Math.random()*7),totalUnits=totalInches*den,cutUnits=randomUnits(totalUnits-1);state.answerUnits=totalUnits-cutUnits;els.prompt.textContent='Board remaining';els.target.style.fontSize='clamp(1.55rem,3.4vw,3.2rem)';els.target.textContent=format(totalUnits,den,'simplified')+' − '+format(cutUnits,den,'simplified');els.hint.textContent='Type the board length left after the cut.';showType(true)}else els.target.style.fontSize='';
      if(kind==='multiCut'){const totalInches=10+Math.floor(Math.random()*3),totalUnits=totalInches*den,maxPiece=Math.floor((totalUnits-den)/3),pieceUnits=den+Math.floor(Math.random()*Math.max(1,maxPiece-den));state.answerUnits=totalUnits-pieceUnits*2;els.prompt.textContent='Two pieces from one board';els.target.style.fontSize='clamp(1.45rem,3vw,2.8rem)';els.target.textContent=format(totalUnits,den,'simplified')+' − 2 × '+format(pieceUnits,den,'simplified');els.hint.textContent='Ignore saw kerf. How much stock remains?';showType(true)}
      if(kind==='difference'){const planned=2*den+randomUnits(4*den),gap=randomUnits(den),actual=planned-gap;state.answerUnits=gap;els.prompt.textContent='Measurement difference';els.target.style.fontSize='clamp(1.45rem,3vw,2.8rem)';els.target.textContent=format(planned,den,'simplified')+' planned · '+format(actual,den,'simplified')+' actual';els.hint.textContent='How much shorter is the actual piece?';showType(true)}
      if(settings.session==='learn'){els.note.textContent=learningHelp(state.answerUnits,den);els.note.classList.add('show')}
      setStatMode();startClock();draw();
    }
    function startGame(){cancelAnimationFrame(state.raf);state={...state,playing:true,score:0,correct:0,answered:0,strikes:0,level:1,locked:false,shownUnits:null,firstMeasure:null};els.score.textContent='0';els.start.textContent='Restart';els.skip.hidden=settings.session==='learn';$('#gameOver').classList.remove('show');updateStrikes();newQuestion()}
    function startClock(){cancelAnimationFrame(state.raf);state.duration=state.expectsFind?Math.max(3,11-state.level):Math.max(6,20-(state.level-1)*2);if(settings.session==='challenge')state.duration+=settings.challengeLevel==='easy'?6:settings.challengeLevel==='hard'?-2:0;state.startTime=performance.now();if(settings.timer==='off'||settings.session==='learn'){els.time.textContent='∞';els.timerBar.style.width='100%';els.timerBar.style.background='#16866f';return}const tick=now=>{if(!state.playing||state.locked)return;const left=Math.max(0,state.duration-(now-state.startTime)/1000),pct=left/state.duration*100;els.time.textContent=Math.ceil(left)+'s';els.timerBar.style.width=pct+'%';els.timerBar.style.background=pct<25?'#d44949':pct<55?'#e7ad29':'#16866f';if(left<=0){check(null);return}state.raf=requestAnimationFrame(tick)};state.raf=requestAnimationFrame(tick)}
    function parseAnswer(v){v=v.trim().replace(/[″"]/g,'');if(!v)return null;if(/^\d*\.?\d+$/.test(v))return+v;const m=v.match(/^(?:(\d+)\s+)?(\d+)\/(\d+)$/);if(!m)return null;return+(m[1]||0)+(+m[2]/+m[3])}
    function check(selectedMark){
      if(!state.playing||state.locked)return;if(state.expectsFind&&selectedMark!==null&&settings.measureTwice==='on'){if(state.firstMeasure===null){state.firstMeasure=selectedMark;els.hint.textContent='First measurement saved. Move away, then measure it again.';toast('First measurement saved',false);return}if(state.firstMeasure!==selectedMark){state.firstMeasure=null;els.hint.textContent='Those measurements did not match. Start again.';toast('Measurements did not match',true);return}}
      state.locked=true;cancelAnimationFrame(state.raf);let ok=false;if(state.expectsFind)ok=selectedMark!==null&&selectedMark===unitsAtMarks(state.answerUnits);else{const v=parseAnswer(els.answer.value);ok=v!==null&&Math.abs(v-state.answerUnits/state.answerDen)<1e-7}
      state.answered++;if(ok){state.correct++;state.score+=state.level*10;els.score.textContent=state.score;toast('Correct! +'+state.level*10,false);sound(true)}else{state.strikes++;const correct=format(state.answerUnits,state.answerDen,'simplified');toast(selectedMark===null?'Time’s up — '+correct:'Correct answer: '+correct,true);sound(false);if(state.expectsFind)state.shownUnits=unitsAtMarks(state.answerUnits);draw()}
      if(settings.session!=='challenge'&&state.correct>0&&state.correct%10===0){state.level=Math.min(10,Math.floor(state.correct/10)+1);els.level.textContent=state.level}
      updateStrikes();if(settings.session==='learn'&&!ok)els.note.textContent='The correct mark is highlighted. '+learningHelp(state.answerUnits,state.answerDen);
      const done=settings.session==='challenge'&&state.answered>=20||settings.session==='practice'&&state.strikes>=3;if(done)setTimeout(endGame,1100);else setTimeout(newQuestion,ok?700:1500)
    }
    function setStatMode(){const challenge=settings.session==='challenge';els.levelLabel.textContent=challenge?'Question':'Level';els.level.textContent=challenge?(Math.min(20,state.answered+1)+'/20'):state.level;els.strikeLabel.textContent=challenge?'Missed':'Strikes';els.strikes.hidden=challenge;els.mistakes.hidden=!challenge;if(challenge)els.mistakes.textContent=state.strikes}
    function updateStrikes(){[...els.strikes.children].forEach((x,i)=>x.classList.toggle('on',i<state.strikes));els.mistakes.textContent=state.strikes;setStatMode()}
    function endGame(){state.playing=false;cancelAnimationFrame(state.raf);const total=Math.max(1,state.answered),accuracy=Math.round(state.correct/total*100),mastered=settings.session==='challenge'&&accuracy>=90,best=Math.max(state.score,+localStorage.getItem('rulerLabBest')||0);localStorage.setItem('rulerLabBest',best);els.best.textContent=best;$('#resultEyebrow').textContent=settings.session==='challenge'?'Shop challenge complete':'Practice complete';$('#finalScore').textContent=settings.session==='challenge'?(mastered?'Measurement mastered':'Keep building accuracy'):'Score: '+state.score;$('#resultName').textContent=settings.studentName||'Ruler Lab Student';$('#masteryBadge').textContent=settings.session==='challenge'?(mastered?'Mastered • 90%+':'Not yet mastered • Goal: 90%'):'Practice session';$('#masteryBadge').classList.toggle('not-yet',!mastered);$('#resultAccuracy').textContent=accuracy+'%';$('#resultCorrect').textContent=state.correct+' / '+state.answered;$('#resultPrecision').textContent=precisionName();$('#resultDate').textContent=new Date().toLocaleDateString();$('#finalMessage').textContent=mastered?'Ready to apply this skill in the shop.':accuracy>=75?'Very close—review the marks you missed and try again.':'Slow down, count the spaces, and measure twice.';$('#gameOver').classList.add('show');els.start.textContent='Start '+sessionName();els.skip.hidden=true}
    function sessionName(){return settings.session==='challenge'?'challenge':settings.session}
    function updateSessionUI(){document.querySelectorAll('.session-btn').forEach(b=>b.setAttribute('aria-selected',String(b.dataset.session===settings.session)));const challenge=settings.session==='challenge',notes={easy:'Eighth-inch questions on a full sixteenth-inch ruler',medium:'Sixteenths, midpoints, and one-step remaining length',hard:'Sixteenths, multi-cut stock, differences, and mixed calculations'};$('#challengeLevels').hidden=!challenge;document.querySelectorAll('.level-btn').forEach(b=>b.setAttribute('aria-selected',String(b.dataset.level===settings.challengeLevel)));$('#difficultyNote').textContent=notes[settings.challengeLevel];els.modeLabel.textContent=settings.session==='learn'?'Color-guided learning':challenge?'Shop Challenge · '+settings.challengeLevel[0].toUpperCase()+settings.challengeLevel.slice(1):'Independent practice';els.start.textContent='Start '+sessionName();setStatMode();if(!state.playing){els.prompt.textContent=challenge?'20-question '+settings.challengeLevel+' challenge':settings.session==='learn'?'Learn each ruler mark':'Build speed and accuracy';els.target.textContent='Press Start';els.hint.textContent=challenge?notes[settings.challengeLevel]+'.':settings.session==='learn'?'Colors identify quarters, eighths, and sixteenths on the ruler.':'Practice on a standard ruler without color clues.';showType(false);draw()}}
    let toastTimer;function toast(msg,bad){els.toast.textContent=msg;els.toast.className='toast show'+(bad?' bad':'');clearTimeout(toastTimer);toastTimer=setTimeout(()=>els.toast.className='toast',1100)}
    function sound(good){if(settings.sound!=='on')return;try{const ac=new(window.AudioContext||window.webkitAudioContext)(),o=ac.createOscillator(),g=ac.createGain();o.frequency.value=good?660:190;o.type=good?'sine':'triangle';g.gain.setValueAtTime(.08,ac.currentTime);g.gain.exponentialRampToValueAtTime(.001,ac.currentTime+.16);o.connect(g);g.connect(ac.destination);o.start();o.stop(ac.currentTime+.17)}catch(e){}}
    function pointer(e){const rect=canvas.getBoundingClientRect(),x=Math.max(geom.x,Math.min(geom.x+geom.w,e.clientX-rect.left)),marks=rulerMarks();return Math.round((x-geom.x)/geom.w*(marks*geom.length))}
    canvas.addEventListener('pointermove',e=>{if(state.playing&&!state.locked&&state.expectsFind){state.hoverUnits=pointer(e);draw()}});canvas.addEventListener('pointerleave',()=>{if(state.expectsFind){state.hoverUnits=null;draw()}});canvas.addEventListener('pointerdown',e=>{if(state.playing&&!state.locked&&state.expectsFind){e.preventDefault();const u=pointer(e);state.hoverUnits=u;draw();check(u)}});
    canvas.addEventListener('keydown',e=>{if(!state.playing||state.locked||!state.expectsFind)return;const max=rulerMarks()*rulerLength();if(e.key==='ArrowRight'||e.key==='ArrowUp'){e.preventDefault();state.hoverUnits=Math.min(max,(state.hoverUnits??0)+1);draw()}else if(e.key==='ArrowLeft'||e.key==='ArrowDown'){e.preventDefault();state.hoverUnits=Math.max(0,(state.hoverUnits??0)-1);draw()}else if(e.key==='Enter'||e.key===' '){e.preventDefault();check(state.hoverUnits??0)}});
    $('#submit').onclick=()=>check(0);els.answer.addEventListener('keydown',e=>{if(e.key==='Enter')check(0)});els.start.onclick=startGame;$('#playAgain').onclick=startGame;$('#printResults').onclick=()=>window.print();els.skip.onclick=()=>check(null);
    document.querySelectorAll('.session-btn').forEach(b=>b.onclick=()=>{settings.session=b.dataset.session;localStorage.setItem('rulerLabSettings',JSON.stringify(settings));if(state.playing)startGame();else updateSessionUI()});
    document.querySelectorAll('.level-btn').forEach(b=>b.onclick=()=>{settings.challengeLevel=b.dataset.level;localStorage.setItem('rulerLabSettings',JSON.stringify(settings));if(state.playing)startGame();else updateSessionUI()});
    $('#openSettings').onclick=()=>$('#settingsDialog').showModal();$('#saveSettings').onclick=e=>{e.preventDefault();inputs.forEach(id=>settings[id]=$('#'+id).value);settings.studentName=$('#studentName').value.trim();if(+settings.questions>+settings.marks){settings.marks=settings.questions;$('#marks').value=settings.marks;toast('Ruler marks adjusted to match questions',false)}localStorage.setItem('rulerLabSettings',JSON.stringify(settings));$('#settingsDialog').close();if(state.playing)startGame();else updateSessionUI()};
    $('#copyPreset').onclick=async()=>{inputs.forEach(id=>settings[id]=$('#'+id).value);const url=new URL(location.href);url.search='';url.searchParams.set('session',settings.session);url.searchParams.set('challengeLevel',settings.challengeLevel);inputs.forEach(k=>url.searchParams.set(k,settings[k]));try{await navigator.clipboard.writeText(url.href);toast('Teacher preset link copied',false)}catch(e){prompt('Copy this teacher preset link:',url.href)}};
    $('#questions').onchange=()=>{if(+$('#questions').value>+$('#marks').value)$('#marks').value=$('#questions').value};$('#marks').onchange=()=>{if(+$('#marks').value<+$('#questions').value)$('#questions').value=$('#marks').value};$('#fullscreen').onclick=()=>{if(!document.fullscreenElement)document.documentElement.requestFullscreen?.();else document.exitFullscreen?.()};window.addEventListener('resize',draw);
    els.best.textContent=localStorage.getItem('rulerLabBest')||'0';updateSessionUI();
    if(document.modelContext?.registerTool){const register=t=>Promise.resolve(document.modelContext.registerTool(t)).catch(()=>{});register({name:'start_ruler_game',title:'Start ruler game',description:'Start or restart the visible ruler learning, practice, or shop challenge session.',inputSchema:{type:'object',properties:{session:{enum:['learn','practice','challenge']},challengeLevel:{enum:['easy','medium','hard']}},additionalProperties:false},annotations:{readOnlyHint:false,untrustedContentHint:false},execute:v=>{if(v?.session)settings.session=v.session;if(v?.challengeLevel)settings.challengeLevel=v.challengeLevel;updateSessionUI();startGame();return{status:'started',session:settings.session,challengeLevel:settings.challengeLevel,precision:settings.session==='challenge'?challengeDen():settings.questions}}});register({name:'configure_ruler_game',title:'Configure ruler game',description:'Change the visible ruler game settings.',inputSchema:{type:'object',properties:{session:{enum:['learn','practice','challenge']},challengeLevel:{enum:['easy','medium','hard']},mode:{enum:['find','type']},questions:{enum:['1','2','4','8','16','32','64']},marks:{enum:['2','4','8','16','32','64']},length:{enum:['auto','1','2','3','4','6','8','10','12']},notation:{enum:['fraction','decimal']},timer:{enum:['on','off']},measureTwice:{enum:['on','off']}},additionalProperties:false},annotations:{readOnlyHint:false,untrustedContentHint:false},execute:v=>{settings={...settings,...v};if(+settings.questions>+settings.marks)settings.marks=settings.questions;inputs.forEach(id=>$('#'+id).value=settings[id]);localStorage.setItem('rulerLabSettings',JSON.stringify(settings));updateSessionUI();return{status:'configured',settings}}})}
  })();
  </script>
</body>
</html>