16 experiments, each set out as 1. Question, 2. Aim, 3. Steps, 4. Programme, 5. Execution and Results.
Code lives in labs/course-7-web/.
NOTE
These run, twice over. Every page is opened in Chromium, served over http from a local
server as a page should be, and what it shows is under 5. Execution and Results: the
page's headings, or what a driver typed, clicked and read back, with screenshots. The
interactive pages each have a driver beside them, _drive_10_string_ops.py and so on, run by
tools/data-science/capture_lab_outputs.py. Separately, every JavaScript function is executed
by tools/data-science/run_web_labs.js under Node 22 with jsdom, and its result asserted, and
every HTML file is structurally checked — balanced tags, every <label for> resolving to a
real id, every <img> carrying alt.
npm --prefix tools/data-science install # jsdom and jQuery, once
node tools/data-science/run_web_labs.js # from the repository root
Two things cannot be reached from where these pages are checked. jQuery's CDN,
code.jquery.com, is served instead from npm's copy of the same release, 3.7.1, and the page's
integrity attribute makes the browser check that it is the same file, byte for byte. The
weather API needs a key and a network, so Experiment 15 shows the page's own offline path. The
clock is fixed at 09:00 on 4 October 2026, Indian time, so the calendar and the greeting show
the same thing on every run.
Each experiment that has logic is split in two:
labs/course-7-web/
10_string_ops.js ← pure functions, exported — testable
10_string_ops.html ← the browser page that uses them
That split is what makes the labs verifiable. A function that takes its input
as a parameter and returns a value can be asserted; one that reads
document.getElementById(…) and writes to the page cannot be, without a
browser. The .js file holds the logic, the .html file holds the wiring.
The examiner will ask you to demonstrate in a browser, so know both halves.
Write a page that uses the HTML formatting options: bold, italics, underline, headings H1–H6, font type, size and colour, a coloured or image background, paragraphs, line breaks, a horizontal rule and <pre>.
Use HTML's formatting elements, and know which are obsolete and which carry meaning.
THE EXAM POINT
The syllabus asks for the <font> tag, which is
obsolete — removed in HTML5. Show that you know it existed and that CSS
replaced it:
<font face="Georgia" size="5" color="blue">Obsolete since HTML5</font>
<span style="font-family:Georgia; font-size:20px; color:blue">The modern way</span>
Likewise <b>, <i> and <u> still render, but <strong>, <em> and a CSS
underline carry meaning. Saying so is worth a mark.
<pre> is the only element that preserves whitespace and newlines. Everything
else collapses runs of spaces to one.
<!DOCTYPE html>
<html lang="en">
<!-- Step 1: Set up the head: charset, viewport, title and styles -->
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Experiment 1 — HTML formatting options</title>
<style>
body { font-family: Georgia, "Times New Roman", serif; line-height: 1.6;
max-width: 760px; margin: 2rem auto; padding: 0 1rem;
background-color: #f4f6f9; }
pre { background: #1e2430; color: #e6e9ef; padding: 1rem; border-radius: 6px;
overflow-x: auto; }
.bg-image { background-image: url("images/pattern.svg");
background-repeat: repeat; padding: 1rem; border-radius: 6px; }
</style>
</head>
<body>
<!-- Step 2: Show the six heading levels -->
<h1>Heading level 1 — H1</h1>
<h2>Heading level 2 — H2</h2>
<h3>Heading level 3 — H3</h3>
<h4>Heading level 4 — H4</h4>
<h5>Heading level 5 — H5</h5>
<h6>Heading level 6 — H6</h6>
<!-- Step 3: Bold, italics and underline -->
<h2>(a) (b) (c) Bold, italics and underline</h2>
<p>
<strong>Bold with <strong></strong> and <b>bold with <b></b>.<br>
<em>Italic with <em></em> and <i>italic with <i></i>.<br>
<u>Underlined with <u></u> and
<span style="text-decoration: underline;">underlined with CSS</span>.
</p>
<p>
<strong><strong></strong> and <strong><em></strong> are
<em>semantic</em> — they mean "important" and "emphasised", and a screen
reader changes its tone for them. <b><b></b> and <i><i></i> are
purely presentational. Prefer the semantic ones.
</p>
<!-- Step 4: Paragraphs, line breaks and a horizontal rule -->
<h2>(g) Paragraphs, (h) line breaks, (i) horizontal rule</h2>
<p>This is one paragraph. A paragraph is a block element, so the browser puts
space above and below it automatically.</p>
<p>Line one<br>Line two, after a <br><br>Line three</p>
<p>Use <code><br></code> only where the line break is part of the content
— an address, a poem. Never <code><br><br></code> for spacing;
that is what CSS margins are for.</p>
<hr>
<!-- Step 5: Set the font type, size and colour -->
<h2>(e) Font type, size and colour</h2>
<p style="font-family: Georgia, serif; font-size: 20px; color: #2b4c7e;">
Georgia, 20px, navy — set with CSS.
</p>
<p style="font-family: 'Courier New', monospace; font-size: 14px; color: #c92a2a;">
Courier New, 14px, red — set with CSS.
</p>
<p>
<font face="Georgia" size="5" color="blue">The <font> tag still
renders, but it was removed in HTML5 and must not be used.</font>
</p>
<!-- Step 6: Give a background colour and a background image -->
<h2>(f) Background — colour and image</h2>
<p>The page background is <code>#f4f6f9</code>, set on <code>body</code>.</p>
<div class="bg-image">
<p>This block uses <code>background-image</code> with
<code>background-repeat: repeat</code>.</p>
</div>
<!-- Step 7: Keep the spacing with pre -->
<h2>(j) The pre tag</h2>
<pre>
<pre> preserves whitespace
and line breaks
exactly as written.
Every other element collapses
runs of spaces into one.
</pre>
<hr>
<!-- Step 8: Show the other inline formatting -->
<h2>Other inline formatting worth knowing</h2>
<p>
<mark>Highlighted</mark> ·
<small>Small print</small> ·
<del>Deleted</del> <ins>Inserted</ins> ·
H<sub>2</sub>O · x<sup>2</sup> ·
<code>inline code</code> ·
<abbr title="Document Object Model">DOM</abbr> ·
<q>a short quotation</q>
</p>
<blockquote cite="https://www.w3.org/">
<p>The power of the Web is in its universality. Access by everyone
regardless of disability is an essential aspect.</p>
<footer>— Tim Berners-Lee</footer>
</blockquote>
</body>
</html>
OUTPUT
opened 01_formatting.html in Chromium, 900 px wide: "Experiment 1 — HTML formatting options"
H1 Heading level 1 — H1
H2 Heading level 2 — H2
H3 Heading level 3 — H3
H4 Heading level 4 — H4
H5 Heading level 5 — H5
H6 Heading level 6 — H6
H2 (a) (b) (c) Bold, italics and underline
H2 (g) Paragraphs, (h) line breaks, (i) horizontal rule
H2 (e) Font type, size and colour
H2 (f) Background — colour and image
H2 (j) The pre tag
H2 Other inline formatting worth knowing
elements: 12 p
[screenshot 1: the whole page]

RESULT
Every option renders: the six heading levels, the semantic and presentational bold and italic, the CSS fonts, the patterned background, and the <pre> block with its spacing kept.
Write a page with ordered, unordered and nested lists, and an image.
Use HTML's three list types, nest them correctly, and add an image with alt text.
THE MISTAKE EVERYONE MAKES
A nested list goes inside an <li>, not
between two of them.
<ul>
<li>Parent
<ul><li>Child</li></ul> <!-- correct: inside the li -->
</li>
</ul>
<ul>
<li>Parent</li>
<ul><li>Child</li></ul> <!-- WRONG: a ul directly inside a ul -->
</ul>
Both render similarly in a forgiving browser, but only the first validates, and only the first is announced correctly by a screen reader.
There are three list types: <ol> ordered, <ul> unordered, <dl>
description. type on <ol> gives 1, A, a, I, i.
<!DOCTYPE html>
<html lang="en">
<!-- Step 1: Set up the head: charset, viewport, title and styles -->
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Experiment 2 — Lists and an image</title>
<style>
body { font-family: system-ui, sans-serif; line-height: 1.6;
max-width: 760px; margin: 2rem auto; padding: 0 1rem; }
dt { font-weight: 700; margin-top: .6rem; }
dd { margin-left: 1.5rem; color: #444; }
figure { margin: 1.5rem 0; }
figcaption { font-size: .9rem; color: #666; }
img { max-width: 100%; height: auto; border-radius: 6px; }
</style>
</head>
<body>
<h1>Lists and an image</h1>
<!-- Step 2: An ordered list, and its type and start -->
<h2>(a) Ordered list</h2>
<ol>
<li>Semester I</li>
<li>Semester II</li>
<li>Semester III</li>
</ol>
<p>The <code>type</code> attribute changes the marker, and
<code>start</code> changes where the numbering begins:</p>
<ol type="I" start="3">
<li>Third, numbered with Roman numerals</li>
<li>Fourth</li>
</ol>
<!-- Step 3: An unordered list -->
<h2>(b) Unordered list</h2>
<ul>
<li>NumPy</li>
<li>Pandas</li>
<li>Matplotlib</li>
</ul>
<!-- Step 4: A nested list, inside an li -->
<h2>(c) Nested list</h2>
<ul>
<li>Year I
<ul>
<li>Computer Fundamentals</li>
<li>Problem Solving in C</li>
</ul>
</li>
<li>Year II
<ul>
<li>Database Management Systems</li>
<li>Data Science with R</li>
<li>Web Technologies
<ol type="a">
<li>HTML</li>
<li>CSS</li>
<li>JavaScript</li>
</ol>
</li>
</ul>
</li>
</ul>
<p><strong>Note the nesting rule:</strong> a nested list goes
<em>inside</em> an <code><li></code>, never between two of them.
Both forms render similarly, but only this one validates and only this one
is announced correctly by a screen reader.</p>
<!-- Step 5: A description list -->
<h2>Description list</h2>
<dl>
<dt>DOM</dt>
<dd>Document Object Model — the browser's in-memory tree of the page.</dd>
<dt>JSON</dt>
<dd>JavaScript Object Notation — a text format for exchanging data.</dd>
<dt>CSS</dt>
<dd>Cascading Style Sheets — the presentation layer.</dd>
</dl>
<!-- Step 6: An image, with alt text and a caption -->
<h2>(d) Image</h2>
<figure>
<img src="images/campus.svg" alt="A stylised drawing of the college campus"
width="400" height="250">
<figcaption>The campus. The <code>alt</code> text describes the image for
screen readers and displays if the file fails to load.</figcaption>
</figure>
</body>
</html>
OUTPUT
opened 02_lists.html in Chromium, 900 px wide: "Experiment 2 — Lists and an image"
H1 Lists and an image
H2 (a) Ordered list
H2 (b) Unordered list
H2 (c) Nested list
H2 Description list
H2 (d) Image
elements: 2 p, 18 li, 1 img
[screenshot 1: the whole page]

RESULT
The ordered list continues from III in Roman numerals, the nested list reaches three levels, and the image loads with its caption.
Align ten images using a table.
Lay images out in a table as asked, and then the way HTML5 intends, with CSS Grid.
STATE THE CAVEAT
State the caveat, because the examiner is listening for it. This experiment asks you to use a table for layout, which HTML5 forbids: tables are for tabular data, and a screen reader announces "table, 2 rows, 5 columns" to someone who cannot see the images. Do it as asked — then add the modern equivalent, which is three lines and also responsive:
.gallery { display: grid; gap: 8px;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }
object-fit: cover makes images of different aspect ratios fill an identical
box by cropping, rather than stretching. display: block removes the few
pixels of space under an inline image.
<!DOCTYPE html>
<html lang="en">
<!-- Step 1: Set up the head: charset, viewport, title and styles -->
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Experiment 3 — Ten images aligned with a table</title>
<style>
body { font-family: system-ui, sans-serif; line-height: 1.6;
max-width: 860px; margin: 2rem auto; padding: 0 1rem; }
table { border-collapse: collapse; margin: 1rem 0; }
caption { caption-side: top; text-align: left; font-weight: 700;
padding-bottom: .5rem; }
td { padding: 4px; border: 1px solid #d6dbe4; vertical-align: middle; }
td img { width: 150px; height: 110px; object-fit: cover; display: block;
border-radius: 4px; }
.wide img { width: 304px; }
.note { background: #fff8e6; border-left: 4px solid #f6c761;
padding: .8rem 1rem; border-radius: 0 6px 6px 0; }
.gallery { display: grid; gap: 8px;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }
.gallery img { width: 100%; height: 110px; object-fit: cover;
display: block; border-radius: 4px; }
</style>
</head>
<body>
<h1>Ten images aligned with a table</h1>
<!-- Step 2: Arrange ten images in a table -->
<table>
<caption>Gallery — a 2 × 5 arrangement using colspan</caption>
<tbody>
<tr>
<td><img src="images/1.svg" alt="Placeholder image one"></td>
<td class="wide" colspan="2"><img src="images/2.svg" alt="Placeholder image two"></td>
<td><img src="images/3.svg" alt="Placeholder image three"></td>
</tr>
<tr>
<td><img src="images/4.svg" alt="Placeholder image four"></td>
<td><img src="images/5.svg" alt="Placeholder image five"></td>
<td><img src="images/6.svg" alt="Placeholder image six"></td>
<td><img src="images/7.svg" alt="Placeholder image seven"></td>
</tr>
<tr>
<td rowspan="1"><img src="images/8.svg" alt="Placeholder image eight"></td>
<td class="wide" colspan="2"><img src="images/9.svg" alt="Placeholder image nine"></td>
<td><img src="images/10.svg" alt="Placeholder image ten"></td>
</tr>
</tbody>
</table>
<!-- Step 3: Say why a table is the wrong tool for layout -->
<div class="note">
<p><strong>The caveat this experiment is really about.</strong> The syllabus
asks you to align images using a table, which is <em>layout</em>, and HTML5
forbids it. Tables are for tabular data. A screen reader announces "table,
3 rows, 4 columns" to someone who cannot see the images, which helps nobody.</p>
<p>Do it as asked in the exam — then show the modern equivalent below, which
is three lines of CSS and is also responsive.</p>
</div>
<!-- Step 4: Lay out the same gallery with CSS Grid -->
<h2>The same gallery with CSS Grid</h2>
<div class="gallery">
<img src="images/1.svg" alt="Placeholder image one">
<img src="images/2.svg" alt="Placeholder image two">
<img src="images/3.svg" alt="Placeholder image three">
<img src="images/4.svg" alt="Placeholder image four">
<img src="images/5.svg" alt="Placeholder image five">
<img src="images/6.svg" alt="Placeholder image six">
<img src="images/7.svg" alt="Placeholder image seven">
<img src="images/8.svg" alt="Placeholder image eight">
<img src="images/9.svg" alt="Placeholder image nine">
<img src="images/10.svg" alt="Placeholder image ten">
</div>
<pre><code>.gallery { display: grid; gap: 8px;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }</code></pre>
<p><code>auto-fit</code> fits as many columns as will hold 150px and stretches
them to fill the row — a complete responsive gallery with no media queries.
<code>object-fit: cover</code> makes images of different aspect ratios fill an
identical box by cropping rather than stretching.</p>
</body>
</html>
OUTPUT
opened 03_image_table.html in Chromium, 900 px wide: "Experiment 3 — Ten images aligned with a table"
H1 Ten images aligned with a table
H2 The same gallery with CSS Grid
elements: 3 p, 20 img, 1 table
[screenshot 1: the whole page]

RESULT
The ten images sit in the table's grid, and again in the CSS Grid gallery below it.
Create a form with text boxes, radio buttons, check boxes, and reset and submit buttons.
Build a form from every kind of control, each labelled and named.
RADIO VS CHECKBOX, AND THE RULE BEHIND IT
Radio buttons in one group
must share the same name — that is what makes them mutually exclusive,
and different names give you three independent buttons that can all be on at
once. Checkboxes sharing a name submit as multiple values under that key.
Every input needs a name, or it is not submitted at all. Every input
needs a <label for> matching its id, or clicking the text does not focus
the field and a screen reader has nothing to announce.
<!DOCTYPE html>
<html lang="en">
<!-- Step 1: Set up the head: charset, viewport, title and styles -->
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Experiment 4 — A form with every control type</title>
<style>
body { font-family: system-ui, sans-serif; line-height: 1.6;
max-width: 620px; margin: 2rem auto; padding: 0 1rem; }
fieldset { border: 1px solid #ccd3dd; border-radius: 8px;
padding: 1rem; margin-bottom: 1rem; }
legend { font-weight: 700; color: #2b4c7e; padding: 0 .4rem; }
label { display: block; margin-bottom: .3rem; font-weight: 600; }
fieldset fieldset label { display: inline; font-weight: 400; margin-right: 1rem; }
input[type="text"], input[type="email"], input[type="password"],
input[type="number"], input[type="date"], select, textarea {
width: 100%; padding: 9px 11px; margin-bottom: 1rem; font: inherit;
border: 1px solid #ccd3dd; border-radius: 6px; }
input:focus, select:focus, textarea:focus {
border-color: #2b4c7e; outline: 3px solid rgba(43,76,126,.25); }
button { padding: 10px 18px; border: 0; border-radius: 6px; font: inherit;
cursor: pointer; margin-right: .5rem; }
button[type="submit"] { background: #2b4c7e; color: #fff; }
button[type="reset"] { background: #e8ecf2; color: #333; }
</style>
</head>
<body>
<h1>Form controls</h1>
<!-- Step 2: Open the form -->
<form action="/submit" method="post">
<fieldset>
<!-- Step 3: Text boxes, each with a label -->
<legend>(a) Text boxes</legend>
<label for="name">Name</label>
<input type="text" id="name" name="name" placeholder="Asha Kumari"
autocomplete="name">
<label for="email">Email</label>
<input type="email" id="email" name="email" autocomplete="email">
<label for="pw">Password</label>
<input type="password" id="pw" name="password" autocomplete="new-password">
<label for="age">Age</label>
<input type="number" id="age" name="age" min="16" max="100" step="1">
<label for="dob">Date of birth</label>
<input type="date" id="dob" name="dob">
<label for="about">About yourself</label>
<textarea id="about" name="about" rows="3" maxlength="200"></textarea>
</fieldset>
<fieldset>
<!-- Step 4: Radio buttons, sharing one name -->
<legend>(b) Radio buttons — one choice only</legend>
<input type="radio" id="gf" name="gender" value="F"><label for="gf">Female</label>
<input type="radio" id="gm" name="gender" value="M"><label for="gm">Male</label>
<input type="radio" id="go" name="gender" value="O"><label for="go">Other</label>
<p><small>All three share <code>name="gender"</code>. That shared name is
what makes them mutually exclusive — give them different names and all
three can be selected at once.</small></p>
</fieldset>
<fieldset>
<!-- Step 5: Check boxes -->
<legend>(c) Check boxes — any number</legend>
<input type="checkbox" id="s1" name="subjects" value="ds"><label for="s1">Data Science</label>
<input type="checkbox" id="s2" name="subjects" value="st"><label for="s2">Statistics</label>
<input type="checkbox" id="s3" name="subjects" value="cs"><label for="s3">Computer Science</label>
</fieldset>
<fieldset>
<!-- Step 6: A select list and a file picker -->
<legend>Select and file</legend>
<label for="course">Programme</label>
<select id="course" name="course">
<option value="">— select —</option>
<optgroup label="Science">
<option value="ds">Data Science</option>
<option value="st" selected>Statistics</option>
</optgroup>
<optgroup label="Commerce">
<option value="bcom">B.Com.</option>
</optgroup>
</select>
<label for="photo">Photograph</label>
<input type="file" id="photo" name="photo" accept="image/*">
</fieldset>
<fieldset>
<!-- Step 7: Reset and submit buttons -->
<legend>(d) Reset and submit</legend>
<button type="submit">Submit</button>
<button type="reset">Reset</button>
</fieldset>
</form>
<p><small><strong>Every input needs a <code>name</code></strong> — without one
the field is not submitted at all. Every input needs a <code><label
for></code> matching its <code>id</code>, or clicking the text does not
focus the field and a screen reader has nothing to announce. A placeholder is
not a label: it disappears the moment the user types.</small></p>
</body>
</html>
OUTPUT
opened 04_form_controls.html in Chromium, 900 px wide: "Experiment 4 — A form with every control type"
checked Female, then Male: Female is now False, Male True -- one name, so one choice
what the form would send, from FormData:
name=Asha Rao
email=asha@example.com
password=Passw0rd!
age=19
dob=2007-03-14
about=Second-year data science student.
gender=M
subjects=ds
subjects=cs
course=ds
[screenshot 1: filled in]
after Reset: name is '', Male checked False

The driver fills the form but does not submit it: it posts to /submit, and no server is
behind that address. FormData shows what would be sent — one gender, two subjects.
RESULT
Checking Male clears Female, as one shared name makes them; the two subjects submit under one key; and Reset empties the form.
Embed a calendar object in a web page.
Show a calendar three ways: the native date picker, an embedded calendar, and a month grid generated in JavaScript.
In HTML, 05_calendar.html:
In JavaScript, 05_calendar.js:
THREE MODERN ANSWERS
The syllabus says "embed a calendar object", which in 2004 meant an ActiveX
control. Three modern answers, in increasing order of effort: the native date picker (one
line), an embedded external calendar (an <iframe>), and a month grid you generate yourself,
which is the version worth learning because it is pure logic.
The trick worth remembering: new Date(y, m, 0) is day zero of month m,
which is the last day of month m − 1 — so it gives you the number of days
in a month, leap years included, with no table and no arithmetic.
In HTML, 05_calendar.html:
<!DOCTYPE html>
<html lang="en">
<!-- Step 1: Set up the head: charset, viewport, title and styles -->
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Experiment 5 — Embed a calendar</title>
<style>
body { font-family: system-ui, sans-serif; line-height: 1.6;
max-width: 620px; margin: 2rem auto; padding: 0 1rem; }
.controls { display: flex; gap: .5rem; align-items: center; margin: 1rem 0; }
button, select, input { font: inherit; padding: 6px 10px;
border: 1px solid #ccd3dd; border-radius: 6px; }
button { background: #2b4c7e; color: #fff; border: 0; cursor: pointer; }
table { border-collapse: collapse; width: 100%; }
caption { font-weight: 700; font-size: 1.1rem; padding-bottom: .5rem;
color: #2b4c7e; }
th, td { border: 1px solid #e2e7ee; padding: 8px; text-align: center; }
th { background: #eef2f7; }
td.empty { background: #fafbfd; }
td.today { background: #2b8a3e; color: #fff; font-weight: 700; }
</style>
</head>
<body>
<h1>Embedding a calendar</h1>
<!-- Step 2: The native date picker -->
<h2>1. The native date picker — one line</h2>
<label for="dob">Date of birth</label>
<input type="date" id="dob" name="dob" min="1990-01-01" max="2010-12-31">
<p><small>Every modern browser renders its own calendar widget for
<code><input type="date"></code>, localised and keyboard accessible,
for no effort at all.</small></p>
<!-- Step 3: An embedded external calendar -->
<h2>2. An embedded external calendar</h2>
<pre><code><iframe src="https://calendar.google.com/calendar/embed?src=…"
width="600" height="400" style="border:0"
title="Academic calendar" loading="lazy"></iframe></code></pre>
<p><small>Shown as markup rather than live, because it needs a real calendar
URL and network access. Note the <code>title</code> — an iframe without one
is announced as "frame" and nothing else.</small></p>
<!-- Step 4: Controls for a generated month grid -->
<h2>3. A month grid, generated in JavaScript</h2>
<div class="controls">
<button id="prev" type="button" aria-label="Previous month">←</button>
<select id="month" aria-label="Month"></select>
<input type="number" id="year" min="1900" max="2100" style="width: 6rem"
aria-label="Year">
<button id="next" type="button" aria-label="Next month">→</button>
</div>
<table id="cal"></table>
<script type="module">
import { renderCalendar, MONTH_NAMES } from "./05_calendar.js";
const table = document.getElementById("cal");
const monthSel = document.getElementById("month");
const yearIn = document.getElementById("year");
// Step 5: Fill the month list, and start at today's month
MONTH_NAMES.forEach((n, i) => {
const o = document.createElement("option");
o.value = String(i + 1);
o.textContent = n;
monthSel.append(o);
});
const now = new Date();
let year = now.getFullYear();
let month = now.getMonth() + 1;
// Step 6: Draw the month, and move between months
function draw() {
monthSel.value = String(month);
yearIn.value = String(year);
renderCalendar(year, month, table);
}
function shift(by) {
month += by;
if (month < 1) { month = 12; year--; }
if (month > 12) { month = 1; year++; }
draw();
}
document.getElementById("prev").addEventListener("click", () => shift(-1));
document.getElementById("next").addEventListener("click", () => shift(1));
monthSel.addEventListener("change", () => { month = Number(monthSel.value); draw(); });
yearIn.addEventListener("change", () => { year = Number(yearIn.value); draw(); });
draw();
</script>
</body>
</html>
In JavaScript, 05_calendar.js:
/* Experiment 5 — Embed a calendar object in a web page.
*
* The syllabus phrase "calendar object" dates from an era of ActiveX
* controls. Three modern answers, in the HTML: the native <input type="date">
* picker, an <iframe> embed, and a month grid generated here.
*
* This module is pure: it takes a year and month and returns data, with no
* reference to `document`. That is what makes it testable — see
* tools/run_web_labs.js.
*/
// Step 1: Name the months and days
export const MONTH_NAMES = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"
];
export const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
// Step 2: Count the days in a month
/** Number of days in a month. `month` is 1-12.
*
* new Date(y, m, 0) is "day zero" of month m, which the Date constructor
* normalises to the LAST day of month m-1. Because `month` here is 1-based
* and the constructor is 0-based, passing `month` straight through lands on
* the last day of the month we asked about — leap years included, with no
* table and no arithmetic of our own.
*/
export function daysInMonth(year, month) {
return new Date(year, month, 0).getDate();
}
// Step 3: Find the first weekday, and lay the month out in weeks
/** Weekday index (0 = Sunday) of the first of the month. */
export function firstWeekday(year, month) {
return new Date(year, month - 1, 1).getDay();
}
/** A month as an array of 7-element week rows.
* Cells before the 1st and after the last are null. */
export function monthMatrix(year, month) {
if (!Number.isInteger(month) || month < 1 || month > 12)
throw new RangeError("month must be an integer from 1 to 12");
const cells = Array(firstWeekday(year, month)).fill(null);
for (let d = 1; d <= daysInMonth(year, month); d++) cells.push(d);
while (cells.length % 7 !== 0) cells.push(null);
const weeks = [];
for (let i = 0; i < cells.length; i += 7) weeks.push(cells.slice(i, i + 7));
return weeks;
}
// Step 4: Test for a leap year
export function isLeapYear(y) {
return (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0;
}
// Step 5: Draw the month into a table
/** Render a month into a <table>. `doc` is injectable so jsdom can pass its
* own document; in a browser it defaults to the real one. */
export function renderCalendar(year, month, table, doc = document) {
table.textContent = "";
const caption = doc.createElement("caption");
caption.textContent = `${MONTH_NAMES[month - 1]} ${year}`;
table.append(caption);
const thead = doc.createElement("thead");
const hrow = doc.createElement("tr");
for (const d of DAY_NAMES) {
const th = doc.createElement("th");
th.scope = "col";
th.textContent = d;
hrow.append(th);
}
thead.append(hrow);
table.append(thead);
const today = new Date();
const isThisMonth = today.getFullYear() === year && today.getMonth() === month - 1;
const tbody = doc.createElement("tbody");
for (const week of monthMatrix(year, month)) {
const tr = doc.createElement("tr");
for (const day of week) {
const td = doc.createElement("td");
if (day === null) {
td.className = "empty";
} else {
td.textContent = String(day);
if (isThisMonth && day === today.getDate()) td.className = "today";
}
tr.append(td);
}
tbody.append(tr);
}
table.append(tbody);
return table;
}
OUTPUT
opened 05_calendar.html in Chromium, 900 px wide: "Experiment 5 — Embed a calendar"
October 2026 (today's month)
SunMonTueWedThuFriSat
1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31
today, marked: 4
[screenshot 1: October 2026, the month it opens on]
August 2026 (after Previous twice)
SunMonTueWedThuFriSat
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30 31
1 August 2026 is in column 6, a Saturday
February 2024 (February 2024, chosen from the list)
SunMonTueWedThuFriSat
1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29
29 days: 2024 is a leap year
[screenshot 2: February 2024]


Verified: February 2024 has 29 days, February 2025 has 28, and 1 August 2026 falls on a Saturday. The first and third are shown above; all three are asserted by the runner.
Note that JavaScript months are 0-indexed in the Date constructor
(January is 0) but 1-indexed in an ISO string. Mixing the two is the classic
date bug.
RESULT
The page opens on October 2026 with the 4th marked as today; it moves between months, and February 2024 has 29 days.
Create a form to subscribe to a mailing list.
Build a subscription form that asks for consent, and check it before it is sent.
CONSENT
An explicit consent checkbox, unticked by default, is not decoration — pre-ticked consent is unlawful under most data-protection regimes and is the kind of detail that separates a good answer from a complete one.
<!DOCTYPE html>
<html lang="en">
<!-- Step 1: Set up the head, and link the stylesheet -->
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Experiment 6 — Mailing list subscription form</title>
<link rel="stylesheet" href="07_styled_form.css">
</head>
<body>
<h1>Subscribe to the Data Science newsletter</h1>
<form id="subscribe" action="/subscribe" method="post" novalidate>
<fieldset>
<!-- Step 2: Ask for the email and name -->
<legend>Your details</legend>
<label for="email">Email address <span aria-hidden="true">*</span></label>
<input type="email" id="email" name="email" required
autocomplete="email" aria-describedby="email-error"
placeholder="asha@example.com">
<span id="email-error" class="error" role="alert"></span>
<label for="name">Name</label>
<input type="text" id="name" name="name" autocomplete="name"
placeholder="Asha Kumari">
<label for="org">College or organisation</label>
<input type="text" id="org" name="org" autocomplete="organization">
</fieldset>
<fieldset>
<!-- Step 3: Ask for the topics -->
<legend>What would you like to hear about?</legend>
<input type="checkbox" id="i1" name="topics" value="ds" checked>
<label for="i1">Data Science</label>
<input type="checkbox" id="i2" name="topics" value="ml">
<label for="i2">Machine Learning</label>
<input type="checkbox" id="i3" name="topics" value="viz">
<label for="i3">Visualization</label>
<input type="checkbox" id="i4" name="topics" value="jobs">
<label for="i4">Jobs and internships</label>
</fieldset>
<fieldset>
<!-- Step 4: Ask for the frequency, format and consent -->
<legend>Preferences</legend>
<label for="freq">How often?</label>
<select id="freq" name="freq">
<option value="d">Daily digest</option>
<option value="w" selected>Weekly summary</option>
<option value="m">Monthly round-up</option>
</select>
<fieldset>
<legend>Format</legend>
<input type="radio" id="fh" name="format" value="html" checked>
<label for="fh">HTML</label>
<input type="radio" id="ft" name="format" value="text">
<label for="ft">Plain text</label>
</fieldset>
</fieldset>
<input type="checkbox" id="consent" name="consent" required
aria-describedby="consent-error">
<label for="consent">I consent to receiving email at this address
<span aria-hidden="true">*</span></label>
<span id="consent-error" class="error" role="alert"></span>
<p><button type="submit">Subscribe</button>
<button type="reset">Clear</button></p>
<p id="status" role="status" aria-live="polite"></p>
</form>
<p><small>The consent checkbox is <strong>unticked by default</strong> and is
<code>required</code>. Pre-ticked consent is unlawful under most
data-protection regimes, and an examiner who knows that is looking for
it.</small></p>
<script type="module">
import { validate } from "./11_validation.js";
const form = document.getElementById("subscribe");
// Step 5: Check the email and the consent on submit
form.addEventListener("submit", e => {
e.preventDefault(); // demo only: nothing to POST to
const email = form.elements.email.value;
const box = document.getElementById("email-error");
const consentBox = document.getElementById("consent-error");
const emailOk = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(email.trim());
box.textContent = emailOk ? "" : "Enter a valid email address";
form.elements.email.classList.toggle("is-invalid", !emailOk);
const consentOk = form.elements.consent.checked;
consentBox.textContent = consentOk ? "" : "You must consent before subscribing";
document.getElementById("status").textContent =
emailOk && consentOk ? `Subscribed ${email.trim()}.` : "";
});
</script>
</body>
</html>
OUTPUT
opened 06_subscribe.html in Chromium, 900 px wide: "Experiment 6 — Mailing list subscription form"
submitted empty: email says 'Enter a valid email address'
consent says 'You must consent before subscribing'
[screenshot 1: submitted empty]
filled in: status says 'Subscribed asha@example.com.'
[screenshot 2: filled in and submitted]


The page borrows its look from 07_styled_form.css, so its styling is Experiment 7's.
RESULT
Submitted empty, the form asks for a valid email and for consent; filled in, it reports "Subscribed asha@example.com."
Style a registration form with CSS: different selectors, colours, borders and spacing.
Style a form with CSS, using every kind of selector.
In HTML, 07_styled_form.html:
In CSS, 07_styled_form.css:
SELECTORS, AND TWO DETAILS
Selector types demonstrated: element (form), class, id, attribute
(input[type="text"]), pseudo-class (:focus, :invalid), descendant,
and a custom-property :root block.
Two details the examiner looks for. font: inherit — form controls use
the operating system font unless told otherwise, so an unstyled input looks
alien beside your text. And :not(:placeholder-shown) — without it,
:invalid fires on an empty required field the moment the page loads, and the
form is red before the user has typed anything.
In HTML, 07_styled_form.html:
<!DOCTYPE html>
<html lang="en">
<!-- Step 1: Set up the head, and link the stylesheet -->
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Experiment 7 — A CSS-styled student registration form</title>
<link rel="stylesheet" href="07_styled_form.css">
</head>
<body>
<h1>Student registration</h1>
<form id="reg" action="/register" method="post" novalidate>
<fieldset>
<!-- Step 2: Personal details -->
<legend>Personal details</legend>
<label for="name">Full name <span aria-hidden="true">*</span></label>
<input type="text" id="name" name="name" required minlength="3" maxlength="50"
placeholder="Asha Kumari" autocomplete="name"
aria-describedby="name-error">
<span id="name-error" class="error" role="alert"></span>
<label for="email">Email <span aria-hidden="true">*</span></label>
<input type="email" id="email" name="email" required
placeholder="asha@nri.ac.in" autocomplete="email"
aria-describedby="email-error">
<span id="email-error" class="error" role="alert"></span>
<label for="mobile">Mobile <span aria-hidden="true">*</span></label>
<input type="tel" id="mobile" name="mobile" required
pattern="[6-9][0-9]{9}" placeholder="9876543210"
title="10-digit Indian mobile number" aria-describedby="mobile-error">
<span id="mobile-error" class="error" role="alert"></span>
<label for="dob">Date of birth</label>
<input type="date" id="dob" name="dob" max="2010-12-31">
<fieldset>
<legend>Gender</legend>
<input type="radio" id="gf" name="gender" value="F"><label for="gf">Female</label>
<input type="radio" id="gm" name="gender" value="M"><label for="gm">Male</label>
<input type="radio" id="go" name="gender" value="O"><label for="go">Other</label>
</fieldset>
</fieldset>
<fieldset>
<!-- Step 3: Account -->
<legend>Account</legend>
<label for="password">Password <span aria-hidden="true">*</span></label>
<input type="password" id="password" name="password" required
placeholder="At least 8 characters" autocomplete="new-password"
aria-describedby="password-error">
<span id="password-error" class="error" role="alert"></span>
<label for="confirm">Confirm password <span aria-hidden="true">*</span></label>
<input type="password" id="confirm" name="confirm" required
placeholder="Type it again" autocomplete="new-password"
aria-describedby="confirm-error">
<span id="confirm-error" class="error" role="alert"></span>
</fieldset>
<fieldset>
<!-- Step 4: Programme -->
<legend>Programme</legend>
<label for="course">Course</label>
<select id="course" name="course">
<option value="">— select —</option>
<option value="ds" selected>Data Science</option>
<option value="st">Statistics</option>
</select>
<label for="about">About yourself</label>
<textarea id="about" name="about" rows="4" maxlength="200"
placeholder="A sentence or two"></textarea>
</fieldset>
<input type="checkbox" id="terms" name="terms" required
aria-describedby="terms-error">
<label for="terms">I accept the terms <span aria-hidden="true">*</span></label>
<span id="terms-error" class="error" role="alert"></span>
<p><button type="submit">Register</button>
<button type="reset">Clear</button></p>
<p id="status" role="status" aria-live="polite"></p>
</form>
</body>
</html>
In CSS, 07_styled_form.css:
/* Experiment 7 — Style a registration form.
* Demonstrates: element, class, id, attribute, pseudo-class and descendant
* selectors; custom properties; colours, borders and spacing.
*/
/* Step 1: Define the colours once, as custom properties */
:root {
--brand: #2b4c7e;
--brand-dark: #1f3a63;
--danger: #c92a2a;
--ok: #2b8a3e;
--line: #ccd3dd;
--ink: #1f2733;
--muted: #5b6675;
}
*, *::before, *::after { box-sizing: border-box; }
/* Step 2: Lay out the page, the form and its fieldsets */
body {
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
line-height: 1.6;
color: var(--ink);
background: #eef2f7;
margin: 0;
padding: 2rem 1rem;
}
h1 { max-width: 560px; margin: 0 auto 1rem; color: var(--brand); }
body > p { max-width: 560px; margin: 1rem auto; color: var(--muted); }
form {
max-width: 560px;
margin: 0 auto;
padding: 1.5rem;
background: #fff;
border: 1px solid var(--line);
border-radius: 10px;
box-shadow: 0 2px 10px rgba(31, 39, 51, .06);
}
fieldset {
border: 1px solid var(--line);
border-radius: 8px;
padding: 1rem 1rem .5rem;
margin: 0 0 1.25rem;
}
legend {
font-weight: 700;
color: var(--brand);
padding: 0 .4rem;
}
/* Step 3: Style the labels and the text-like controls */
label {
display: block;
margin-bottom: .3rem;
font-weight: 600;
}
/* radio and checkbox labels sit beside their control, not above it */
input[type="radio"] + label,
input[type="checkbox"] + label {
display: inline;
font-weight: 400;
margin-right: 1.2rem;
}
/* attribute selectors — one rule for every text-like control */
input[type="text"],
input[type="email"],
input[type="tel"],
input[type="password"],
input[type="number"],
input[type="date"],
select,
textarea {
width: 100%;
padding: 10px 12px;
margin-bottom: 1rem;
border: 1px solid var(--line);
border-radius: 6px;
background: #fff;
font: inherit; /* form controls do NOT inherit fonts by default */
color: inherit;
transition: border-color .18s, box-shadow .18s;
}
textarea { resize: vertical; min-height: 5rem; }
/* Step 4: Show state with pseudo-classes */
/* pseudo-classes for state */
input:focus, select:focus, textarea:focus {
border-color: var(--brand);
outline: 3px solid rgba(43, 76, 126, .25);
outline-offset: 0;
}
/* :not(:placeholder-shown) stops an empty required field turning red on load */
input:invalid:not(:placeholder-shown) { border-color: var(--danger); }
input:valid:not(:placeholder-shown) { border-color: var(--ok); }
/* The script's verdict must beat the browser's. :valid knows only `required` and
the type, so to it 1234567890 is a good phone number; the script's rule, an
Indian mobile, says it is not. input:valid:not(:placeholder-shown) is more
specific than input.is-invalid, so .is-invalid repeats those pseudo-classes.
[Corrected: without them, a field the script rejected kept a green border.] */
input.is-invalid, input.is-invalid:valid:not(:placeholder-shown),
select.is-invalid, textarea.is-invalid {
border-color: var(--danger);
background: #fff6f6;
}
::placeholder { color: #9aa4b2; }
/* Step 5: Style the error messages and the buttons */
.error {
display: block;
min-height: 1.2em; /* reserve the space so the layout never jumps */
margin: -.75rem 0 .75rem;
color: var(--danger);
font-size: .85rem;
}
button {
padding: 10px 20px;
border: 0;
border-radius: 6px;
font: inherit;
font-weight: 600;
cursor: pointer;
transition: background .2s, transform .12s;
}
button[type="submit"] { background: var(--brand); color: #fff; }
button[type="submit"]:hover { background: var(--brand-dark); }
button[type="submit"]:active { transform: translateY(1px); }
button[type="reset"] { background: #e8ecf2; color: var(--ink); }
button:disabled { background: #aeb6c2; color: #fff; cursor: not-allowed; }
/* Step 6: Respect the user's settings */
/* :focus-visible shows the ring for keyboard users but not on a mouse click */
:focus-visible { outline: 3px solid var(--ok); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; animation: none !important; }
}
/* Visually hidden but still announced by screen readers. Used for table
captions that would be redundant on screen. Note: display:none would hide
it from assistive technology too, which is not what we want. */
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
OUTPUT
opened 07_styled_form.html in Chromium, 900 px wide: "Experiment 7 — A CSS-styled student registration form"
H1 Student registration
elements: 2 p, 1 form, 10 input, 1 select, 2 button
[screenshot 1: the whole page]

Corrected: the .is-invalid rule lost to input:valid:not(:placeholder-shown), which is
more specific, so a field the script had rejected kept a green border while showing its error —
in Experiment 11, a mobile number of 1234567890 looked accepted. The rule now repeats those
pseudo-classes, and the stylesheet says why.
RESULT
The form is drawn with the stylesheet's colours, borders and spacing, and no field is red before anything has been typed.
Create a responsive page with Flexbox and Grid.
Lay a page out with Flexbox and Grid so that it adapts to the screen's width.
In HTML, 08_responsive.html:
In CSS, 08_responsive.css:
THE VIEWPORT, AND THE RULE
<meta name="viewport" content="width=device-width, initial-scale=1">
Without that meta tag the page is not responsive, whatever the CSS says: a phone pretends to be 980px wide and renders your desktop layout shrunk to illegibility.
Flexbox for one dimension, Grid for two — that is the whole rule.
The repeat(auto-fit, minmax(240px, 1fr)) line is worth memorising: it fits
as many columns as will hold 240px each and stretches them to fill the row,
reflowing at every width with no media queries at all. It is a complete
responsive grid in one declaration.
In HTML, 08_responsive.html:
<!DOCTYPE html>
<html lang="en">
<!-- Step 1: Set up the head: the viewport meta, and the stylesheet -->
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Experiment 8 — Responsive page with Flexbox and Grid</title>
<link rel="stylesheet" href="08_responsive.css">
</head>
<body>
<!-- Step 2: A navigation bar -->
<nav class="nav">
<span class="brand">Data Science</span>
<ul>
<li><a href="#home">Home</a></li>
<li><a href="#courses">Courses</a></li>
<li><a href="#labs">Labs</a></li>
<li><a href="#contact">Contact</a></li>
</ul>
</nav>
<div class="page">
<!-- Step 3: The page header -->
<header>
<h1>Semester III</h1>
<p>Resize the window. The navigation wraps, the sidebar moves below the
content under 720px, and the cards reflow with no breakpoint of their
own.</p>
</header>
<!-- Step 4: A sidebar -->
<aside>
<h2>Units</h2>
<ul>
<li>HTML</li>
<li>CSS</li>
<li>JavaScript</li>
<li>Client-side scripting</li>
<li>JSON and jQuery</li>
</ul>
</aside>
<!-- Step 5: The main content: cards, and a media object -->
<main>
<h2>Courses</h2>
<div class="cards">
<article class="card">
<h3>Course 5</h3>
<p>Database Management Systems — SQL, normalisation, transactions.</p>
</article>
<article class="card">
<h3>Course 6</h3>
<p>Data Science with R — vectors, data frames, ggplot2, ARIMA.</p>
</article>
<article class="card">
<h3>Course 7</h3>
<p>Web Technologies — HTML, CSS, JavaScript, JSON and jQuery.</p>
</article>
<article class="card">
<h3>Course 8</h3>
<p>Data Mining — association rules, classification, clustering.</p>
</article>
</div>
<h2>A flex media object</h2>
<div class="media">
<img src="images/campus.svg" alt="The college campus">
<div class="body">
<h3>Why mobile first?</h3>
<p>Writing the small-screen layout as the default and adding
complexity with <code>min-width</code> produces less CSS than the
reverse, and degrades safely on devices you did not anticipate.</p>
</div>
</div>
</main>
<!-- Step 6: The footer -->
<footer class="no-print">
<p><small>Flexbox for one dimension, Grid for two. That is the whole
rule.</small></p>
</footer>
</div>
</body>
</html>
In CSS, 08_responsive.css:
/* Experiment 8 — A responsive page with Flexbox and Grid.
*
* Mobile-first: the base rules ARE the small-screen rules, and media queries
* add complexity as the viewport grows. This produces less CSS than the
* reverse and degrades safely on devices nobody anticipated.
*
* None of it works without <meta name="viewport"> in the HTML head.
*/
/* Step 1: Define the colours and the gap */
:root {
--brand: #2b4c7e;
--line: #d6dbe4;
--ink: #1f2733;
--gap: 1rem;
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, sans-serif;
line-height: 1.6;
color: var(--ink);
background: #f4f6f9;
}
/* Step 2: A navigation bar with Flexbox */
.nav {
display: flex;
flex-wrap: wrap; /* wrap instead of overflowing on a phone */
align-items: center;
justify-content: space-between;
gap: var(--gap);
padding: .8rem 1rem;
background: var(--brand);
color: #fff;
}
.nav .brand { font-weight: 700; font-size: 1.1rem; }
.nav ul {
display: flex;
flex-wrap: wrap;
gap: var(--gap);
list-style: none;
margin: 0;
padding: 0;
}
.nav a { color: #fff; text-decoration: none; padding: .2rem 0;
border-bottom: 2px solid transparent; }
.nav a:hover, .nav a:focus-visible { border-bottom-color: #fff; }
/* Step 3: A page layout with named Grid areas */
.page {
display: grid;
gap: var(--gap);
padding: var(--gap);
max-width: 1100px;
margin: 0 auto;
/* mobile first: one column, and MAIN COMES BEFORE THE SIDEBAR so a phone
user reads the content first */
grid-template-areas:
"head"
"main"
"side"
"foot";
grid-template-columns: 1fr;
}
.page > header { grid-area: head; }
.page > main { grid-area: main; }
.page > aside { grid-area: side; }
.page > footer { grid-area: foot; }
.page > * {
background: #fff;
border: 1px solid var(--line);
border-radius: 8px;
padding: 1rem;
}
/* Step 4: A card grid that needs no media query */
.cards {
display: grid;
gap: var(--gap);
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
.card {
border: 1px solid var(--line);
border-radius: 8px;
padding: 1rem;
background: #fbfcfe;
}
.card h3 { margin-top: 0; color: var(--brand); }
/* auto-fit fits as many columns as will hold 240px and stretches them to
fill the row. It reflows at every width with no breakpoints of our own. */
/* Step 5: A media object with Flexbox */
.media { display: flex; gap: var(--gap); flex-wrap: wrap; align-items: flex-start; }
.media img { width: 120px; height: 90px; object-fit: cover; border-radius: 6px;
flex: 0 0 auto; }
.media .body { flex: 1 1 240px; } /* grow, shrink, 240px basis */
/* Step 6: Change the layout at the breakpoints, for print and for reduced motion */
@media (min-width: 720px) {
.page {
grid-template-areas:
"head head"
"side main"
"foot foot";
grid-template-columns: 220px 1fr;
}
}
@media (min-width: 1100px) {
:root { --gap: 1.5rem; }
.page { grid-template-columns: 260px 1fr; }
}
@media print {
.nav, .no-print { display: none; }
body { background: #fff; }
.page > * { border: 0; }
}
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; animation: none !important; }
}
OUTPUT
opened 08_responsive.html in Chromium, 390 px wide: "Experiment 8 — Responsive page with Flexbox and Grid"
page grid columns: 358px
cards: 1 in the first row, of 4; the sidebar is below the content
[screenshot 1: 390 px wide]
opened 08_responsive.html in Chromium, 800 px wide: "Experiment 8 — Responsive page with Flexbox and Grid"
page grid columns: 220px 532px
cards: 2 in the first row, of 4; the sidebar is beside the content
[screenshot 2: 800 px wide]
opened 08_responsive.html in Chromium, 1200 px wide: "Experiment 8 — Responsive page with Flexbox and Grid"
page grid columns: 260px 768px
cards: 2 in the first row, of 4; the sidebar is beside the content
[screenshot 3: 1200 px wide]



Note that the mobile layout puts main above side, so a phone user
reads the content before the sidebar. Named grid areas make that reordering a
one-line change. At 1200 px the cards still come two to a row: their box is 734 px wide, and
three cards of 240 px, with the two 24 px gaps used above 1100 px, need 768.
RESULT
At 390 px there is one column, with the sidebar under the content; at 800 and 1200 px the sidebar sits beside it, and the cards reflow on their own.
Add hover effects and transitions to a page with CSS.
Animate elements on hover with CSS transitions, cheaply and considerately.
In HTML, 09_hover.html:
In CSS, 09_hover.css:
THREE POINTS EARN THE MARKS
Animate transform and opacity, not width, top or margin. The
browser can composite transform and opacity changes on the GPU without
recalculating layout; the others force a reflow on every frame and stutter.
overflow: hidden on the wrapper is what makes the zoom crop cleanly
instead of the enlarged image spilling over its neighbours.
The prefers-reduced-motion block respects users who get motion sickness
from animation. It is two lines and it is the difference between a page that
is merely pretty and one that is considerate.
In HTML, 09_hover.html:
<!DOCTYPE html>
<html lang="en">
<!-- Step 1: Set up the head, and link the stylesheet -->
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Experiment 9 — Hover effects and transitions</title>
<link rel="stylesheet" href="09_hover.css">
</head>
<body>
<h1>Hover effects and transitions</h1>
<p>Every effect on this page is pure CSS — there is no JavaScript at all.</p>
<!-- Step 2: Buttons -->
<h2>1. Buttons — background, lift and shadow</h2>
<p>
<button class="btn" type="button">Solid</button>
<button class="btn outline" type="button">Outline</button>
</p>
<p><small><code>transition: background .25s, transform .25s, box-shadow .25s</code>
— three properties, one declaration.</small></p>
<!-- Step 3: Images that zoom -->
<h2>2. Image zoom, cropped by the wrapper</h2>
<div class="thumbs">
<div class="thumb"><img src="images/1.svg" alt="Placeholder image one"></div>
<div class="thumb"><img src="images/2.svg" alt="Placeholder image two"></div>
<div class="thumb"><img src="images/3.svg" alt="Placeholder image three"></div>
<div class="thumb"><img src="images/4.svg" alt="Placeholder image four"></div>
</div>
<p><small><code>overflow: hidden</code> on the wrapper is what makes the zoom
crop cleanly instead of spilling over its neighbours.</small></p>
<!-- Step 4: Cards whose caption slides up -->
<h2>3. A caption that slides up</h2>
<div class="thumbs">
<figure class="card" style="margin:0">
<img src="images/5.svg" alt="Placeholder image five">
<figcaption class="caption">Slides up on hover</figcaption>
</figure>
<figure class="card" style="margin:0">
<img src="images/6.svg" alt="Placeholder image six">
<figcaption class="caption">rgba background, crisp text</figcaption>
</figure>
</div>
<p><small>The overlay uses <code>rgba(0,0,0,.65)</code>, not
<code>opacity</code>. <code>opacity</code> would fade the white text
too.</small></p>
<!-- Step 5: A growing underline -->
<h2>4. An underline that grows from the centre</h2>
<p>An <a class="link" href="#4">animated link underline</a>, built with
<code>::after</code> and a width transition.</p>
<!-- Step 6: A tooltip -->
<h2>5. A CSS-only tooltip</h2>
<p>The coefficient of determination
<span class="tip" tabindex="0" data-tip="Proportion of variance explained">R²</span>
is covered in Course 4.</p>
<p><small><code>content: attr(data-tip)</code> reads the text straight out of
the HTML attribute, so one rule serves every tooltip on the page.</small></p>
<!-- Step 7: A keyframe animation -->
<h2>6. Keyframe animation</h2>
<p class="fade-up">This paragraph faded up when the page loaded.</p>
<p><button class="btn pulse" type="button">Hover to pulse</button></p>
<p><small><strong>Note the <code>prefers-reduced-motion</code> block</strong>
at the end of the stylesheet. Two lines, and it is the difference between a
page that is merely pretty and one that is considerate.</small></p>
</body>
</html>
In CSS, 09_hover.css:
/* Experiment 9 — Hover effects and transitions.
*
* Rule of thumb: animate `transform` and `opacity`. The browser can composite
* those on the GPU without recalculating layout. Animating `width`, `height`,
* `top` or `margin` forces a reflow on every frame and stutters.
*/
*, *::before, *::after { box-sizing: border-box; }
body {
font-family: system-ui, sans-serif;
line-height: 1.6;
max-width: 860px;
margin: 2rem auto;
padding: 0 1rem;
color: #1f2733;
background: #f4f6f9;
}
h2 { color: #2b4c7e; margin-top: 2.5rem; }
/* Step 1: Buttons: background, lift and shadow */
.btn {
display: inline-block;
padding: 10px 20px;
border: 0;
border-radius: 6px;
background: #2b4c7e;
color: #fff;
font: inherit;
font-weight: 600;
cursor: pointer;
text-decoration: none;
transition: background .25s ease, transform .25s ease, box-shadow .25s ease;
}
.btn:hover { background: #1f3a63; transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(31, 39, 51, .25); }
.btn:active { transform: translateY(0); box-shadow: none; }
.btn.outline { background: transparent; color: #2b4c7e;
box-shadow: inset 0 0 0 2px #2b4c7e; }
.btn.outline:hover { background: #2b4c7e; color: #fff; }
/* Step 2: Image zoom, cropped by the wrapper */
.thumbs { display: grid; gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); }
.thumb {
overflow: hidden; /* THIS is what crops the zoom cleanly */
border-radius: 8px;
border: 1px solid #d6dbe4;
background: #fff;
}
.thumb img {
display: block;
width: 100%;
height: 130px;
object-fit: cover;
filter: grayscale(60%);
transition: transform .4s ease, filter .4s ease;
}
.thumb:hover img { transform: scale(1.1); filter: grayscale(0); }
/* Step 3: A caption that slides up on hover */
.card {
position: relative; /* the anchor for the absolute caption */
overflow: hidden;
border-radius: 8px;
}
.card img { display: block; width: 100%; height: 180px; object-fit: cover; }
.card .caption {
position: absolute;
inset: auto 0 0 0; /* top auto, right/bottom/left 0 */
background: rgba(0, 0, 0, .65); /* rgba, NOT opacity — the text stays crisp */
color: #fff;
padding: .7rem 1rem;
transform: translateY(100%);
transition: transform .3s ease;
}
.card:hover .caption,
.card:focus-within .caption { transform: translateY(0); }
/* Step 4: An underline that grows from the centre */
.link {
position: relative;
color: #2b4c7e;
text-decoration: none;
font-weight: 600;
}
.link::after {
content: ""; /* mandatory on ::before / ::after */
position: absolute;
left: 50%; bottom: -3px;
width: 0; height: 2px;
background: #2b4c7e;
transition: width .25s ease, left .25s ease;
}
.link:hover::after,
.link:focus-visible::after { width: 100%; left: 0; }
/* Step 5: A CSS-only tooltip */
.tip {
position: relative;
border-bottom: 1px dotted #888;
cursor: help;
}
.tip::after {
content: attr(data-tip); /* pull the text out of the HTML attribute */
position: absolute;
bottom: 125%; left: 50%;
transform: translateX(-50%); /* centre on the anchor, whatever the width */
background: #1f2733;
color: #fff;
padding: 6px 10px;
border-radius: 4px;
white-space: nowrap;
font-size: .85rem;
opacity: 0;
visibility: hidden; /* toggled with opacity so it is unclickable */
transition: opacity .25s ease;
}
.tip:hover::after, .tip:focus::after { opacity: 1; visibility: visible; }
/* Step 6: Keyframe animations */
@keyframes fade-up {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: none; }
}
.fade-up { animation: fade-up .5s ease-out both; }
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.06); }
}
.pulse:hover { animation: pulse .6s ease-in-out infinite; }
/* Step 7: Respect the user's motion preference */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
transition: none !important;
animation: none !important;
}
}
OUTPUT
opened 09_hover.html in Chromium, 900 px wide: "Experiment 9 — Hover effects and transitions"
button: transform none -> matrix(1, 0, 0, 1, 0, -2) (lifted 2 px)
[screenshot 1: hovering over the first button]
image: filter grayscale(0.6) -> grayscale(0), transform none -> matrix(1.1, 0, 0, 1.1, 0, 0)
[screenshot 2: hovering over the first image]
caption: transform matrix(1, 0, 0, 1, 0, 47.9688) -> matrix(1, 0, 0, 1, 0, 0) (slid up into view)
[screenshot 3: hovering over the first card]
tooltip: opacity of its ::after is 1
[screenshot 4: hovering over the tooltip]




Note that rgba() fades only the caption's background; opacity there would
fade the white text too.
RESULT
On hover the button lifts 2 px, the image loses its grey and scales by 1.1, the caption slides up into view, and the tooltip appears.
Write JavaScript string operations: reverse a string, take a substring, and count the vowels.
Operate on strings in JavaScript, and show the results live on a page.
In HTML, 10_string_ops.html:
In JavaScript, 10_string_ops.js:
THREE THINGS TO NOTICE
[...s] splits by code point, while s.split("") splits by UTF-16 code
unit. For plain ASCII they agree; for an emoji or an accented character
split("") tears the character in half and the reversal produces garbage.
(s.match(…) || []) is not optional. match returns null when nothing
matches, not an empty array, and null.length throws.
s.trim().split(/\s+/) on an empty string gives [""], whose length is 1 —
so a word count needs the explicit empty check, or every blank input reports
one word.
In HTML, 10_string_ops.html:
<!DOCTYPE html>
<html lang="en">
<!-- Step 1: Set up the head, and link the stylesheet -->
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Experiment 10 — String operations</title>
<link rel="stylesheet" href="07_styled_form.css">
<style>
table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
th, td { border: 1px solid #d6dbe4; padding: 8px 10px; text-align: left; }
th { background: #eef2f7; width: 40%; }
tbody tr:nth-child(even) { background: #fafbfd; }
code { background: #eef2f7; padding: 1px 5px; border-radius: 3px; }
</style>
</head>
<body>
<h1>String operations</h1>
<!-- Step 2: A text box for the string -->
<form id="f" onsubmit="return false">
<fieldset>
<legend>Input</legend>
<label for="text">Enter some text</label>
<input type="text" id="text" name="text" value="Data Science"
placeholder="Type anything">
</fieldset>
<table>
<caption class="sr-only">Computed string statistics</caption>
<tbody id="out"></tbody>
</table>
</form>
<script type="module">
import { stats } from "./10_string_ops.js";
const input = document.getElementById("text");
const out = document.getElementById("out");
const LABELS = {
length: "Length", words: "Words", vowels: "Vowels",
consonants: "Consonants", digits: "Digits",
upper: "Uppercase", lower: "Lowercase", title: "Title Case",
reversed: "Reversed", firstFour: "slice(0, 4)", lastSeven: "slice(-7)",
isPalindrome: "Palindrome?", longestWord: "Longest word"
};
// Step 3: Show every result for what is typed
function render() {
const s = stats(input.value);
out.textContent = "";
for (const [key, label] of Object.entries(LABELS)) {
const tr = document.createElement("tr");
const th = document.createElement("th");
const td = document.createElement("td");
th.scope = "row";
th.textContent = label;
td.textContent = String(s[key]); // textContent, never innerHTML
tr.append(th, td);
out.append(tr);
}
}
// Step 4: Update on every keystroke
input.addEventListener("input", render); // live, on every keystroke
render();
</script>
</body>
</html>
In JavaScript, 10_string_ops.js:
/* Experiment 10 — JavaScript string operations.
*
* Reverse, substring, count vowels — plus the statistics a viva usually asks
* for on top. Every function is pure: input in, value out, no DOM. That is
* what lets tools/run_web_labs.js assert them without a browser.
*/
// Step 1: Reverse a string
/** Reverse a string.
*
* [...s] splits by CODE POINT; s.split("") splits by UTF-16 code unit. For
* plain ASCII they agree, but split("") tears an emoji or a surrogate pair in
* half and the reversal produces garbage.
*/
export function reverse(s) {
return [...s].reverse().join("");
}
// Step 2: Count the vowels, consonants and words
export function countVowels(s) {
return [...s.toLowerCase()].filter(c => "aeiou".includes(c)).length;
}
export function countConsonants(s) {
return (s.match(/[b-df-hj-np-tv-z]/gi) || []).length;
}
/** Word count. s.trim().split(/\s+/) on an empty string gives [""], whose
* length is 1 — so a blank input would otherwise report one word. */
export function countWords(s) {
const t = s.trim();
return t === "" ? 0 : t.split(/\s+/).length;
}
// Step 3: Test for a palindrome, title-case, and count each character
export function isPalindrome(s) {
const clean = s.toLowerCase().replace(/[^a-z0-9]/g, "");
return clean.length > 0 && clean === reverse(clean);
}
export function titleCase(s) {
return s.toLowerCase().replace(/\b[a-z]/g, c => c.toUpperCase());
}
/** Frequency of each character, ignoring case and whitespace. */
export function charFrequency(s) {
const freq = {};
for (const c of s.toLowerCase()) {
if (/\s/.test(c)) continue;
freq[c] = (freq[c] || 0) + 1; // (freq[c] || 0) handles the first hit
}
return freq;
}
// Step 4: Find the longest word
export function longestWord(s) {
const words = s.trim() === "" ? [] : s.trim().split(/\s+/);
return words.reduce((best, w) => (w.length > best.length ? w : best), "");
}
// Step 5: Put everything together
/** Everything at once — what the browser page displays. */
export function stats(s) {
return {
length: s.length,
words: countWords(s),
vowels: countVowels(s),
consonants: countConsonants(s),
digits: (s.match(/\d/g) || []).length,
upper: s.toUpperCase(),
lower: s.toLowerCase(),
title: titleCase(s),
reversed: reverse(s),
firstFour: s.slice(0, 4),
lastSeven: s.slice(-7),
isPalindrome: isPalindrome(s),
longestWord: longestWord(s)
};
}
OUTPUT
opened 10_string_ops.html in Chromium, 900 px wide: "Experiment 10 — String operations"
typed 'Data Science'; the page shows:
Length 12
Words 2
Vowels 5
Consonants 6
Digits 0
Uppercase DATA SCIENCE
Lowercase data science
Title Case Data Science
Reversed ecneicS ataD
slice(0, 4) Data
slice(-7) Science
Palindrome? false
Longest word Science
[screenshot 1: after typing 'Data Science']
typed 'A man, a plan, a canal: Panama'; the page shows:
Length 30
Words 7
Vowels 10
Consonants 11
Digits 0
Uppercase A MAN, A PLAN, A CANAL: PANAMA
Lowercase a man, a plan, a canal: panama
Title Case A Man, A Plan, A Canal: Panama
Reversed amanaP :lanac a ,nalp a ,nam A
slice(0, 4) A ma
slice(-7) Panama
Palindrome? true
Longest word canal:
[screenshot 2: after typing 'A man, a plan, a canal: Panama']


Asserted by the runner: reverse("Data Science") === "ecneicS ataD",
countVowels("Data Science") === 5, isPalindrome("A man, a plan, a canal: Panama")
=== true, and stats("").words === 0. The longest word of the second string is
"canal:", colon and all: words are split on spaces only.
RESULT
"Data Science" has 12 characters, 2 words and 5 vowels, and reverses to "ecneicS ataD"; "A man, a plan, a canal: Panama" is a palindrome.
Validate a form in JavaScript: the email's format, the password's length and the required fields.
Check a form's fields in JavaScript before it is sent, and show each error beside its field.
In HTML, 11_validation.html:
In JavaScript, 11_validation.js:
In JavaScript, 11_validation_wire.js:
THE DETAIL MOST OFTEN MISSED
The HTML half wires it up, and its submit handler is the part that matters:
form.addEventListener("submit", e => {
const errors = validate(readValues());
for (const id of [...Object.keys(RULES), "confirm", "terms"])
setFieldError(form.elements[id], errors[id] || "");
if (Object.keys(errors).length) {
e.preventDefault(); // THIS is what cancels submit
form.querySelector(".is-invalid")?.focus(); // move focus to the first problem
}
});
FormData omits unchecked checkboxes entirely — the key is simply absent,
not false — so the terms value has to be read from .checked separately.
That one line is the most commonly missed detail in this experiment.
And say it in the viva: this is a convenience, not a control. The server validates again.
In HTML, 11_validation.html:
<!DOCTYPE html>
<html lang="en">
<!-- Step 1: Set up the head, and link the stylesheet -->
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Experiment 11 — JavaScript form validation</title>
<link rel="stylesheet" href="07_styled_form.css">
</head>
<body>
<h1>Form validation</h1>
<p>Leave a field blank or wrong and press <strong>Register</strong>. Errors
appear inline, and focus jumps to the first problem.</p>
<!-- Step 2: The registration form, with a place for each error -->
<form id="reg" action="/register" method="post" novalidate>
<fieldset>
<legend>Details</legend>
<label for="name">Full name <span aria-hidden="true">*</span></label>
<input type="text" id="name" name="name" required
placeholder="Asha Kumari" autocomplete="name"
aria-describedby="name-error">
<span id="name-error" class="error" role="alert"></span>
<label for="email">Email <span aria-hidden="true">*</span></label>
<input type="email" id="email" name="email" required
placeholder="asha@nri.ac.in" autocomplete="email"
aria-describedby="email-error">
<span id="email-error" class="error" role="alert"></span>
<label for="mobile">Mobile <span aria-hidden="true">*</span></label>
<input type="tel" id="mobile" name="mobile" required
placeholder="9876543210" aria-describedby="mobile-error">
<span id="mobile-error" class="error" role="alert"></span>
</fieldset>
<fieldset>
<legend>Password</legend>
<label for="password">Password <span aria-hidden="true">*</span></label>
<input type="password" id="password" name="password" required
placeholder="8+ chars, mixed case, digit, symbol"
autocomplete="new-password" aria-describedby="password-error">
<span id="password-error" class="error" role="alert"></span>
<label for="confirm">Confirm password <span aria-hidden="true">*</span></label>
<input type="password" id="confirm" name="confirm" required
placeholder="Type it again" autocomplete="new-password"
aria-describedby="confirm-error">
<span id="confirm-error" class="error" role="alert"></span>
</fieldset>
<input type="checkbox" id="terms" name="terms" required
aria-describedby="terms-error">
<label for="terms">I accept the terms <span aria-hidden="true">*</span></label>
<span id="terms-error" class="error" role="alert"></span>
<p><button type="submit">Register</button>
<button type="reset">Clear</button></p>
<p id="status" role="status" aria-live="polite"></p>
</form>
<p><small><strong>This is not security.</strong> It runs on the user's machine
and can be bypassed by disabling JavaScript, editing the DOM, or sending the
request with <code>curl</code>. The server must validate every field
again.</small></p>
<!-- Step 3: Load the code that checks it -->
<script type="module" src="11_validation_wire.js"></script>
</body>
</html>
In JavaScript, 11_validation.js:
/* Experiment 11 — Form validation.
*
* The rules are DATA, not a wall of if-statements, so adding a field is one
* line and the same array drives both submit-time and live validation.
*
* IMPORTANT: none of this is security. It runs on the user's machine and can
* be bypassed by disabling JavaScript, editing the DOM, or sending the request
* with curl. The server must validate every field again.
*/
// Step 1: Write a rule for each field
export const RULES = {
name: {
test: v => v.trim().length >= 3 && v.trim().length <= 50,
msg: "Name must be 3 to 50 characters"
},
email: {
// A plausible shape, not an RFC-complete pattern. The only real proof an
// address exists is sending a confirmation message to it.
test: v => /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(v.trim()),
msg: "Enter a valid email address"
},
mobile: {
test: v => /^[6-9]\d{9}$/.test(v.trim()),
msg: "Enter a 10-digit Indian mobile number"
},
password: {
// Four lookaheads assert lowercase, uppercase, digit and symbol in any
// order without consuming anything; .{8,} then does the matching.
test: v => /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]).{8,}$/.test(v),
msg: "8+ characters with upper, lower, digit and symbol"
}
};
// Step 2: Check every rule, and the two that compare fields
/** Validate a plain object of values. Returns { field: message } — empty when
* everything passed. */
export function validate(values) {
const errors = {};
for (const [field, rule] of Object.entries(RULES))
if (!rule.test(values[field] ?? "")) errors[field] = rule.msg;
// Rules that compare two fields cannot be expressed as a pattern.
if (values.password !== values.confirm)
errors.confirm = "Passwords do not match";
// A checkbox is read from .checked, never .value.
if (!values.terms)
errors.terms = "You must accept the terms";
return errors;
}
// Step 3: Check that a date is real, and find an age
/** A real calendar date. 2026-02-30 matches the shape but does not exist:
* new Date(2026, 1, 30) rolls over to 2 March, so comparing the components
* back is what proves the date was real. */
export function isRealDate(s) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return false;
const [y, m, d] = s.split("-").map(Number);
const dt = new Date(y, m - 1, d);
return dt.getFullYear() === y && dt.getMonth() === m - 1 && dt.getDate() === d;
}
/** Age in whole years, given an ISO date of birth. */
export function ageOn(dobStr, on = new Date()) {
const dob = new Date(dobStr);
let age = on.getFullYear() - dob.getFullYear();
const m = on.getMonth() - dob.getMonth();
if (m < 0 || (m === 0 && on.getDate() < dob.getDate())) age--;
return age;
}
In JavaScript, 11_validation_wire.js:
/* Experiment 11 — the DOM half. Wires 11_validation.js to the form in
* 11_validation.html (styled by 07_styled_form.css). Kept separate so the rules
* stay testable. [Corrected: this named the form as in 07_styled_form.html.] */
import { RULES, validate } from "./11_validation.js";
const form = document.getElementById("reg");
// Step 1: Show or clear a field's error
function setFieldError(input, message) {
if (!input) return;
const box = document.getElementById(input.id + "-error");
input.classList.toggle("is-invalid", Boolean(message));
input.setAttribute("aria-invalid", message ? "true" : "false");
if (box) box.textContent = message || ""; // textContent, never innerHTML
}
// Step 2: Read the form's values
function readValues() {
const values = Object.fromEntries(new FormData(form));
// FormData OMITS unchecked checkboxes entirely — the key is absent, not
// false — so the checkbox has to be read from .checked separately.
values.terms = form.elements.terms.checked;
return values;
}
// Step 3: Check everything on submit
form.addEventListener("submit", e => {
const errors = validate(readValues());
for (const id of [...Object.keys(RULES), "confirm", "terms"])
setFieldError(form.elements[id], errors[id] || "");
if (Object.keys(errors).length) {
e.preventDefault(); // THIS is what cancels submit
form.querySelector(".is-invalid")?.focus(); // move focus to the first problem
document.getElementById("status").textContent = "";
} else {
e.preventDefault(); // demo only: nothing to POST to
document.getElementById("status").textContent = "All fields valid.";
}
});
// Step 4: Check a field again once it has been left
// Live feedback, but only after the user has left the field once. Validating
// from the first character shows "invalid email" while they are still typing
// the first letter, which is hostile.
for (const [field, rule] of Object.entries(RULES)) {
const input = form.elements[field];
if (!input) continue;
let touched = false;
const run = () => setFieldError(input, rule.test(input.value) ? "" : rule.msg);
input.addEventListener("blur", () => { touched = true; run(); });
input.addEventListener("input", () => { if (touched) run(); });
}
OUTPUT
opened 11_validation.html in Chromium, 900 px wide: "Experiment 11 — JavaScript form validation"
submitted empty:
name Name must be 3 to 50 characters
email Enter a valid email address
mobile Enter a 10-digit Indian mobile number
password 8+ characters with upper, lower, digit and symbol
terms You must accept the terms
[screenshot 1: submitted empty]
submitted with a bad email, mobile and password, the passwords not matching, and the terms not accepted:
email Enter a valid email address
mobile Enter a 10-digit Indian mobile number
password 8+ characters with upper, lower, digit and symbol
confirm Passwords do not match
terms You must accept the terms
[screenshot 2: submitted with mistakes]
submitted correctly: errors {}, status 'All fields valid.'
[screenshot 3: submitted correctly]



The runner asserts that asha@ is rejected and asha@nri.ac.in accepted;
that 9876543210 passes and 1234567890 fails (an Indian mobile starts 6–9);
that Passw0rd! passes and password fails; and that mismatched passwords
produce exactly the confirm error. The driver tries the same values in the page.
RESULT
Each wrong field gets its message and a red border; with every field right, the page reports "All fields valid."
Show a greeting that changes with the time of day.
Greet by the hour in JavaScript, in a way that can be tested at every hour.
In HTML, 12_greeting.html:
In JavaScript, 12_greeting.js:
THE WHOLE TRICK
Taking hour as a parameter with a default is the whole trick. A function
that reads the clock internally can only be tested by waiting until 9 am; this
one is asserted at every boundary — 0, 11, 12, 16, 17, 20, 21, 23 — in a
fraction of a second, and still behaves identically when called with no
arguments in the page.
That is a general lesson, not a JavaScript one: push the unpredictable input to the edge of the function, and the logic inside becomes testable. The same idea makes Python for Data Analysis and Visualization's data pipelines testable.
In HTML, 12_greeting.html:
<!DOCTYPE html>
<html lang="en">
<!-- Step 1: Set up the head: charset, viewport, title and styles -->
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Experiment 12 — Time-based greeting</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; line-height: 1.6; margin: 0;
min-height: 100vh; display: grid; place-items: center;
transition: background .6s ease, color .6s ease; }
.card { text-align: center; padding: 2rem 3rem; border-radius: 12px;
background: rgba(255,255,255,.85); color: #1f2733;
box-shadow: 0 6px 24px rgba(0,0,0,.15); }
#greet { font-size: 2rem; margin: 0 0 .3rem; }
#clock { font-family: ui-monospace, "Courier New", monospace;
font-size: 1.4rem; color: #444; }
label { display: block; margin-top: 1.2rem; font-weight: 600; }
input, select { font: inherit; padding: 8px 10px; margin-top: .3rem;
border: 1px solid #ccd3dd; border-radius: 6px; }
body.morning { background: linear-gradient(#ffe9b0, #ffd27f); }
body.afternoon { background: linear-gradient(#cfe8ff, #9ecbff); }
body.evening { background: linear-gradient(#ffc8a2, #f0876a); }
body.night { background: linear-gradient(#28324a, #131a2b); color: #eef; }
</style>
</head>
<body>
<!-- Step 2: A card for the greeting and the clock -->
<div class="card">
<p id="greet">…</p>
<p id="clock">--:--:--</p>
<label for="name">Your name</label>
<input type="text" id="name" placeholder="Asha">
<label for="hour">Pretend the hour is</label>
<select id="hour">
<option value="">use the real clock</option>
</select>
</div>
<script type="module">
import { personalGreeting, periodClass, formatTime } from "./12_greeting.js";
const greetEl = document.getElementById("greet");
const clockEl = document.getElementById("clock");
const nameEl = document.getElementById("name");
const hourEl = document.getElementById("hour");
for (let h = 0; h < 24; h++) {
const o = document.createElement("option");
o.value = String(h);
o.textContent = String(h).padStart(2, "0") + ":00";
hourEl.append(o);
}
// Step 3: Greet by the hour, with the name
function render() {
const override = hourEl.value === "" ? undefined : Number(hourEl.value);
const hour = override ?? new Date().getHours();
greetEl.textContent = personalGreeting(nameEl.value, hour);
document.body.className = periodClass(hour);
}
nameEl.addEventListener("input", render);
hourEl.addEventListener("change", render);
// Step 4: Tick the clock every second
// setInterval for a clock is correct — it is polling, not animation.
// requestAnimationFrame would run 60 times a second to update once.
setInterval(() => {
clockEl.textContent = formatTime();
if (hourEl.value === "") render();
}, 1000);
render();
clockEl.textContent = formatTime();
</script>
</body>
</html>
In JavaScript, 12_greeting.js:
/* Experiment 12 — A greeting that changes with the time of day.
*
* The whole trick is the default parameter. A function that reads the clock
* internally can only be tested by waiting until 9 a.m.; this one is asserted
* at every boundary in a fraction of a second, and still behaves identically
* when called with no arguments in the page.
*
* The general lesson: push the unpredictable input to the EDGE of the
* function, and the logic inside becomes testable.
*/
// Step 1: Choose the greeting from the hour
export function greeting(hour = new Date().getHours()) {
if (!Number.isInteger(hour) || hour < 0 || hour > 23)
throw new RangeError("hour must be an integer from 0 to 23");
if (hour < 12) return "Good morning";
if (hour < 17) return "Good afternoon";
if (hour < 21) return "Good evening";
return "Good night";
}
// Step 2: Give a class for each period of the day
/** A CSS class per period, so the page can restyle itself too. */
export function periodClass(hour = new Date().getHours()) {
return { "Good morning": "morning", "Good afternoon": "afternoon",
"Good evening": "evening", "Good night": "night" }[greeting(hour)];
}
// Step 3: Add the name
/** "Good morning, Asha!" — with a sensible fallback for an empty name. */
export function personalGreeting(name, hour = new Date().getHours()) {
const who = (name ?? "").trim();
return who === "" ? `${greeting(hour)}!` : `${greeting(hour)}, ${who}!`;
}
// Step 4: Format the time
/** 24-hour clock formatted as "09:05:03". padStart is why this never shows
* "9:5:3". */
export function formatTime(d = new Date()) {
const p = n => String(n).padStart(2, "0");
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}
OUTPUT
opened 12_greeting.html in Chromium, 900 px wide: "Experiment 12 — Time-based greeting"
at the real (fixed) clock: 'Good morning!', the clock shows 09:00:00
hour 9:00 -> 'Good morning, Asha!' (body class 'morning')
[screenshot 1: pretending the hour is 9:00]
hour 14:00 -> 'Good afternoon, Asha!' (body class 'afternoon')
[screenshot 2: pretending the hour is 14:00]
hour 19:00 -> 'Good evening, Asha!' (body class 'evening')
[screenshot 3: pretending the hour is 19:00]
hour 22:00 -> 'Good night, Asha!' (body class 'night')
[screenshot 4: pretending the hour is 22:00]




The page's own "Pretend the hour is" list does in the browser what the parameter does in the runner.
RESULT
At 09:00 the page says "Good morning!"; with a name and the hour set to 14, 19 and 22, it says good afternoon, evening and night, and restyles itself for each.
Manipulate an array of objects in JavaScript: add, delete, sort and search.
Keep a list of students in an array, and change it only through pure functions.
In HTML, 13_arrays.html:
In JavaScript, 13_arrays.js:
PURE FUNCTIONS, AND THE COMPARATOR
Every function is pure: it takes the list and returns a new one rather
than mutating in place. That is why addStudent spreads instead of pushing,
and why sortBy copies with [...list] before sorting — sort mutates, and
a sort that silently reorders the caller's array is a bug that surfaces three
functions away.
The comparator branches on type because sort() with no comparator compares
as strings, so [10, 9, 100] sorts to [10, 100, 9]. Numbers need x - y;
strings need localeCompare, which also handles accents correctly.
In HTML, 13_arrays.html:
<!DOCTYPE html>
<html lang="en">
<!-- Step 1: Set up the head, and link the stylesheet -->
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Experiment 13 — Arrays and objects</title>
<link rel="stylesheet" href="07_styled_form.css">
<style>
table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
th, td { border: 1px solid #d6dbe4; padding: 8px 10px; text-align: left; }
thead th { background: #eef2f7; cursor: pointer; user-select: none; }
thead th:hover { background: #dde5ef; }
tbody tr:nth-child(even) { background: #fafbfd; }
.del { background: #c92a2a; color: #fff; border: 0; border-radius: 4px;
padding: 3px 9px; cursor: pointer; font: inherit; font-size: .85rem; }
#summary { background: #eef2f7; border-radius: 6px; padding: .8rem 1rem;
margin-top: 1rem; }
.row { display: flex; gap: .5rem; flex-wrap: wrap; align-items: end; }
.row > div { flex: 1 1 120px; }
</style>
</head>
<body>
<h1>Arrays and objects</h1>
<!-- Step 2: A form to add a student, a search box and a table -->
<form id="app" onsubmit="return false">
<fieldset>
<legend>Add a student</legend>
<div class="row">
<div><label for="roll">Roll</label>
<input type="number" id="roll" name="roll" min="1"></div>
<div><label for="name">Name</label>
<input type="text" id="name" name="name"></div>
<div><label for="marks">Marks</label>
<input type="number" id="marks" name="marks" min="0" max="100"></div>
<div><label for="dept">Dept</label>
<select id="dept" name="dept"><option>DS</option><option>Stats</option></select></div>
<div><button type="button" id="add">Add</button></div>
</div>
<p id="err" class="error" role="alert"></p>
</fieldset>
<fieldset>
<legend>Search</legend>
<label for="q">Filter by name, roll or department</label>
<input type="search" id="q" name="q" placeholder="Type to filter">
</fieldset>
<table id="table">
<thead>
<tr>
<th data-key="roll" scope="col">Roll</th>
<th data-key="name" scope="col">Name</th>
<th data-key="marks" scope="col">Marks</th>
<th data-key="dept" scope="col">Dept</th>
<th scope="col">Action</th>
</tr>
</thead>
<tbody id="body"></tbody>
</table>
<div id="summary"></div>
</form>
<p><small>Click a column heading to sort. The delete buttons use
<strong>event delegation</strong> — one listener on the table body, so rows
added later work with no extra wiring.</small></p>
<script type="module">
import { STUDENTS, addStudent, removeStudent, sortBy, search, summary }
from "./13_arrays.js";
let list = [...STUDENTS];
let sortKey = "roll", sortDesc = false;
const body = document.getElementById("body");
const err = document.getElementById("err");
// Step 3: Draw the table and the summary
function render() {
const rows = sortBy(search(list, document.getElementById("q").value),
sortKey, sortDesc);
body.textContent = "";
const frag = document.createDocumentFragment();
for (const s of rows) {
const tr = document.createElement("tr");
for (const v of [s.roll, s.name, s.marks, s.dept]) {
const td = document.createElement("td");
td.textContent = String(v); // textContent, never innerHTML
tr.append(td);
}
const td = document.createElement("td");
const btn = document.createElement("button");
btn.type = "button";
btn.className = "del";
btn.dataset.roll = String(s.roll);
btn.textContent = "Delete";
td.append(btn);
tr.append(td);
frag.append(tr);
}
body.append(frag);
const s = summary(rows);
document.getElementById("summary").textContent = s.count === 0
? "No students match."
: `${s.count} students · average ${s.average} · top ${s.top} · ${s.pass} passed`;
}
// Step 4: Add a student
document.getElementById("add").addEventListener("click", () => {
err.textContent = "";
try {
list = addStudent(list, {
roll: Number(document.getElementById("roll").value),
name: document.getElementById("name").value.trim() || "Unnamed",
marks: Number(document.getElementById("marks").value),
dept: document.getElementById("dept").value
});
render();
} catch (e) {
err.textContent = e.message;
}
});
// Step 5: Delete a student
// delegation: one listener, works for rows added later
body.addEventListener("click", e => {
const btn = e.target.closest(".del");
if (!btn) return;
list = removeStudent(list, Number(btn.dataset.roll));
render();
});
// Step 6: Search, and sort by a column
document.getElementById("q").addEventListener("input", render);
document.querySelectorAll("thead th[data-key]").forEach(th => {
th.addEventListener("click", () => {
const key = th.dataset.key;
sortDesc = key === sortKey ? !sortDesc : false;
sortKey = key;
render();
});
});
render();
</script>
</body>
</html>
In JavaScript, 13_arrays.js:
/* Experiment 13 — Manipulate arrays and objects: add, delete, sort, search.
*
* Every function here is PURE: it takes the list and returns a new one rather
* than mutating in place. That is why addStudent spreads instead of pushing,
* and why sortBy copies with [...list] before sorting — Array.prototype.sort
* mutates, and a sort that silently reorders the caller's array is a bug that
* surfaces three functions away from its cause.
*/
// Step 1: Start with five students
export const STUDENTS = [
{ roll: 21, name: "Asha", marks: 72, dept: "DS" },
{ roll: 22, name: "Ravi", marks: 45, dept: "DS" },
{ roll: 23, name: "Meena", marks: 91, dept: "Stats" },
{ roll: 24, name: "Kiran", marks: 66, dept: "DS" },
{ roll: 25, name: "Bhanu", marks: 38, dept: "Stats" }
];
// Step 2: Add, remove and update, each returning a new array
export function addStudent(list, student) {
if (list.some(s => s.roll === student.roll))
throw new Error(`Roll ${student.roll} already exists`);
return [...list, student];
}
export const removeStudent = (list, roll) => list.filter(s => s.roll !== roll);
export function updateStudent(list, roll, changes) {
if (!list.some(s => s.roll === roll))
throw new Error(`Roll ${roll} not found`);
return list.map(s => (s.roll === roll ? { ...s, ...changes } : s));
}
// Step 3: Sort by any key
/** Sort by any key, ascending or descending.
*
* The comparator branches on type because sort() with no comparator compares
* as STRINGS: [10, 9, 100].sort() gives [10, 100, 9]. Numbers need x - y;
* strings need localeCompare, which also orders accented letters correctly.
*/
export const sortBy = (list, key, desc = false) =>
[...list].sort((a, b) => {
const [x, y] = desc ? [b[key], a[key]] : [a[key], b[key]];
return typeof x === "string" ? x.localeCompare(y) : x - y;
});
// Step 4: Search, find and group
export function search(list, term) {
const t = String(term).trim().toLowerCase();
if (t === "") return list;
return list.filter(s => s.name.toLowerCase().includes(t)
|| String(s.roll).includes(t)
|| s.dept.toLowerCase().includes(t));
}
export const findByRoll = (list, roll) => list.find(s => s.roll === roll) ?? null;
/** Group into { dept: [names] } — the JavaScript equivalent of SQL's GROUP BY
* and of Course 9's df.groupby("dept"). */
export function groupBy(list, key) {
const out = {};
for (const item of list) (out[item[key]] ||= []).push(item);
return out;
}
// Step 5: Summarise
export function summary(list) {
if (list.length === 0) return { count: 0, average: null, top: null, pass: 0 };
const total = list.reduce((sum, s) => sum + s.marks, 0);
return {
count: list.length,
average: +(total / list.length).toFixed(2),
top: sortBy(list, "marks", true)[0].name,
pass: list.filter(s => s.marks >= 50).length
};
}
OUTPUT
opened 13_arrays.html in Chromium, 900 px wide: "Experiment 13 — Arrays and objects"
as it opens:
21 | Asha | 72 | DS
22 | Ravi | 45 | DS
23 | Meena | 91 | Stats
24 | Kiran | 66 | DS
25 | Bhanu | 38 | Stats
5 students · average 62.4 · top Meena · 3 passed
after adding Divya, roll 106:
21 | Asha | 72 | DS
22 | Ravi | 45 | DS
23 | Meena | 91 | Stats
24 | Kiran | 66 | DS
25 | Bhanu | 38 | Stats
106 | Divya | 88 | Stats
6 students · average 66.67 · top Meena · 4 passed
adding roll 106 again: 'Roll 106 already exists'
sorted by marks, highest first (the heading clicked twice):
23 | Meena | 91 | Stats
106 | Divya | 88 | Stats
21 | Asha | 72 | DS
24 | Kiran | 66 | DS
22 | Ravi | 45 | DS
25 | Bhanu | 38 | Stats
6 students · average 66.67 · top Meena · 4 passed
[screenshot 1: sorted by marks]
searching for 'ra':
24 | Kiran | 66 | DS
22 | Ravi | 45 | DS
2 students · average 55.5 · top Kiran · 1 passed
after deleting roll 106:
23 | Meena | 91 | Stats
21 | Asha | 72 | DS
24 | Kiran | 66 | DS
22 | Ravi | 45 | DS
25 | Bhanu | 38 | Stats
5 students · average 62.4 · top Meena · 3 passed
[screenshot 2: after the delete]


Asserted by the runner against a five-student fixture: the average is 62.4,
the top scorer is Meena, adding a duplicate roll throws, and summary([])
returns nulls rather than NaN. The page shows the same.
RESULT
The five students average 62.4, with Meena top; a sixth is added, a duplicate roll is refused, the table sorts by marks and filters by name, and a delete restores the five.
Fetch student information held in a JSON file, and display it as a table.
Load JSON with fetch(), and draw it into a table safely and efficiently.
In HTML, 14_json_table.html:
In JavaScript, 14_json_table.js:
THREE DELIBERATE CHOICES
textContent, not innerHTML. The data came from a file or an API, so a
name of <img src=x onerror=alert(1)> must render as text. This is the XSS
rule from Unit 4, in the one place students most often break it.
A DocumentFragment. Rows are built off-document and inserted once, so
the browser reflows once rather than once per row.
Optional chaining on s.marks?.maths. One student in the fixture has no
marks object at all; without the ?. the whole render throws and the page
goes blank instead of showing a dash.
In HTML, 14_json_table.html:
<!DOCTYPE html>
<html lang="en">
<!-- Step 1: Set up the head, and link the stylesheet -->
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Experiment 14 — Display JSON data in a table</title>
<link rel="stylesheet" href="07_styled_form.css">
<style>
table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
th, td { border: 1px solid #d6dbe4; padding: 8px 10px; text-align: left; }
thead th { background: #eef2f7; cursor: pointer; user-select: none; }
tbody tr:nth-child(even) { background: #fafbfd; }
td.missing { color: #999; text-align: center; }
#summary { background: #eef2f7; border-radius: 6px; padding: .8rem 1rem;
margin-top: 1rem; }
#status { color: #c92a2a; }
</style>
</head>
<body>
<h1>Student data from JSON</h1>
<!-- Step 2: A search box, a status line and the table -->
<form onsubmit="return false">
<fieldset>
<legend>Filter</legend>
<label for="q">Search any column</label>
<input type="search" id="q" name="q" placeholder="Type to filter">
</fieldset>
</form>
<p id="status" role="status" aria-live="polite">Loading…</p>
<table>
<thead>
<tr>
<th data-key="roll" scope="col">Roll</th>
<th data-key="name" scope="col">Name</th>
<th data-key="dept" scope="col">Department</th>
<th data-key="maths" scope="col">Maths</th>
<th data-key="stats" scope="col">Statistics</th>
<th data-key="total" scope="col">Total</th>
</tr>
</thead>
<tbody id="body"></tbody>
</table>
<div id="summary"></div>
<p><small>Bhanu Prakash has no <code>marks</code> object in
<code>students.json</code>. Optional chaining
(<code>s.marks?.maths ?? null</code>) is why the page shows a dash for that
row instead of going blank with a <code>TypeError</code>.</small></p>
<script type="module">
import { loadStudents, renderTable, sortRows, filterRows, summarise }
from "./14_json_table.js";
const body = document.getElementById("body");
const status = document.getElementById("status");
let rows = [], key = "roll", desc = false;
// Step 3: Draw the rows that match, in order
function draw() {
const shown = sortRows(filterRows(rows, document.getElementById("q").value),
key, desc);
renderTable(shown, body);
const s = summarise(shown);
document.getElementById("summary").textContent = s.avgTotal === null
? `${s.count} rows, none with marks`
: `${s.count} rows · ${s.scored} with marks · average total ${s.avgTotal} · top ${s.top}`;
}
// Step 4: Filter, and sort by a column
document.getElementById("q").addEventListener("input", draw);
document.querySelectorAll("thead th[data-key]").forEach(th =>
th.addEventListener("click", () => {
desc = th.dataset.key === key ? !desc : false;
key = th.dataset.key;
draw();
}));
try {
rows = await loadStudents("students.json");
status.textContent = "";
draw();
} catch (e) {
// Opening this file with file:// blocks fetch under the CORS policy.
// Serve it: python3 -m http.server, then visit localhost:8000.
status.textContent = `Could not load data: ${e.message}. ` +
`Serve the folder over http:// rather than opening the file directly.`;
}
</script>
</body>
</html>
In JavaScript, 14_json_table.js:
/* Experiment 14 — Fetch and display student information held in JSON.
*
* Three deliberate choices in renderTable, each worth a mark in the viva:
*
* 1. textContent, NOT innerHTML. The data came from a file or an API, so a
* name of "<img src=x onerror=alert(1)>" must render as text. This is
* the XSS rule from Unit 4, in the one place students most often break it.
*
* 2. A DocumentFragment. Rows are built off-document and inserted once, so
* the browser reflows once instead of once per row.
*
* 3. Optional chaining on s.marks?.maths. One student in students.json has
* no marks object at all; without the ?. the whole render throws and the
* page goes blank instead of showing a dash.
*/
// Step 1: Turn the JSON into rows
export const COLUMNS = ["roll", "name", "dept", "maths", "stats", "total"];
export function toRows(data) {
return data.students.map(s => ({
roll: s.roll,
name: s.name,
dept: s.dept,
maths: s.marks?.maths ?? null,
stats: s.marks?.stats ?? null,
total: (s.marks?.maths ?? 0) + (s.marks?.stats ?? 0)
}));
}
// Step 2: Sort and filter the rows
export function sortRows(rows, key, desc = false) {
return [...rows].sort((a, b) => {
const [x, y] = desc ? [b[key], a[key]] : [a[key], b[key]];
if (x === null) return 1; // nulls always sink to the bottom
if (y === null) return -1;
return typeof x === "string" ? x.localeCompare(y) : x - y;
});
}
export function filterRows(rows, term) {
const t = String(term).trim().toLowerCase();
if (t === "") return rows;
return rows.filter(r =>
Object.values(r).some(v => String(v ?? "").toLowerCase().includes(t)));
}
// Step 3: Summarise them
export function summarise(rows) {
const scored = rows.filter(r => r.maths !== null && r.stats !== null);
if (scored.length === 0) return { count: rows.length, avgTotal: null, top: null };
const sum = scored.reduce((a, r) => a + r.total, 0);
return {
count: rows.length,
scored: scored.length,
avgTotal: +(sum / scored.length).toFixed(2),
top: sortRows(scored, "total", true)[0].name
};
}
// Step 4: Draw them, as text
export function renderTable(rows, tbody, doc = document) {
tbody.textContent = "";
const frag = doc.createDocumentFragment();
for (const r of rows) {
const tr = doc.createElement("tr");
for (const col of COLUMNS) {
const td = doc.createElement("td");
td.textContent = r[col] ?? "—";
if (r[col] === null) td.className = "missing";
tr.append(td);
}
frag.append(tr);
}
tbody.append(frag); // ONE insertion into the document
return tbody;
}
// Step 5: Load the file, checking the response
/** Load and parse, with the two guards that matter.
* fetchFn is injectable so the runner can test the failure path offline. */
export async function loadStudents(url = "students.json", fetchFn = fetch) {
const res = await fetchFn(url);
// fetch does NOT reject on 404 — an error status is still a successful HTTP
// transaction. Skip this check and you call .json() on an HTML error page.
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
return toRows(await res.json());
}
OUTPUT
opened 14_json_table.html in Chromium, 900 px wide: "Experiment 14 — Display JSON data in a table"
loaded from students.json: status ''
21 | Asha Kumari | Data Science | 88 | 91 | 179
22 | Ravi Teja | Data Science | 65 | 58 | 123
23 | Meena Devi | Statistics | 94 | 89 | 183
24 | Kiran Kumar | Data Science | 71 | 66 | 137
25 | Bhanu Prakash | Statistics | — | — | 0
5 rows · 4 with marks · average total 155.5 · top Meena Devi
2 cell(s) shown as a dash: a student with no marks
[screenshot 1: as loaded]
sorted by total, highest first: status ''
23 | Meena Devi | Statistics | 94 | 89 | 183
21 | Asha Kumari | Data Science | 88 | 91 | 179
24 | Kiran Kumar | Data Science | 71 | 66 | 137
22 | Ravi Teja | Data Science | 65 | 58 | 123
25 | Bhanu Prakash | Statistics | — | — | 0
5 rows · 4 with marks · average total 155.5 · top Meena Devi
filtered for 'stat': status ''
23 | Meena Devi | Statistics | 94 | 89 | 183
25 | Bhanu Prakash | Statistics | — | — | 0
2 rows · 1 with marks · average total 183 · top Meena Devi
[screenshot 2: filtered]


The doc parameter defaults to document but can be passed jsdom's document,
which is how the runner tests this without a browser. Bhanu Prakash, the student with no
marks, shows two dashes and a total of 0, and is left out of the average.
RESULT
The five students load from students.json, with an average total of 155.5 and Meena Devi top; the table sorts by total and filters to the Statistics students.
Fetch real-time weather data from an open API, and display it.
Call a web API with fetch(), check the response, and show its data.
In HTML, 15_weather.html:
In JavaScript, 15_weather.js:
THE POINT OF THE EXPERIMENT
The runner has no network access and no API key, so it tests summarise
against a saved sample response, and tests fetchWeather with a stub
fetchFn that returns { ok: false, status: 404 } — asserting that it throws
rather than returning undefined.
That split is the point of the experiment. Separating the network call from the parsing makes the parsing testable, and the parsing is where the bugs actually are.
In HTML, 15_weather.html:
<!DOCTYPE html>
<html lang="en">
<!-- Step 1: Set up the head, and link the stylesheet -->
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Experiment 15 — Weather from an open API</title>
<link rel="stylesheet" href="07_styled_form.css">
<style>
.wx { max-width: 560px; margin: 1rem auto; background: #fff; padding: 1.5rem;
border: 1px solid #ccd3dd; border-radius: 10px; }
.wx h2 { margin: 0 0 .2rem; color: #2b4c7e; }
.temp { font-size: 3rem; font-weight: 700; margin: .2rem 0; }
.cond { text-transform: capitalize; color: #5b6675; }
dl { display: grid; grid-template-columns: auto 1fr; gap: .3rem 1rem;
margin-top: 1rem; }
dt { font-weight: 600; } dd { margin: 0; }
.row { display: flex; gap: .5rem; flex-wrap: wrap; align-items: end; }
.row > div { flex: 1 1 160px; }
</style>
</head>
<body>
<h1>Weather</h1>
<!-- Step 2: A form for the city and the API key -->
<form id="wxform">
<fieldset>
<legend>Look up a city</legend>
<div class="row">
<div><label for="city">City</label>
<input type="text" id="city" name="city" value="Vijayawada" required></div>
<div><label for="key">API key</label>
<input type="text" id="key" name="key" placeholder="openweathermap.org key"></div>
<div><button type="submit">Fetch</button>
<button type="button" id="demo">Use saved sample</button></div>
</div>
<p id="status" class="error" role="status" aria-live="polite"></p>
</fieldset>
</form>
<!-- Step 3: A card for the weather -->
<div class="wx" id="out" hidden>
<h2 id="place"></h2>
<p class="temp" id="temp"></p>
<p class="cond" id="cond"></p>
<dl>
<dt>Feels like</dt><dd id="feels"></dd>
<dt>Humidity</dt><dd id="hum"></dd>
<dt>Pressure</dt><dd id="press"></dd>
<dt>Wind</dt><dd id="wind"></dd>
<dt>Cloud cover</dt><dd id="cloud"></dd>
</dl>
</div>
<p><small>No key? Press <strong>Use saved sample</strong>. It reads
<code>weather-sample.json</code> — a real OpenWeatherMap response saved to
disk — through exactly the same <code>summarise()</code> function, which is
also how the automated test runs offline.</small></p>
<script type="module">
import { fetchWeather, summarise, toF } from "./15_weather.js";
const status = document.getElementById("status");
const out = document.getElementById("out");
// Step 4: Show a summary on the card
function show(w) {
document.getElementById("place").textContent =
w.country ? `${w.place}, ${w.country}` : w.place;
document.getElementById("temp").textContent = `${w.tempC} °C`;
document.getElementById("cond").textContent = w.condition;
document.getElementById("feels").textContent = `${w.feelsC} °C (${toF(w.feelsC)} °F)`;
document.getElementById("hum").textContent = `${w.humidity} %`;
document.getElementById("press").textContent = `${w.pressure} hPa`;
document.getElementById("wind").textContent =
w.windMs === null ? "—" : `${w.windMs} m/s`;
document.getElementById("cloud").textContent =
w.cloudPct === null ? "—" : `${w.cloudPct} %`;
out.hidden = false;
}
// Step 5: Fetch the weather on submit
document.getElementById("wxform").addEventListener("submit", async e => {
e.preventDefault();
status.textContent = "Fetching…";
out.hidden = true;
try {
show(await fetchWeather(document.getElementById("city").value.trim(),
document.getElementById("key").value.trim()));
status.textContent = "";
} catch (err) {
status.textContent = err.message;
}
});
// Step 6: Or show the saved sample
document.getElementById("demo").addEventListener("click", async () => {
status.textContent = "";
try {
const res = await fetch("weather-sample.json");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
show(summarise(await res.json()));
} catch (err) {
status.textContent = `${err.message} — serve this folder over http:// ` +
`rather than opening the file directly.`;
}
});
</script>
</body>
</html>
In JavaScript, 15_weather.js:
/* Experiment 15 — Fetch real-time weather from an open API and display it.
*
* The module is split in two on purpose:
*
* summarise() — pure. Takes a parsed response, returns display values.
* Testable offline against weather-sample.json, which is a
* real OpenWeatherMap response saved to disk.
* fetchWeather() — does the network call, then delegates to summarise().
* fetchFn is injectable so the failure path can be tested
* without a network or an API key.
*
* Separating the network call from the parsing is what makes the parsing
* testable, and the parsing is where the bugs actually are.
*
* THREE THINGS TO SAY IN THE VIVA
*
* 1. fetch does NOT reject on a 404. An error status is still a successful
* HTTP transaction, so res.ok must be checked explicitly. Skip it and you
* call .json() on an HTML error page and get a confusing SyntaxError.
*
* 2. An API key in front-end JavaScript is visible to anyone who opens dev
* tools. Fine for a lab with a free key; in production the request goes
* through a server that holds the key.
*
* 3. CORS may block the request entirely. It is a BROWSER restriction — the
* same request from curl succeeds — and it cannot be worked around from
* the client. An API that forbids browser access needs a server proxy.
*/
const ENDPOINT = "https://api.openweathermap.org/data/2.5/weather";
// Step 1: Pick the fields to show from the response
export function summarise(json) {
return {
place: json.name,
country: json.sys?.country ?? "",
tempC: Math.round(json.main.temp),
feelsC: Math.round(json.main.feels_like),
humidity: json.main.humidity,
pressure: json.main.pressure,
condition: json.weather?.[0]?.description ?? "unknown",
icon: json.weather?.[0]?.icon ?? null,
windMs: json.wind?.speed ?? null,
cloudPct: json.clouds?.all ?? null
};
}
// Step 2: Convert to Fahrenheit
/** Celsius to Fahrenheit, because the viva always asks. */
export const toF = c => +(c * 9 / 5 + 32).toFixed(1);
// Step 3: Build the request, and fetch it
export function buildUrl(city, key, units = "metric") {
const url = new URL(ENDPOINT);
// URLSearchParams escapes the city name correctly — "New Delhi" becomes
// "New+Delhi" without any manual encodeURIComponent.
url.search = new URLSearchParams({ q: city, appid: key, units });
return url;
}
export async function fetchWeather(city, key, fetchFn = fetch) {
if (!key) throw new Error("An API key is required. Get a free one at openweathermap.org.");
const res = await fetchFn(buildUrl(city, key));
if (!res.ok) {
if (res.status === 401) throw new Error("Invalid API key");
if (res.status === 404) throw new Error(`City "${city}" not found`);
throw new Error(`HTTP ${res.status} ${res.statusText}`);
}
return summarise(await res.json());
}
OUTPUT
opened 15_weather.html in Chromium, 900 px wide: "Experiment 15 — Weather from an open API"
the sample response, through summarise():
place Vijayawada, IN
temp 30 °C
cond Moderate Rain
feels 34 °C (93.2 °F)
hum 78 %
press 1004 hPa
wind 4.63 m/s
cloud 90 %
[screenshot 1: the saved sample]
console.error: Failed to load resource: net::ERR_INTERNET_DISCONNECTED
a real fetch, which cannot reach the API from here: status 'Failed to fetch'
[screenshot 2: a fetch that failed]


The page has the same split: Use saved sample puts the saved response through the same
summarise(). The driver presses it, then tries a real fetch, which it stops, as the API cannot
be reached from here; the page says so in its status line rather than going blank.
Three things to say in the viva.
fetch does not reject on a 404 — an error status is still a successful
HTTP transaction, so res.ok must be checked explicitly. Skip it and you call
.json() on an HTML error page and get a confusing SyntaxError.
An API key in front-end JavaScript is visible to anyone who opens dev tools. Acceptable for a lab with a free key; in production the request goes through a server that holds the key.
CORS may block the request entirely. It is a browser restriction — the
same request from curl succeeds — and it cannot be worked around from the
client. If an API does not permit browser access, it needs a server-side proxy.
RESULT
The sample shows Vijayawada at 30 °C, feeling like 34 °C (93.2 °F), in moderate rain; a fetch that cannot reach the API is reported as "Failed to fetch".
Use jQuery to hide, show, fade, slide and toggle elements.
Change a page with jQuery's effects, chaining and event delegation.
In HTML, 16_jquery.html:
In CSS, 16_jquery.css:
In JavaScript, 16_jquery_native.js:
FOUR EXAM POINTS
Delegation. $("#table").on("click", ".delete-btn", …) binds one listener
to the table, so rows added by AJAX afterwards work. $(".delete-btn").on(…)
binds only to rows that exist at that instant.
.closest("tr"), not .parent().parent(). Wrap the button in a <span>
for styling and the parent chain now removes the wrong element.
this inside a jQuery handler is a raw DOM element — wrap it as $(this)
to use jQuery methods on it. And an arrow function handler gets the
enclosing scope's this, so $(this) will not be the element; use function
handlers with jQuery.
.stop(true) before an animation clears the queue. Without it, five
rapid clicks queue five animations and the box keeps moving long after you
stopped clicking.
In HTML, 16_jquery.html:
<!DOCTYPE html>
<html lang="en">
<!-- Step 1: Set up the head, and link the stylesheet -->
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Experiment 16 — jQuery DOM manipulation</title>
<link rel="stylesheet" href="16_jquery.css">
</head>
<body>
<h1>jQuery: hide, show, fade, slide, toggle</h1>
<p><small>This page loads jQuery from a CDN. With no network it falls back to
<code>16_jquery_native.js</code>, which does exactly the same things with no
library — compare the two.</small></p>
<!-- Step 2: A panel to hide, show, fade, slide and toggle -->
<h2>Effects</h2>
<p>
<button type="button" id="hide">hide()</button>
<button type="button" id="show">show()</button>
<button type="button" id="fade">fadeToggle()</button>
<button type="button" id="slide">slideToggle()</button>
<button type="button" id="toggle">toggle()</button>
</p>
<div id="panel">
<p>Effects queue <strong>per element</strong>: each waits for the previous to
finish. That is why they chain so readably — and why a hover effect
without <code>.stop()</code> keeps animating long after the pointer has
left, because every hover queued another animation.</p>
</div>
<!-- Step 3: A box to animate -->
<h2>animate()</h2>
<p><button type="button" id="animate">animate()</button>
<button type="button" id="reset">reset</button></p>
<div id="box"></div>
<!-- Step 4: A message to change by chaining -->
<h2>Chaining</h2>
<p><button type="button" id="ok">Success</button>
<button type="button" id="bad">Error</button></p>
<p id="msg"></p>
<pre><code>$("#msg").removeClass("error").addClass("success")
.text("Saved successfully").fadeIn(300);</code></pre>
<p><small><strong>Getters break the chain.</strong> <code>.text()</code> with
no argument returns a string, and a string has no <code>.css()</code>. Only
setters return the jQuery object.</small></p>
<!-- Step 5: A table whose rows can be added and deleted -->
<h2>Event delegation</h2>
<p><button type="button" id="addrow">Add a row</button></p>
<table id="table">
<thead><tr><th scope="col">Roll</th><th scope="col">Name</th><th scope="col">Action</th></tr></thead>
<tbody id="tbody">
<tr><td>21</td><td>Asha</td><td><button type="button" class="delete-btn">Delete</button></td></tr>
<tr><td>22</td><td>Ravi</td><td><button type="button" class="delete-btn">Delete</button></td></tr>
<tr><td>23</td><td>Meena</td><td><button type="button" class="delete-btn">Delete</button></td></tr>
</tbody>
</table>
<p><small>Add a row, then delete it. It works because one listener sits on the
<em>table</em>, not on each button — <code>$("#table").on("click",
".delete-btn", …)</code>. Binding to <code>$(".delete-btn")</code> directly
would leave every new row dead.</small></p>
<!-- Step 6: Load jQuery -->
<script src="https://code.jquery.com/jquery-3.7.1.min.js"
integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo="
crossorigin="anonymous"></script>
<script>
// Step 7: Wire every button with jQuery
if (window.jQuery) {
$(function () {
$("#hide").on("click", function () { $("#panel").hide(400); });
$("#show").on("click", function () { $("#panel").show(400); });
$("#fade").on("click", function () { $("#panel").fadeToggle(300); });
$("#slide").on("click", function () { $("#panel").slideToggle(300); });
$("#toggle").on("click", function () { $("#panel").toggle(300); });
$("#animate").on("click", function () {
// .stop(true) clears the queue: without it five rapid clicks queue five
// animations and the box keeps moving long after you stopped clicking.
$("#box").stop(true).animate({ left: "250px", opacity: 0.5 }, 500,
function () { $(this).addClass("done"); });
});
$("#reset").on("click", function () {
$("#box").stop(true).css({ left: 0, opacity: 1 }).removeClass("done");
});
// Chaining. Note `this` inside a jQuery handler is a RAW DOM ELEMENT —
// $(this) is what wraps it back up. An arrow function here would take
// `this` from the enclosing scope and break.
$("#ok").on("click", function () {
$("#msg").removeClass("error").addClass("success")
.text("Saved successfully").fadeIn(300);
});
$("#bad").on("click", function () {
$("#msg").removeClass("success").addClass("error")
.text("Could not save").fadeIn(300);
});
// Delegation: the selector is the SECOND argument.
$("#table").on("click", ".delete-btn", function () {
$(this).closest("tr").fadeOut(300, function () { $(this).remove(); });
});
let next = 24;
$("#addrow").on("click", function () {
$("#tbody").append(
'<tr><td>' + (next++) + '</td><td>New student</td>' +
'<td><button type="button" class="delete-btn">Delete</button></td></tr>');
});
});
}
</script>
<script type="module">
// Step 8: Or, where jQuery did not load, with plain JavaScript
if (!window.jQuery) {
const { hide, show, toggle, fadeToggle, slideToggle, setMessage,
wireTable, slideTo } = await import("./16_jquery_native.js");
const panel = document.getElementById("panel");
const box = document.getElementById("box");
const msg = document.getElementById("msg");
document.getElementById("hide").addEventListener("click", () => hide(panel));
document.getElementById("show").addEventListener("click", () => show(panel));
document.getElementById("toggle").addEventListener("click", () => toggle(panel));
document.getElementById("fade").addEventListener("click", () => fadeToggle(panel));
document.getElementById("slide").addEventListener("click", () => slideToggle(panel));
document.getElementById("animate").addEventListener("click", () => slideTo(box, 250));
document.getElementById("reset").addEventListener("click", () => slideTo(box, 0));
document.getElementById("ok").addEventListener("click",
() => setMessage(msg, "Saved successfully", "success"));
document.getElementById("bad").addEventListener("click",
() => setMessage(msg, "Could not save", "error"));
wireTable(document.getElementById("table"));
let next = 24;
document.getElementById("addrow").addEventListener("click", () => {
const tr = document.createElement("tr");
for (const v of [next++, "New student"]) {
const td = document.createElement("td");
td.textContent = String(v);
tr.append(td);
}
const td = document.createElement("td");
const btn = document.createElement("button");
btn.type = "button";
btn.className = "delete-btn";
btn.textContent = "Delete";
td.append(btn);
tr.append(td);
document.getElementById("tbody").append(tr);
});
}
</script>
</body>
</html>
In CSS, 16_jquery.css:
/* The visual half of experiment 16. Every effect the jQuery version animates
in JavaScript is expressed here as a CSS transition, which is what the
native version toggles. */
/* Step 1: Style the page and the buttons */
*, *::before, *::after { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; line-height: 1.6;
max-width: 780px; margin: 2rem auto; padding: 0 1rem; color: #1f2733; }
h2 { color: #2b4c7e; margin-top: 2.2rem; }
button { font: inherit; padding: 8px 14px; margin: 0 .3rem .5rem 0;
border: 0; border-radius: 6px; background: #2b4c7e; color: #fff;
cursor: pointer; }
button:hover { background: #1f3a63; }
/* Step 2: The panel's states, for the library-free version */
#panel { background: #eef2f7; border: 1px solid #ccd3dd; border-radius: 8px;
padding: 1rem; overflow: hidden;
transition: opacity .3s ease, max-height .3s ease, padding .3s ease; }
#panel.is-hidden { display: none; }
#panel.is-faded { opacity: 0; }
#panel.is-collapsed { max-height: 0; padding-top: 0; padding-bottom: 0; opacity: 0; }
/* Step 3: The box, the table, and the message */
#box { width: 60px; height: 60px; background: #2b8a3e; border-radius: 8px;
transition: transform .5s ease, opacity .5s ease; }
table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
th, td { border: 1px solid #d6dbe4; padding: 8px 10px; text-align: left; }
th { background: #eef2f7; }
tr { transition: opacity .3s ease; }
tr.fading { opacity: 0; }
.delete-btn { background: #c92a2a; padding: 3px 9px; font-size: .85rem; }
.delete-btn:hover { background: #a51f1f; }
#msg { padding: .7rem 1rem; border-radius: 6px; min-height: 1.5rem; }
#msg.success { background: #e7f5eb; color: #1c6b2c; }
#msg.error { background: #fdecec; color: #8c1f1f; }
In JavaScript, 16_jquery_native.js:
/* Experiment 16 — the same behaviour as 16_jquery.html, with no library.
*
* jQuery's animation queue depends on timing that jsdom does not reproduce
* faithfully, so this native version is what tools/run_web_labs.js executes:
* class toggles can be asserted directly and deterministically.
*
* Read it beside the jQuery in 16_jquery.html. Every line has a one-to-one
* counterpart, which is the real lesson of this experiment — jQuery's ideas
* succeeded so completely that the library became unnecessary.
*/
// Step 1: Hide, show and toggle
/* jQuery: $(el).hide() / $(el).show() */
export const hide = el => { el.classList.add("is-hidden"); return el; };
export const show = el => { el.classList.remove("is-hidden"); return el; };
/* jQuery: $(el).toggle() */
export const toggle = el => { el.classList.toggle("is-hidden"); return el; };
// Step 2: Fade and slide
/* jQuery: $(el).fadeToggle() — the fade lives in the stylesheet, where the
browser can run it on the compositor instead of a JavaScript timer. */
export const fadeToggle = el => { el.classList.toggle("is-faded"); return el; };
/* jQuery: $(el).slideToggle() */
export const slideToggle = el => { el.classList.toggle("is-collapsed"); return el; };
// Step 3: Set a message
/* jQuery: $(el).addClass(a).removeClass(b).text(t) — chaining works here too,
because each function returns the element. */
export function setMessage(el, text, kind = "success") {
el.classList.remove("error", "success");
el.classList.add(kind);
el.textContent = text; // textContent, never innerHTML
return el;
}
// Step 4: Delete rows by delegation
/* jQuery: $("#table").on("click", ".delete-btn", fn)
*
* Event DELEGATION. One listener on the container, so rows added later — by
* AJAX, or by the user — work with no extra wiring. Binding directly to
* ".delete-btn" would only catch the rows that existed at that instant.
*
* closest("tr") rather than parentNode.parentNode: wrap the button in a
* <span> for styling and a parent chain removes the wrong element.
*/
export function wireTable(root) {
root.addEventListener("click", e => {
const btn = e.target.closest(".delete-btn");
if (!btn) return;
const tr = btn.closest("tr");
if (!tr) return;
// Remove after the CSS fade finishes — the native equivalent of jQuery's
// fadeOut(300, function () { $(this).remove(); }) completion callback.
tr.addEventListener("transitionend", () => tr.remove(), { once: true });
tr.classList.add("fading");
});
return root;
}
// Step 5: Move, append and empty
/* jQuery: $(el).animate({ left: "250px" }, 500) */
export function slideTo(el, x) {
el.style.transform = `translateX(${x}px)`;
return el;
}
/* jQuery: $("#list").append("<li>…</li>") */
export function appendItem(list, text, doc = document) {
const li = doc.createElement("li");
li.textContent = text;
list.append(li);
return li;
}
/* jQuery: $("#list").empty() */
export const empty = el => { el.textContent = ""; return el; };
OUTPUT
opened 16_jquery.html in Chromium, 900 px wide: "Experiment 16 — jQuery DOM manipulation"
jQuery loaded: 3.7.1 (so the jQuery version of the page is the one running)
hide -> the panel is hidden
show -> the panel is shown, opacity 1
fade -> the panel is hidden
fade -> the panel is shown, opacity 1
slide -> the panel is hidden
slide -> the panel is shown, opacity 1
toggle -> the panel is hidden
toggle -> the panel is shown, opacity 1
animate -> the box is at left 250px, class 'done'
[screenshot 1: after animate()]
ok -> the message reads 'Saved successfully', class 'success'
rows: 3, 4 after Add row, 3 after deleting the new one -- delegation works for a row added later
[screenshot 2: after the rows]


16_jquery_native.js is the same behaviour with no library, and it is
what the runner executes under jsdom — jQuery's animation queue depends on
timing that jsdom does not reproduce faithfully, while the native version's
class toggles can be asserted directly. Asserted: clicking a .delete-btn adds fading
to the correct <tr>, and a click elsewhere in the table does nothing.
In Chromium, jQuery loads, so the jQuery version is the one that runs, and its animations play out in real time; the clock is not fixed for this page, as jQuery times its animations by it.
RESULT
jQuery 3.7.1 loads; each effect hides or shows the panel, animate() moves the box to 250 px, the chained message reads "Saved successfully", and a row added after the page loaded can be deleted.
The lab exam gives you one experiment, a browser and a text editor, roughly an hour, and then a viva.
What actually costs marks:
name on form inputs, so nothing submits<label> whose for does not match any idname values<!DOCTYPE html> or <meta charset="UTF-8">border: 2px red with no style keyword — it renders nothinginput.value as a numberclick on the submit button instead of submit on the forme.preventDefault(), so the page reloads and your output vanishessort() on numbers with no comparatorinnerHTML with data from a file or an APIWhat earns them:
Open dev tools (F12) when something does not work. The Console shows the error, the Elements tab shows the live DOM, and Network shows what was fetched. Debugging without them is guesswork, and examiners notice.
Validate at validator.w3.org before you submit. Browsers silently forgive broken markup; validators do not.
Say the security sentence out loud in the viva: client-side validation is a convenience for honest users, and the server must validate again.
When the syllabus asks for something obsolete — <font>, a table used for
layout, window.status — do it as asked, then name the modern replacement
and why it replaced it. That is the difference between a pass and a
distinction.