41) Canvas Signature Pad + Save PNG — exercise41_signature_pad.html

<!doctype html>
<html lang=”en”>
<head>
<meta charset=”utf-8″>
<title>Signature Pad — Canvas + Pointer Events</title>
<!–
Build: Simple signature/drawing pad using <canvas> + Pointer Events with Save PNG.
Objectives:
– Use pointerdown/move/up/cancel to draw smooth lines on a canvas.
– Handle DPR (devicePixelRatio) for crisp rendering.
– Provide Clear and Save (download PNG) actions.
Steps:
1) Draw inside the canvas with mouse/touch/stylus.
2) Click “Save PNG” to download your drawing; “Clear” to reset.
3) Inspect how the canvas is scaled for devicePixelRatio.
–>
<style>
body{font:16px/1.5 system-ui,sans-serif;padding:1rem}
.wrap{max-width:680px}
canvas{border:1px solid #ddd;border-radius:.5rem;background:#fff;touch-action:none}
.row{margin:.5rem 0;display:flex;gap:.5rem;align-items:center}
input[type=color],input[type=range]{vertical-align:middle}
</style>
</head>
<body>
<div class=”wrap”>
<h1>Exercise 41 — Signature Pad</h1>
<div class=”row”>
<label>Color <input type=”color” id=”color” value=”#111111″></label>
<label>Width <input type=”range” id=”width” min=”1″ max=”20″ value=”3″></label>
<button id=”clear”>Clear</button>
<button id=”save”>Save PNG</button>
</div>
<canvas id=”pad” width=”640″ height=”360″ aria-label=”Signature pad”></canvas>
</div>
<script>
const canvas = document.getElementById(‘pad’);
const ctx = canvas.getContext(‘2d’);
const color = document.getElementById(‘color’);
const width = document.getElementById(‘width’);
function resizeForDPR(){
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = Math.round(rect.width * dpr);
canvas.height = Math.round(rect.height * dpr);
ctx.scale(dpr, dpr);
ctx.lineJoin = ctx.lineCap = ’round’;
ctx.lineWidth = Number(width.value);
ctx.strokeStyle = color.value;
ctx.fillStyle = ‘#ffffff’;
ctx.fillRect(0,0,rect.width,rect.height);
}
function ensureCSSSize(){ // keep CSS size stable
canvas.style.width = ‘640px’;
canvas.style.height = ‘360px’;
}
ensureCSSSize(); resizeForDPR();
window.addEventListener(‘resize’, ()=>{ ensureCSSSize(); resizeForDPR(); });
let drawing = false, last = null;
function pos(e){
const r = canvas.getBoundingClientRect();
return { x: e.clientX – r.left, y: e.clientY – r.top };
}
canvas.addEventListener(‘pointerdown’, (e)=>{ e.preventDefault(); drawing=true; last=pos(e); });
canvas.addEventListener(‘pointermove’, (e)=>{
if(!drawing) return;
const p = pos(e);
ctx.strokeStyle = color.value; ctx.lineWidth = Number(width.value);
ctx.beginPath(); ctx.moveTo(last.x,last.y); ctx.lineTo(p.x,p.y); ctx.stroke();
last = p;
});
[‘pointerup’,’pointercancel’,’pointerleave’].forEach(type =>
canvas.addEventListener(type, ()=> drawing=false));
document.getElementById(‘clear’).onclick = ()=>{
const r = canvas.getBoundingClientRect();
ctx.fillStyle = ‘#fff’; ctx.fillRect(0,0,r.width,r.height);
};
document.getElementById(‘save’).onclick = ()=>{
const a = document.createElement(‘a’);
a.href = canvas.toDataURL(‘image/png’);
a.download = ‘signature.png’; a.click();
};
</script>
</body>
</html>
42) Drag-and-Drop File Previews (Images) — exercise42_dnd_file_previews.html

<!doctype html>
<html lang=”en”>
<head>
<meta charset=”utf-8″>
<title>Drag & Drop — Image Previews with Validation</title>
<!–
Build: Dropzone that accepts multiple images with type/size validation and live previews.
Objectives:
– Handle dragenter/over/leave/drop events and prevent default.
– Validate file types and size; show errors per file.
– Use FileReader to preview thumbnails.
Steps:
1) Drag files over the dashed box or click to choose.
2) Valid images show as thumbnails; errors show below.
3) Inspect the drop handler for file iteration and validation.
–>
<style>
body{font:16px/1.5 system-ui,sans-serif;padding:1rem}
.drop{border:2px dashed #91d5ff;border-radius:.75rem;padding:1.25rem;text-align:center;background:#f7fbff}
.drop.drag{background:#e6f7ff}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:.5rem;margin-top:.75rem}
.card{border:1px solid #eee;border-radius:.5rem;overflow:hidden}
.card img{display:block;width:100%;height:96px;object-fit:cover}
.errors{color:#b00020;margin-top:.5rem}
.hidden{display:none}
</style>
</head>
<body>
<h1>Exercise 42 — Drag & Drop Previews</h1>
<div class=”drop” id=”drop” tabindex=”0″ role=”button” aria-label=”Upload images”>
<p><strong>Drop images</strong> here or click to select</p>
<input id=”file” type=”file” accept=”image/*” multiple class=”hidden”>
</div>
<div class=”grid” id=”grid”></div>
<div class=”errors” id=”errors”></div>
<script>
const drop = document.getElementById(‘drop’);
const input = document.getElementById(‘file’);
const grid = document.getElementById(‘grid’);
const errs = document.getElementById(‘errors’);
function showError(msg){ errs.innerHTML += `<div>• ${msg}</div>`; }
function clearErrors(){ errs.innerHTML=”; }
function handleFiles(files){
clearErrors();
[…files].forEach(f=>{
if(!f.type.startsWith(‘image/’)) return showError(`${f.name}: Not an image.`);
if(f.size > 2*1024*1024) return showError(`${f.name}: > 2MB.`);
const reader = new FileReader();
reader.onload = e=>{
const card = document.createElement(‘div’);
card.className=’card’;
card.innerHTML = `<img src=”${e.target.result}” alt=”${f.name}”><div style=”padding:.35rem .5rem”><small>${f.name}</small></div>`;
grid.appendChild(card);
};
reader.readAsDataURL(f);
});
}
[‘dragenter’,’dragover’].forEach(type=>{
drop.addEventListener(type, e=>{ e.preventDefault(); drop.classList.add(‘drag’); });
});
[‘dragleave’,’dragend’,’drop’].forEach(type=>{
drop.addEventListener(type, e=>{ drop.classList.remove(‘drag’); });
});
drop.addEventListener(‘drop’, e=>{
e.preventDefault();
if(e.dataTransfer.files?.length) handleFiles(e.dataTransfer.files);
});
drop.addEventListener(‘click’, ()=> input.click());
input.addEventListener(‘change’, ()=> handleFiles(input.files));
</script>
</body>
</html>
43) ARIA Live Region: Simulated Feed Loader — exercise43_live_region_feed.html

<!doctype html>
<html lang=”en”>
<head>
<meta charset=”utf-8″>
<title>ARIA Live — Announce New Items</title>
<!–
Build: Simulated “Load more” feed that appends items after a delay and announces via aria-live.
Objectives:
– Use aria-live=”polite” to announce additions to assistive tech.
– Simulate async with setTimeout and a loading state.
– Insert items efficiently and keep focus management polite.
Steps:
1) Click “Load more” to fetch 3 new items after ~800ms.
2) Hear/see an announcement “3 new items added”.
3) Inspect how aria-live text is updated.
–>
<style>
body{font:16px/1.5 system-ui,sans-serif;padding:1rem}
ul{max-width:520px}
li{border:1px solid #eee;border-radius:.5rem;padding:.5rem .75rem;margin:.4rem 0;background:#fff}
.row{display:flex;gap:.5rem;align-items:center}
.status{min-height:1.4em;color:#555}
</style>
</head>
<body>
<h1>Exercise 43 — ARIA Live Region</h1>
<div class=”row”>
<button id=”load”>Load more</button>
<span id=”state” class=”status” aria-live=”polite”></span>
</div>
<ul id=”feed”>
<li>Welcome to the feed</li>
<li>Initial item A</li>
<li>Initial item B</li>
</ul>
<script>
const feed = document.getElementById(‘feed’);
const btn = document.getElementById(‘load’);
const state = document.getElementById(‘state’);
let counter = 0;
btn.addEventListener(‘click’, ()=>{
btn.disabled = true;
state.textContent = ‘Loading…’;
setTimeout(()=>{
const count = 3;
for(let i=0;i<count;i++){
const li = document.createElement(‘li’);
li.textContent = `New item #${++counter}`;
feed.appendChild(li);
}
state.textContent = `${count} new items added`;
btn.disabled = false;
}, 800);
});
</script>
</body>
</html>
44) Editable List (CRUD + Reorder + localStorage) — exercise44_editable_list_storage.html

<!doctype html>
<html lang=”en”>
<head>
<meta charset=”utf-8″>
<title>Editable List — CRUD + Reorder + localStorage</title>
<!–
Build: Manage a to-do style list: add, edit inline, delete, move up/down; persist to localStorage.
Objectives:
– Implement event delegation for edit/save/delete controls.
– Persist the array to localStorage and initialize from it.
– Provide simple reordering without drag-and-drop.
Steps:
1) Add several items. Click a text to edit inline; Enter/Esc to save/cancel.
2) Use ↑/↓ buttons to reorder; use 🗑 to delete.
3) Refresh page — list persists from localStorage.
–>
<style>
body{font:16px/1.5 system-ui,sans-serif;padding:1rem}
.row{display:flex;gap:.5rem;margin-bottom:.5rem}
input{padding:.4rem}
ul{list-style:none;padding:0;max-width:540px}
li{display:flex;gap:.5rem;align-items:center;border:1px solid #eee;border-radius:.5rem;padding:.4rem .5rem;margin:.35rem 0;background:#fff}
li span.txt{flex:1;cursor:text}
button{padding:.25rem .45rem}
</style>
</head>
<body>
<h1>Exercise 44 — Editable List</h1>
<div class=”row”>
<input id=”new” placeholder=”Add item…”>
<button id=”add”>Add</button>
<button id=”clear”>Clear All</button>
</div>
<ul id=”list”></ul>
<script>
const KEY = ‘editable-list’;
const list = document.getElementById(‘list’);
const input = document.getElementById(‘new’);
let items = load();
function load(){ try{ return JSON.parse(localStorage.getItem(KEY))||[] }catch{ return [] } }
function save(){ localStorage.setItem(KEY, JSON.stringify(items)); }
function render(){
list.innerHTML = ”;
items.forEach((txt,i)=>{
const li = document.createElement(‘li’);
li.innerHTML = `
<button data-act=”up” data-i=”${i}”>↑</button>
<button data-act=”down” data-i=”${i}”>↓</button>
<span class=”txt” data-i=”${i}” tabindex=”0″>${txt}</span>
<button data-act=”del” data-i=”${i}” aria-label=”Delete”>🗑</button>`;
list.appendChild(li);
});
}
render();
document.getElementById(‘add’).onclick = ()=>{
const v = input.value.trim(); if(!v) return;
items.push(v); save(); render(); input.value=”; input.focus();
};
document.getElementById(‘clear’).onclick = ()=>{ items=[]; save(); render(); };
list.addEventListener(‘click’, (e)=>{
const b = e.target.closest(‘button’); if(!b) return;
const i = Number(b.dataset.i);
if (b.dataset.act===’del’){ items.splice(i,1); }
else if (b.dataset.act===’up’ && i>0){ [items[i-1],items[i]]=[items[i],items[i-1]]; }
else if (b.dataset.act===’down’ && i<items.length-1){ [items[i+1],items[i]]=[items[i],items[i+1]]; }
save(); render();
});
list.addEventListener(‘dblclick’, startEdit);
list.addEventListener(‘keydown’, (e)=>{ if(e.key===’Enter’) startEdit(e); });
function startEdit(e){
const span = e.target.closest(‘span.txt’); if(!span) return;
const i = Number(span.dataset.i);
const old = span.textContent;
const input = document.createElement(‘input’);
input.value = old; input.style.width=’100%’;
span.replaceWith(input); input.focus(); input.select();
function done(commit){
const val = input.value.trim();
input.replaceWith(span);
if (commit && val){ items[i]=val; save(); render(); }
}
input.addEventListener(‘keydown’, ev=>{
if(ev.key===’Enter’) done(true);
else if(ev.key===’Escape’) done(false);
});
input.addEventListener(‘blur’, ()=> done(true));
}
</script>
</body>
</html>
45) Synced Range + Number + Gradient Track — exercise45_range_number_sync.html

<!doctype html>
<html lang=”en”>
<head>
<meta charset=”utf-8″>
<title>Range ↔ Number Sync + Gradient Track</title>
<!–
Build: A range input synced with a number input; the slider track fills with a dynamic gradient.
Objectives:
– Keep two inputs in sync and clamp values within min/max.
– Style the range background using a computed linear-gradient.
– Show live value and percent.
Steps:
1) Move the slider; number box updates (and vice versa).
2) Watch the slider track fill based on the value.
3) Inspect gradient CSS string building.
–>
<style>
body{font:16px/1.5 system-ui,sans-serif;padding:1rem}
.row{display:flex;gap:.5rem;align-items:center;flex-wrap:wrap}
input[type=range]{width:320px;height:24px;border-radius:12px;appearance:none;background:#eee;outline:none}
input[type=range]::-webkit-slider-thumb{appearance:none;width:18px;height:18px;border-radius:50%;background:#444}
.stat{font-family:ui-monospace,Menlo,monospace}
</style>
</head>
<body>
<h1>Exercise 45 — Range & Number</h1>
<div class=”row”>
<input id=”range” type=”range” min=”0″ max=”100″ value=”30″>
<input id=”num” type=”number” min=”0″ max=”100″ value=”30″>
<span class=”stat” id=”pct”>30%</span>
</div>
<script>
const r = document.getElementById(‘range’);
const n = document.getElementById(‘num’);
const pct = document.getElementById(‘pct’);
function paint(){
const min = Number(r.min), max = Number(r.max), val = Number(r.value);
const p = (val – min) / (max – min) * 100;
r.style.background = `linear-gradient(90deg,#5ac8fa 0%,#5ac8fa ${p}%,#eee ${p}%,#eee 100%)`;
pct.textContent = Math.round(p) + ‘%’;
}
function sync(from, to){
to.value = from.value;
paint();
}
r.addEventListener(‘input’, ()=> sync(r, n));
n.addEventListener(‘input’, ()=>{
const v = Math.min(Number(n.max), Math.max(Number(n.min), Number(n.value||0)));
n.value = v; r.value = v; paint();
});
paint();
</script>
</body>
</html>
46) Paste Image from Clipboard to Gallery — exercise46_paste_image_gallery.html

<!doctype html>
<html lang=”en”>
<head>
<meta charset=”utf-8″>
<title>Clipboard — Paste Image to Gallery</title>
<!–
Build: Paste images (Cmd/Ctrl+V) directly into a gallery, using the Clipboard API on paste events.
Objectives:
– Listen to ‘paste’ and iterate ClipboardEvent.clipboardData.items.
– Extract images (image/*) as Blob/File and preview via object URLs.
– Clean up object URLs to avoid leaks.
Steps:
1) Copy an image (screenshot or from another app).
2) Focus page and press Ctrl/Cmd+V — image appears in the gallery.
3) Click “Clear” to remove all previews.
–>
<!doctype html>
<html lang=”en”>
<head>
<meta charset=”utf-8″>
<title>Clipboard — Paste Image to Gallery</title>
<!–
Build: Paste images (Cmd/Ctrl+V) directly into a gallery, using the Clipboard API on paste events.
Objectives:
– Listen to ‘paste’ and iterate ClipboardEvent.clipboardData.items.
– Extract images (image/*) as Blob/File and preview via object URLs.
– Clean up object URLs to avoid leaks.
Steps:
1) Copy an image (screenshot or from another app).
2) Focus page and press Ctrl/Cmd+V — image appears in the gallery.
3) Click “Clear” to remove all previews.
–>
<style>
body{font:16px/1.5 system-ui,sans-serif;padding:1rem}
.actions{margin-bottom:.5rem}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:.5rem}
.card{border:1px solid #eee;border-radius:.5rem;overflow:hidden}
.card img{display:block;width:100%;height:110px;object-fit:cover}
.hint{color:#666}
</style>
</head>
<body>
<h1>Exercise 46 — Paste Images</h1>
<p class=”hint”>Copy an image and press <kbd>Ctrl/Cmd + V</kbd> here.</p>
<div class=”actions”><button id=”clear”>Clear</button></div>
<div class=”grid” id=”grid”></div>
<script>
const grid = document.getElementById(‘grid’);
const urls = [];
document.addEventListener(‘paste’, (e)=>{
const items = e.clipboardData?.items || [];
for(const it of items){
if(it.type.startsWith(‘image/’)){
const file = it.getAsFile();
const url = URL.createObjectURL(file);
urls.push(url);
const card = document.createElement(‘div’);
card.className=’card’;
card.innerHTML = `<img src=”${url}” alt=”pasted image”>`;
grid.appendChild(card);
}
}
});
document.getElementById(‘clear’).onclick = ()=>{
grid.innerHTML=”;
urls.forEach(u=>URL.revokeObjectURL(u));
urls.length=0;
};
</script>
</body>
</html>
47) Form Autosave + Restore (localStorage) — exercise47_form_autosave.html

<!doctype html>
<html lang=”en”>
<head>
<meta charset=”utf-8″>
<title>Autosave Form — localStorage</title>
<!–
Build: Autosave a small form (title + content) to localStorage; restore on load, clear on demand.
Objectives:
– Debounce input events; save JSON payload with timestamp.
– Prompt to restore when draft exists; handle Clear.
– Show “Saved at” time.
Steps:
1) Type in fields; watch “Saved at” update.
2) Refresh — choose to restore the draft.
3) Click Clear to remove stored draft.
–>
<style>
body{font:16px/1.5 system-ui,sans-serif;padding:1rem}
input,textarea{width:100%;max-width:680px;padding:.6rem;margin:.35rem 0}
.row{display:flex;gap:.5rem;align-items:center;flex-wrap:wrap}
.stat{color:#666}
</style>
</head>
<body>
<h1>Exercise 47 — Autosave</h1>
<input id=”title” placeholder=”Title”>
<textarea id=”body” rows=”8″ placeholder=”Write something…”></textarea>
<div class=”row”>
<button id=”clear”>Clear</button>
<span id=”saved” class=”stat”></span>
</div>
<script>
const KEY=’draft-v1′;
const title = document.getElementById(‘title’);
const body = document.getElementById(‘body’);
const saved = document.getElementById(‘saved’);
let t=null;
function stamp(){
const d = new Date(); saved.textContent = ‘Saved at ‘ + d.toLocaleTimeString();
}
function save(){
const payload = {title:title.value, body:body.value, ts: Date.now()};
localStorage.setItem(KEY, JSON.stringify(payload));
stamp();
}
function debounce(fn, ms){ let id; return (…a)=>{ clearTimeout(id); id=setTimeout(()=>fn(…a), ms); }; }
const onInput = debounce(save, 300);
[title, body].forEach(el => el.addEventListener(‘input’, onInput));
(function init(){
try{
const raw = localStorage.getItem(KEY);
if(raw){
const d = JSON.parse(raw);
if ((title.value||body.value) === ”){
if (confirm(‘Restore saved draft?’)){
title.value = d.title || ”;
body.value = d.body || ”;
stamp();
}
}
}
}catch{}
})();
document.getElementById(‘clear’).onclick = ()=>{
localStorage.removeItem(KEY);
saved.textContent = ‘Cleared’;
};
</script>
</body>
</html>
48) Custom Select (Listbox pattern, keyboard-friendly) — exercise48_custom_select.html

<!doctype html>
<html lang=”en”>
<head>
<meta charset=”utf-8″>
<title>Custom Select — ARIA Listbox</title>
<!–
Build: A custom select using the ARIA listbox pattern with keyboard navigation and type-ahead.
Objectives:
– Toggle a popup listbox; move focus with ArrowUp/Down, Home/End; Enter selects; Esc closes.
– Roving tabindex within options; reflect selection in the button label.
– Basic type-ahead buffer to jump to options by typing letters.
Steps:
1) Click the button (or press Enter/Space) to open the list.
2) Use arrows to navigate; Enter to select; Esc or outside-click to close.
3) Type letters to jump to the next matching option.
–>
<style>
body{font:16px/1.5 system-ui,sans-serif;padding:1rem}
.wrap{position:relative;display:inline-block}
.btn{padding:.5rem .75rem;border:1px solid #ccc;border-radius:.5rem;background:#fff;min-width:220px;text-align:left}
.popup{position:absolute;left:0;top:calc(100% + 6px);border:1px solid #ddd;border-radius:.5rem;background:#fff;box-shadow:0 10px 30px rgba(0,0,0,.12);min-width:100%;display:none;max-height:220px;overflow:auto;z-index:5}
.popup.show{display:block}
[role=option]{padding:.45rem .6rem;cursor:pointer}
[role=option][aria-selected=true]{background:#e6f7ff}
[role=option]:hover{background:#f5f5f5}
</style>
</head>
<body>
<h1>Exercise 48 — Custom Select</h1>
<div class=”wrap”>
<button id=”button” class=”btn” aria-haspopup=”listbox” aria-expanded=”false”>Choose a language…</button>
<div id=”listbox” class=”popup” role=”listbox” tabindex=”-1″ aria-label=”Languages”>
<div role=”option” tabindex=”-1″>JavaScript</div>
<div role=”option” tabindex=”-1″>TypeScript</div>
<div role=”option” tabindex=”-1″>Python</div>
<div role=”option” tabindex=”-1″>Go</div>
<div role=”option” tabindex=”-1″>Rust</div>
<div role=”option” tabindex=”-1″>Ruby</div>
<div role=”option” tabindex=”-1″>PHP</div>
<div role=”option” tabindex=”-1″>Kotlin</div>
<div role=”option” tabindex=”-1″>Swift</div>
</div>
</div>
<script>
const btn = document.getElementById(‘button’);
const lb = document.getElementById(‘listbox’);
const opts = […lb.querySelectorAll(‘[role=option]’)];
let active = -1, buffer = ”, t=null;
function open(){
lb.classList.add(‘show’); btn.setAttribute(‘aria-expanded’,’true’);
active = Math.max(0, opts.findIndex(o => o.getAttribute(‘aria-selected’)===’true’));
focusActive();
document.addEventListener(‘click’, onDoc);
}
function close(){
lb.classList.remove(‘show’); btn.setAttribute(‘aria-expanded’,’false’);
btn.focus();
document.removeEventListener(‘click’, onDoc);
}
function onDoc(e){ if(!lb.contains(e.target) && e.target!==btn) close(); }
function focusActive(){
opts.forEach(o => o.tabIndex = -1);
if (active<0) active = 0;
opts[active].tabIndex = 0;
opts[active].focus({preventScroll:true});
opts[active].scrollIntoView({block:’nearest’});
}
function select(i){
opts.forEach(o => o.setAttribute(‘aria-selected’,’false’));
opts[i].setAttribute(‘aria-selected’,’true’);
btn.textContent = opts[i].textContent;
close();
}
btn.addEventListener(‘click’, ()=> lb.classList.contains(‘show’) ? close() : open());
btn.addEventListener(‘keydown’, e=>{
if (e.key===’ArrowDown’ || e.key===’Enter’ || e.key===’ ‘){ e.preventDefault(); open(); }
});
lb.addEventListener(‘keydown’, e=>{
if (e.key===’ArrowDown’){ active = Math.min(opts.length-1, active+1); focusActive(); }
else if (e.key===’ArrowUp’){ active = Math.max(0, active-1); focusActive(); }
else if (e.key===’Home’){ active = 0; focusActive(); }
else if (e.key===’End’){ active = opts.length-1; focusActive(); }
else if (e.key===’Enter’){ select(active); }
else if (e.key===’Escape’){ close(); }
else if (e.key.length===1 && /\w/.test(e.key)){ // type-ahead
buffer += e.key.toLowerCase(); clearTimeout(t);
t = setTimeout(()=> buffer=”, 600);
const i = opts.findIndex(o => o.textContent.toLowerCase().startsWith(buffer));
if (i>=0){ active = i; focusActive(); }
}
});
lb.addEventListener(‘click’, e=>{
const i = opts.indexOf(e.target.closest(‘[role=option]’)); if(i>=0) select(i);
});
</script>
</body>
</html>
49) Responsive Table → Cards (matchMedia) — exercise49_table_to_cards.html

<!doctype html>
<html lang=”en”>
<head>
<meta charset=”utf-8″>
<title>Responsive Table → Cards (matchMedia)</title>
<!–
Build: Switch between a table view and a card grid based on viewport width (JS-driven with matchMedia).
Objectives:
– Use window.matchMedia to detect a breakpoint and toggle templates.
– Render the same data set into two different DOM layouts.
– Update on resize and initial load.
Steps:
1) Resize the window below/above 700px to switch layouts.
2) Inspect the render functions for table vs. cards.
3) Notice we re-render from the same data source.
–>
<style>
body{font:16px/1.5 system-ui,sans-serif;padding:1rem}
table{border-collapse:collapse;min-width:520px}
th,td{border:1px solid #ddd;padding:.5rem .75rem;text-align:left}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:.75rem}
.card{border:1px solid #ddd;border-radius:.5rem;padding:.6rem;background:#fff}
.hint{color:#666}
</style>
</head>
<body>
<h1>Exercise 49 — Table / Cards</h1>
<p class=”hint”>Resize below 700px to see card layout.</p>
<div id=”root”></div>
<script>
const data = [
{name:’Ava’, role:’Engineer’, city:’Toronto’},
{name:’Noah’, role:’Designer’, city:’Ottawa’},
{name:’Mia’, role:’PM’, city:’Calgary’},
{name:’Liam’, role:’Engineer’, city:’Vancouver’},
{name:’Zoe’, role:’QA’, city:’Montreal’}
];
const root = document.getElementById(‘root’);
const mq = window.matchMedia(‘(max-width: 700px)’);
function render(){
root.innerHTML = ”;
if (mq.matches){
const grid = document.createElement(‘div’); grid.className=’grid’;
data.forEach(p=>{
const c = document.createElement(‘div’); c.className=’card’;
c.innerHTML = `<strong>${p.name}</strong><div>${p.role}</div><div>${p.city}</div>`;
grid.appendChild(c);
});
root.appendChild(grid);
} else {
const table = document.createElement(‘table’);
table.innerHTML = `<thead><tr><th>Name</th><th>Role</th><th>City</th></tr></thead><tbody></tbody>`;
const tb = table.querySelector(‘tbody’);
data.forEach(p=>{
const tr = document.createElement(‘tr’);
tr.innerHTML = `<td>${p.name}</td><td>${p.role}</td><td>${p.city}</td>`;
tb.appendChild(tr);
});
root.appendChild(table);
}
}
mq.addEventListener ? mq.addEventListener(‘change’, render) : mq.addListener(render);
render();
</script>
</body>
</html>
50) Theme Switcher (System/Light/Dark with CSS Vars) — exercise50_theme_switcher.html

<!doctype html>
<html lang=”en”>
<head>
<meta charset=”utf-8″>
<title>Theme Switcher — System / Light / Dark</title>
<!–
Build: App-level theme switcher that respects system setting by default and persists choice.
Objectives:
– Use CSS variables for colors; toggle data-theme=”light|dark” on <html>.
– Respect prefers-color-scheme when set to “system”.
– Persist selection to localStorage and update <meta name=”theme-color”>.
Steps:
1) Switch themes; reload to see persistence.
2) Set to “System” and change OS theme to observe sync.
3) Inspect how CSS vars + data-theme control colors.
–>
<meta name=”theme-color” content=”#ffffff”>
<style>
:root{
–bg: #ffffff; –fg: #111111; –card: #f7f7f7; –accent: #2d7ef7;
}
@media (prefers-color-scheme: dark){
:root{ –bg: #111111; –fg: #f2f2f2; –card: #1c1c1c; }
}
html[data-theme=”light”]{ –bg:#ffffff; –fg:#111111; –card:#f7f7f7; }
html[data-theme=”dark”]{ –bg:#111111; –fg:#f2f2f2; –card:#1c1c1c; }
body{background:var(–bg); color:var(–fg); font:16px/1.5 system-ui,sans-serif; padding:1rem}
.card{background:var(–card); border:1px solid rgba(0,0,0,.08); border-radius:.75rem; padding:1rem; max-width:640px}
select{padding:.4rem}
</style>
</head>
<body>
<h1>Exercise 50 — Theme Switcher</h1>
<div class=”card”>
<label>Theme:
<select id=”theme”>
<option value=”system”>System</option>
<option value=”light”>Light</option>
<option value=”dark”>Dark</option>
</select>
</label>
<p>This card reflects the active theme.</p>
</div>
<script>
const KEY=’theme-choice’;
const select = document.getElementById(‘theme’);
const meta = document.querySelector(‘meta[name=”theme-color”]’);
const prefersDark = window.matchMedia(‘(prefers-color-scheme: dark)’);
function apply(choice){
if (choice===’light’){ document.documentElement.setAttribute(‘data-theme’,’light’); meta.content = ‘#ffffff’; }
else if (choice===’dark’){ document.documentElement.setAttribute(‘data-theme’,’dark’); meta.content = ‘#111111’; }
else {
document.documentElement.removeAttribute(‘data-theme’);
meta.content = prefersDark.matches ? ‘#111111’ : ‘#ffffff’;
}
}
function save(choice){ localStorage.setItem(KEY, choice); }
function load(){ return localStorage.getItem(KEY) || ‘system’; }
select.value = load(); apply(select.value);
select.addEventListener(‘change’, ()=>{ apply(select.value); save(select.value); });
prefersDark.addEventListener ? prefersDark.addEventListener(‘change’, ()=> select.value===’system’ && apply(‘system’))
: prefersDark.addListener(()=> select.value===’system’ && apply(‘system’));
</script>
</body>
</html>