Build a Custom Rally Pace-Note Digitizer
Build a browser app that creates and saves crew-specific rally pace notes.
Introduction
30 Second Summary
A rally crew can understand the same corner through symbols that make sense only to them. Those marks become difficult to review when a paper draft gets crowded.
In this project, you will build a responsive browser workspace for digitizing a crew's pace notes. You will use an editable vocabulary to compose a stage sequence that returns after a refresh.
What You'll Build
You will watch a named stage line take shape as each crew-approved term appears, then reopen the same draft after refreshing the browser.
By the end of this project, you'll have:
- A clickable note composer that turns L, Cr, and ! into a visible sequence with a live token count.
- An editable crew lexicon where a new sign, spoken term, and category become a palette button you can use immediately.
- A saved stage workspace that restores the stage name, custom vocabulary, and note sequence after a refresh. Clear revision controls let you remove mistakes or reset the prototype.
- Secret Mission: Preview a photographed paper pace-note page beside the editor for manual transcription.
Are there any prerequisites?
Basic HTML, CSS, and JavaScript syntax are enough to begin this first project in the longer app-building progression.
Use this prototype only for learning because crew confirmation and official event regulations remain the final authority.
Before We Start
First, lock in the purpose of your prototype. You are building an educational workspace for crew-configurable pace-note transcription, with every note reviewed by the driver and co-driver against the event regulations.
Set Up the Rally Notes Prototype
Browser persistence needs a stable origin. A page loaded directly from a file: URL can receive undefined storage behavior.
This setup puts the starter files in Visual Studio Code. A localhost-only HTTP server gives the page a predictable address.
In this step, get ready to:
- Create a three-file rally-notes-prototype workspace in Visual Studio Code.
- Connect a valid starter document to both supporting files.
- Serve the starter page through localhost in Microsoft Edge.
Create the project folder
The project folder gives every file one clear home on your Desktop. Its three starter files separate the page structure from the styling and browser behavior.
- Press the Windows key to open Windows search.
- Type Visual Studio Code into the search box.
- Press Enter to open Visual Studio Code.
You will see the Visual Studio Code window ready for a folder.
- Select File from the top menu.
- Select Open Folder.
- Select your Desktop in the folder picker.
- Select the New Folder control.
- Type rally-notes-prototype as the folder name.
- Press Enter to confirm the folder name.
You will see the new rally-notes-prototype folder on your Desktop.
- Select the rally-notes-prototype folder.
- Click Select Folder.
The Explorer sidebar now shows RALLY-NOTES-PROTOTYPE as the open folder.
Seeing a workspace trust prompt?
Visual Studio Code may ask whether you trust the folder because it has just been opened.
- Choose the option that trusts the folder because you created it yourself.
- Click the New File control in the Explorer sidebar.
A filename box appears under the open folder.
- Type index.html into the filename box.
- Press Enter to create the file.
You will see index.html inside the project folder.
- Click the New File control again.
A fresh filename box appears in the Explorer sidebar.
- Type styles.css into the filename box.
- Press Enter to create the file.
You will see styles.css beside index.html.
- Click the New File control once more.
The Explorer sidebar is ready for the final filename.
- Type app.js into the filename box.
- Press Enter to create the file.
That is the project shell in place: Explorer now lists index.html, styles.css, plus app.js.
Missing a starter file?
- Check that each filename appears directly under RALLY-NOTES-PROTOTYPE.
- Check each filename for an extra space or an added .txt extension.
Ask for help if the file list still looks different: Help me create the three starter files in Visual Studio Code.
Prepare the starter page
HTML gives the browser a page structure. This starter document also points the browser to the empty stylesheet and script files.
- Select index.html in the Explorer sidebar.
- Paste this starter document into index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Rally Pace-Note Digitizer</title>
<link rel="stylesheet" href="styles.css" />
<script src="app.js" defer></script>
</head>
<body>
<header class="site-header">
<h1>Rally Pace-Note Digitizer</h1>
</header>
</body>
</html>
What does this document do?
- The document metadata gives the browser a page title. It also supports layouts that adapt to the screen width.
- The link element connects styles.css. This lets CSS style the workspace.
- The script element loads app.js with defer. This lets JavaScript run after the document has been parsed.
- The h1 element provides the visible heading for your browser check.
- Press Ctrl+S to save index.html.
- Confirm the index.html tab no longer shows an unsaved-change dot.
Do the file links look different?
- Check that the stylesheet filename is exactly styles.css.
- Check that the script filename is exactly app.js.
- Confirm that defer appears inside the script element.
Ask for help comparing the connections if needed: Help me check the supporting file links in index.html.
✔️ Awesome, I've got everything!
Great. Double check that index.html is saved before moving on.
ⓧ I'd like to double check the full code
Compare your saved index.html file with this complete starter document.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Rally Pace-Note Digitizer</title>
<link rel="stylesheet" href="styles.css" />
<script src="app.js" defer></script>
</head>
<body>
<header class="site-header">
<h1>Rally Pace-Note Digitizer</h1>
</header>
</body>
</html>
At this checkpoint, styles.css plus app.js are empty starter files.
The final browser check uses Microsoft Edge. First, confirm that it is available on your Windows computer.
- Press the Windows key to open Windows search.
- Type Microsoft Edge into the search box.
- Press Enter.
✔️ Microsoft Edge opens
Microsoft Edge is ready for the localhost check.
ⓧ Microsoft Edge is unavailable
Microsoft provides an official Windows download for Edge. The installer may request Windows permission during setup.
- Open the official Download Microsoft Edge page.
- Select Download Edge for Windows.
- Approve the Windows permission request if it appears.
- Follow the installation prompts.
- Press the Windows key after installation.
- Type Microsoft Edge into the search box.
- Press Enter to open Microsoft Edge.
Verify the localhost page
Python includes a small development server for local files. The server stays limited to your computer through the loopback address.
- Return to Visual Studio Code.
- Select Terminal from the top menu.
- Select New Terminal.
You will see a terminal panel at the bottom of Visual Studio Code. Its prompt should show the rally-notes-prototype folder.
The server keeps this terminal occupied while it runs. Leave the terminal open for the rest of the project.
- Start the localhost-only development server by running this command:
python -m http.server --bind 127.0.0.1
What does this command do?
- The python -m http.server portion starts Python's standard-library development server.
- The --bind 127.0.0.1 portion limits access to the local computer.
- The server uses port 8000 because no other port was provided.
This server is suitable for local development only.
You will see server startup details in the terminal. The terminal remains occupied while the server accepts browser requests.
Did the server close immediately?
- Confirm that the terminal prompt shows the rally-notes-prototype folder.
- Check whether another local program is already using port 8000.
- Run the server command above again after closing the conflicting program.
Ask for help if the server still stops: Help me troubleshoot the Python localhost server.
Before you load the page, what heading do you expect Edge to show at the localhost address?
- Switch back to Microsoft Edge.
- Select the address bar.
- Enter http://127.0.0.1:8000.
- Press Enter to load the page.
You will see the Rally Pace-Note Digitizer heading. This confirms that Edge received your saved index.html file from the localhost server.
Not seeing the heading?
- Confirm that the Edge address bar contains http://127.0.0.1:8000.
- Return to the terminal panel in Visual Studio Code.
- Confirm that the server is still running.
- Save index.html before reloading the page.
Ask for help if the heading still does not load: Help me trace why the starter page is missing.
You have your first visible win: the localhost server now delivers the Rally Pace-Note Digitizer heading. Next, you will turn this starter page into a clickable rally lexicon.
Build a Clickable Starter Lexicon
Your localhost page is running in Microsoft Edge. It now needs an interface that a co-driver can use to compose a digital note line.
A small clickable vocabulary creates an immediate result. The fixed set also reveals how quickly a crew can outgrow starter examples.
In this step, get ready to:
- Build a two-panel composer with a visible safety warning.
- Style the workspace for desktop screens and narrow screens.
- Render clickable starter terms that append tokens to the current stage line.
Build the two-panel interface
The interface uses semantic HTML to separate the stage composer from the crew lexicon. The warning keeps the learning boundary visible beside the tool.
- Select index.html in the Visual Studio Code Explorer sidebar.
- Replace the starter document with the complete interface shown in the full-file reference below:
✔️ Awesome, I've got everything!
Your index.html file contains the composer panel and crew lexicon panel.
ⓧ I'd like to double check the full code
Compare your index.html file with this complete version:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Rally Pace-Note Digitizer</title>
<link rel="stylesheet" href="styles.css" />
<script src="app.js" defer></script>
</head>
<body>
<header class="site-header">
<p class="eyebrow">Learning prototype</p>
<h1>Rally Pace-Note Digitizer</h1>
<p class="lede">Build a digital note line from a crew-specific lexicon.</p>
<p class="safety-note">
Starter terms are examples, not a universal standard. Confirm every sign
with your crew and check the event regulations. Do not rely on this
learning prototype during competition.
</p>
</header>
<main class="app-grid">
<section class="panel composer" aria-labelledby="composer-title">
<h2 id="composer-title">Stage note composer</h2>
<label class="field" for="stageName">
<span>Stage name</span>
<input
id="stageName"
name="stageName"
type="text"
maxlength="60"
placeholder="Example: Forest stage"
/>
</label>
<div class="section-heading">
<div>
<h3>Term palette</h3>
<p>Click a term to append it to the current note line.</p>
</div>
</div>
<div id="termPalette" class="term-palette"></div>
<div class="section-heading note-heading">
<div>
<h3>Current note line</h3>
<p>Click an added token to remove that token.</p>
</div>
<output id="noteCount" class="counter">0 tokens</output>
</div>
<div id="stageLine" class="stage-line" aria-live="polite"></div>
<div class="button-row">
<button id="undoButton" class="secondary-button" type="button">
Undo last
</button>
<button id="clearButton" class="secondary-button" type="button">
Clear stage
</button>
<button id="resetButton" class="danger-button" type="button">
Reset all
</button>
</div>
</section>
<aside class="panel glossary" aria-labelledby="glossary-title">
<h2 id="glossary-title">Crew lexicon</h2>
<p class="muted">
Add the exact sign and spoken call agreed by the driver and co-driver.
</p>
<form id="customTermForm" class="term-form">
<label class="field" for="customSign">
<span>Short sign</span>
<input
id="customSign"
name="customSign"
type="text"
maxlength="8"
placeholder="Example: !!"
required
/>
</label>
<label class="field" for="customLabel">
<span>Spoken term</span>
<input
id="customLabel"
name="customLabel"
type="text"
maxlength="32"
placeholder="Example: Double caution"
required
/>
</label>
<label class="field" for="customCategory">
<span>Category</span>
<select id="customCategory" name="customCategory">
<option>Direction</option>
<option>Severity</option>
<option>Feature</option>
<option>Hazard</option>
<option>Modifier</option>
<option>Crew custom</option>
</select>
</label>
<button class="primary-button" type="submit">Add crew term</button>
</form>
<p id="statusMessage" class="status" role="status">
Ready to build a crew-specific note.
</p>
<ul id="glossaryList" class="glossary-list"></ul>
</aside>
</main>
</body>
</html>
What does this structure provide?
- The site-header area keeps the prototype warning visible above the workspace.
- The composer section holds the stage name, term palette, note line, counter, and revision controls.
- The glossary panel contains the crew-term form, feedback area, and glossary list.
- Save index.html by pressing Ctrl+S.
- Switch back to the app in Microsoft Edge.
- Refresh http://127.0.0.1:8000.
You should see the safety warning above a stage composer and a crew lexicon. The form and controls are visible even though their behavior arrives in later steps.
Still seeing only the original heading?
- Confirm that index.html is saved inside the open rally-notes-prototype folder.
- Confirm that Microsoft Edge still shows http://127.0.0.1:8000 in the address bar.
Help me check why my updated interface is missing.
Style the responsive workspace
The CSS turns the document into a high-contrast workspace with large tap targets. Its responsive breakpoint moves the panels into one column when the screen becomes narrow.
- Select styles.css in the Visual Studio Code Explorer sidebar.
- Replace the starter styles with the complete responsive stylesheet shown in the full-file reference below:
✔️ Awesome, I've got everything!
Your styles.css file contains the full dark interface and responsive rules.
ⓧ I'd like to double check the full code
Compare your styles.css file with this complete version:
:root {
color-scheme: dark;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
background: #101418;
color: #f4f7f8;
--panel: #1a2128;
--panel-soft: #222c35;
--line: #394650;
--accent: #ffcc33;
--accent-dark: #2a2208;
--danger: #ff6b6b;
--muted: #b9c4cc;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at top left, #29343d 0, transparent 34rem),
#101418;
}
button,
input,
select {
font: inherit;
}
button {
cursor: pointer;
}
.site-header {
width: min(1180px, calc(100% - 2rem));
margin: 0 auto;
padding: 2.5rem 0 1.25rem;
}
.eyebrow {
margin: 0 0 0.35rem;
color: var(--accent);
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.16em;
text-transform: uppercase;
}
h1,
h2,
h3,
p {
margin-top: 0;
}
h1 {
margin-bottom: 0.4rem;
font-size: clamp(2rem, 5vw, 4rem);
line-height: 1;
}
.lede {
color: var(--muted);
font-size: 1.08rem;
}
.safety-note {
max-width: 850px;
margin-bottom: 0;
padding: 0.85rem 1rem;
border-left: 4px solid var(--accent);
background: #221f15;
color: #f7e7ad;
}
.app-grid {
display: grid;
grid-template-columns: minmax(0, 1.65fr) minmax(280px, 0.85fr);
gap: 1rem;
width: min(1180px, calc(100% - 2rem));
margin: 0 auto;
padding-bottom: 3rem;
}
.panel {
border: 1px solid var(--line);
border-radius: 18px;
background: color-mix(in srgb, var(--panel) 94%, transparent);
box-shadow: 0 18px 55px rgb(0 0 0 / 22%);
padding: clamp(1rem, 3vw, 1.5rem);
}
.field {
display: grid;
gap: 0.4rem;
margin-bottom: 1rem;
color: var(--muted);
font-weight: 700;
}
input,
select {
width: 100%;
border: 1px solid var(--line);
border-radius: 10px;
background: #0e1317;
color: #ffffff;
padding: 0.8rem 0.9rem;
}
input:focus,
select:focus,
button:focus-visible {
outline: 3px solid rgb(255 204 51 / 45%);
outline-offset: 2px;
}
.section-heading {
display: flex;
align-items: end;
justify-content: space-between;
gap: 1rem;
margin-top: 1.5rem;
}
.section-heading h3,
.section-heading p {
margin-bottom: 0.25rem;
}
.section-heading p,
.muted {
color: var(--muted);
}
.term-palette,
.stage-line {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
}
.term-palette {
margin-top: 0.8rem;
}
.term-button,
.note-token {
min-width: 88px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--panel-soft);
color: #ffffff;
padding: 0.75rem;
}
.term-button:hover,
.note-token:hover {
border-color: var(--accent);
transform: translateY(-1px);
}
.term-sign {
display: block;
color: var(--accent);
font-size: 1.35rem;
font-weight: 900;
}
.term-label {
display: block;
margin-top: 0.2rem;
font-size: 0.78rem;
}
.note-heading {
margin-top: 2rem;
}
.counter {
border-radius: 999px;
background: #0e1317;
color: var(--accent);
padding: 0.45rem 0.75rem;
white-space: nowrap;
}
.stage-line {
min-height: 118px;
margin-top: 0.8rem;
border: 1px dashed #53616c;
border-radius: 14px;
background: #0e1317;
padding: 1rem;
align-content: flex-start;
}
.note-token {
min-width: auto;
border-color: #6a5a1d;
background: var(--accent-dark);
color: #fff4c4;
font-weight: 800;
}
.empty-state {
margin: auto;
color: #82909a;
}
.button-row {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
margin-top: 1rem;
}
.primary-button,
.secondary-button,
.danger-button,
.remove-term-button {
border-radius: 10px;
padding: 0.72rem 0.9rem;
font-weight: 800;
}
.primary-button {
border: 1px solid var(--accent);
background: var(--accent);
color: #171300;
}
.secondary-button {
border: 1px solid var(--line);
background: var(--panel-soft);
color: #ffffff;
}
.danger-button,
.remove-term-button {
border: 1px solid #743b3b;
background: #2a1717;
color: #ffb0b0;
}
.term-form {
margin-top: 1.25rem;
padding-bottom: 1.25rem;
border-bottom: 1px solid var(--line);
}
.status {
min-height: 1.5rem;
margin: 1rem 0;
color: var(--accent);
}
.glossary-list {
display: grid;
gap: 0.6rem;
margin: 0;
padding: 0;
list-style: none;
}
.glossary-item {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.7rem;
border: 1px solid var(--line);
border-radius: 10px;
background: #11171c;
padding: 0.7rem;
}
.glossary-symbol {
min-width: 2.3rem;
color: var(--accent);
font-size: 1.1rem;
font-weight: 900;
text-align: center;
}
.glossary-details {
display: grid;
gap: 0.12rem;
}
.glossary-category {
color: var(--muted);
font-size: 0.75rem;
}
.custom-badge {
color: var(--accent);
}
.remove-term-button {
padding: 0.45rem 0.6rem;
font-size: 0.75rem;
}
@media (max-width: 820px) {
.app-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 520px) {
.site-header,
.app-grid {
width: min(100% - 1rem, 1180px);
}
.section-heading {
align-items: start;
flex-direction: column;
}
.term-button {
flex: 1 1 42%;
}
}
What does this stylesheet change?
- The .app-grid rule places the composer and glossary into two responsive columns.
- The term and note-token rules create large controls with high-contrast labels.
- The media queries collapse the workspace into one column and widen term buttons on narrow screens.
- Save styles.css by pressing Ctrl+S.
- Switch back to Microsoft Edge.
- Refresh the app.
You should see two dark panels with yellow accents. The term area and current note line should have clearly separated spaces.
- Narrow the Microsoft Edge window until it is less than half of your screen width.
You should see the crew lexicon move below the stage composer. The term buttons should expand into larger tap targets on the narrow layout.
Interface still looks unstyled?
- Confirm that styles.css is saved in the same rally-notes-prototype folder as index.html.
- Confirm that the stylesheet link in index.html uses styles.css.
Help me find why my rally interface has no CSS styling.
Render the starter lexicon
JavaScript represents each starter term as an object with a sign, spoken label, category, and identifier. The visible buttons are generated from that data instead of being repeated in the HTML.
Why use event delegation?
Event delegation places one click listener on the shared term palette. The listener identifies the clicked button through its data attribute.
New palette buttons can use the same listener without receiving separate handlers. This prepares the interface for crew-created terms.
- Select app.js in the Visual Studio Code Explorer sidebar.
- Replace the starter JavaScript with the complete lexicon logic shown in the full-file reference below:
✔️ Awesome, I've got everything!
Your app.js file contains the starter-term data, rendering functions, and delegated palette listener.
ⓧ I'd like to double check the full code
Compare your app.js file with this complete version:
const STARTER_TERMS = [
{ id: "left", sign: "L", label: "Left", category: "Direction", custom: false },
{ id: "right", sign: "R", label: "Right", category: "Direction", custom: false },
{ id: "tightens", sign: "T", label: "Tightens", category: "Modifier", custom: false },
{ id: "crest", sign: "Cr", label: "Crest", category: "Feature", custom: false },
{ id: "caution", sign: "!", label: "Caution", category: "Hazard", custom: false },
];
const stageNameInput = document.getElementById("stageName");
const termPalette = document.getElementById("termPalette");
const stageLine = document.getElementById("stageLine");
const noteCount = document.getElementById("noteCount");
const customTermForm = document.getElementById("customTermForm");
const customSignInput = document.getElementById("customSign");
const customLabelInput = document.getElementById("customLabel");
const customCategoryInput = document.getElementById("customCategory");
const glossaryList = document.getElementById("glossaryList");
const statusMessage = document.getElementById("statusMessage");
const undoButton = document.getElementById("undoButton");
const clearButton = document.getElementById("clearButton");
const resetButton = document.getElementById("resetButton");
let idCounter = 0;
let state = freshState();
function freshState() {
return {
stageName: "",
terms: STARTER_TERMS.map((term) => ({ ...term })),
notes: [],
};
}
function createId(prefix) {
idCounter += 1;
return `${prefix}-${Date.now()}-${idCounter}`;
}
function setStatus(message) {
statusMessage.textContent = message;
}
function renderPalette() {
termPalette.replaceChildren();
state.terms.forEach((term) => {
const button = document.createElement("button");
const sign = document.createElement("span");
const label = document.createElement("span");
button.type = "button";
button.className = "term-button";
button.dataset.termId = term.id;
button.title = `${term.category}: ${term.label}`;
sign.className = "term-sign";
sign.textContent = term.sign;
label.className = "term-label";
label.textContent = term.label;
button.append(sign, label);
termPalette.append(button);
});
}
function renderStageLine() {
stageLine.replaceChildren();
if (state.notes.length === 0) {
const emptyState = document.createElement("p");
emptyState.className = "empty-state";
emptyState.textContent = "Choose a term to start the digital note line.";
stageLine.append(emptyState);
} else {
state.notes.forEach((note) => {
const token = document.createElement("button");
token.type = "button";
token.className = "note-token";
token.dataset.noteId = note.id;
token.textContent = `${note.sign} ${note.label}`;
token.setAttribute("aria-label", `Remove ${note.label} from the note line`);
stageLine.append(token);
});
}
const suffix = state.notes.length === 1 ? "token" : "tokens";
noteCount.textContent = `${state.notes.length} ${suffix}`;
}
function renderGlossary() {
glossaryList.replaceChildren();
state.terms.forEach((term) => {
const item = document.createElement("li");
const symbol = document.createElement("span");
const details = document.createElement("span");
const label = document.createElement("strong");
const category = document.createElement("span");
item.className = "glossary-item";
symbol.className = "glossary-symbol";
details.className = "glossary-details";
category.className = "glossary-category";
symbol.textContent = term.sign;
label.textContent = term.label;
category.textContent = term.custom
? `${term.category} | Custom`
: `${term.category} | Starter example`;
if (term.custom) {
category.classList.add("custom-badge");
}
details.append(label, category);
item.append(symbol, details);
if (term.custom) {
const removeButton = document.createElement("button");
removeButton.type = "button";
removeButton.className = "remove-term-button";
removeButton.dataset.removeTermId = term.id;
removeButton.textContent = "Remove";
item.append(removeButton);
}
glossaryList.append(item);
});
}
function renderAll() {
stageNameInput.value = state.stageName;
renderPalette();
renderStageLine();
renderGlossary();
}
function addTermToNote(termId) {
const term = state.terms.find((item) => item.id === termId);
if (!term) {
return;
}
state.notes.push({
id: createId("note"),
termId: term.id,
sign: term.sign,
label: term.label,
});
renderAll();
}
termPalette.addEventListener("click", (event) => {
const button = event.target.closest("[data-term-id]");
if (button) {
addTermToNote(button.dataset.termId);
}
});
renderAll();
What does this JavaScript do?
- The STARTER_TERMS array holds five reusable term objects.
- The rendering functions rebuild the palette, note line, glossary, and counter from state.
- The addTermToNote(termId) function copies the selected term data into a new note object.
- The delegated click listener reads data-term-id from the selected palette button.
- Save app.js by pressing Ctrl+S.
- Switch back to Microsoft Edge.
- Refresh the app.
You should see five buttons for L, R, T, Cr, and !. The crew lexicon should list the same five entries as starter examples.
Starter terms missing?
- Confirm that app.js is saved inside rally-notes-prototype.
- Confirm that index.html loads app.js with defer.
- Check every identifier against the full-file reference because element IDs are case-sensitive.
Help me debug why my starter term buttons are missing.
Before you test the note line, what do you expect the counter to show after three starter-term clicks?
- Click the L term button.
- Click the Cr term button.
- Click the ! term button.
You should see three note tokens in that order. The counter should read 3 tokens.
That is the first working composition loop. Your starter vocabulary now becomes a visible digital pace-note sequence.
Before you test the crew form, do you think submitting a new term changes the palette yet?
- Enter !! in the Short sign field.
- Enter Double caution in the Spoken term field.
- Select Hazard from the Category menu.
- Click Add crew term.
The new term does not appear in the palette. This intended shortfall proves that the fixed starter data cannot capture a crew's private shorthand.
Your starter lexicon now creates a visible note line from reusable term objects. Next, you will make the crew form add private signs and spoken calls to the same palette.
Add Crew-Specific Signs and Terms
Your starter palette now turns clicks into a visible pace-note sequence. That fast interaction gives the crew a useful starting point.
Real crews develop personal symbols, abbreviations, ordering, and spoken calls. A fixed palette cannot capture that private shorthand.
In this step, get ready to:
- Test where the fixed starter palette falls short.
- Create custom crew terms from the existing form.
- Remove custom glossary terms without deleting copied note tokens.
Test the fixed palette
The current palette only knows the five objects inside STARTER_TERMS. Any crew sign outside that array has no button yet.
Before you test the palette, do you think it can produce the custom sign !! with the spoken call Double caution?
- Switch back to the app in Microsoft Edge.
- Scan the term palette for the sign !!.
You can append the starter terms. You cannot produce !! Double caution because that crew-specific object does not exist.
That shortfall is intentional. The next change turns the existing crew-lexicon form into a way to extend the palette.
Handle custom term submissions
The form already collects a short sign, a spoken term, and a category. A submit handler can trim those values before creating a new JavaScript object.
- Switch back to the app.js tab in Visual Studio Code.
- Find the delegated termPalette click handler.
- Add the form submission handler directly below it by pasting this code:
customTermForm.addEventListener("submit", (event) => {
event.preventDefault();
const sign = customSignInput.value.trim();
const label = customLabelInput.value.trim();
const category = customCategoryInput.value;
if (!sign || !label) {
setStatus("Enter both a short sign and a spoken term.");
return;
}
state.terms.push({
id: createId("term"),
sign,
label,
category,
custom: true,
});
customTermForm.reset();
renderAll();
setStatus(`Added ${label} to the crew lexicon.`);
});
What does this handler do?
- The handler prevents the form from reloading the page before the new term reaches state.terms.
- The two trim() calls remove surrounding spaces from the sign and spoken term.
- The validation stops empty values from creating unusable palette buttons.
- The new object receives a unique ID plus custom: true so the interface can distinguish it from a starter example.
- The final calls clear the form, redraw the interface, and report the successful addition.
- Save app.js by pressing Ctrl+S.
- Return to Microsoft Edge.
- Refresh http://127.0.0.1:8000.
- Enter !! in the Short sign field.
- Enter Double caution in the Spoken term field.
- Select Hazard from the Category field.
- Click Add crew term.
Good progress. The form clears while a new !! Double caution button joins the term palette.
Custom button missing?
- Confirm that both text fields contain visible characters.
- Check that the submission handler sits outside the closing brace of the palette click handler.
- Look for mismatched braces near state.terms.push().
Ask for help with the event handler if the form still does not create a button: Why does my customTermForm submit handler fail to add a term to state.terms?
Render and remove custom terms
The glossary needs to show which terms came from the crew. A custom marker makes that source visible during review.
- Find renderGlossary() in app.js.
- Locate the category assignment below category.className = "glossary-category";.
- Replace the existing category assignment with this custom-aware version:
category.textContent = term.custom
? `${term.category} | Custom`
: `${term.category} | Starter example`;
if (term.custom) {
category.classList.add("custom-badge");
}
How does the custom label work?
- The conditional expression gives crew-created terms the Custom label.
- Starter entries keep the Starter example label from the original glossary.
- The custom-badge class gives custom entries the accent styling already defined in styles.css.
- Find item.append(symbol, details); inside the same function.
- Add the removable custom-term button directly below that line by pasting this code:
if (term.custom) {
const removeButton = document.createElement("button");
removeButton.type = "button";
removeButton.className = "remove-term-button";
removeButton.dataset.removeTermId = term.id;
removeButton.textContent = "Remove";
item.append(removeButton);
}
Why only add buttons to custom terms?
The term.custom check protects the built-in examples from glossary removal. Each crew-created button stores its source term ID in data-remove-term-id.
- Save app.js.
- Return to Microsoft Edge.
- Refresh the app.
- Enter !! in the Short sign field.
- Enter Double caution in the Spoken term field.
- Select Hazard from the Category field.
- Click Add crew term.
The glossary now identifies Double caution as a custom term. You also see a Remove button beside it.
Custom term has no Remove button?
- Confirm that the custom object contains custom: true.
- Check that the button creation code remains inside the state.terms.forEach() callback.
- Confirm that item.append(removeButton) runs before glossaryList.append(item).
Ask for help if the custom entry renders without its controls: Why does my custom glossary entry appear without its Remove button?
A rendered note token contains copied sign and label values. That copy lets the note survive when its source glossary term is removed.
- Add the delegated glossary click handler below the custom form handler by pasting this code:
glossaryList.addEventListener("click", (event) => {
const button = event.target.closest("[data-remove-term-id]");
if (!button) {
return;
}
const termId = button.dataset.removeTermId;
const term = state.terms.find((item) => item.id === termId);
state.terms = state.terms.filter((item) => item.id !== termId);
renderAll();
setStatus(
term
? `Removed ${term.label} from the palette. Existing note tokens remain.`
: "Removed the custom term.",
);
});
What does the removal handler protect?
- Event delegation lets one listener handle every current or future custom-term button.
- The handler reads the term ID from the clicked button.
- The filter() call removes that object from state.terms.
- The handler leaves state.notes untouched so existing note tokens retain their copied values.
- Save app.js.
✔️ Awesome, I've got everything!
Your custom-term submission, glossary labels, and removal handler are ready to test.
ⓧ I'd like to double check the full code
Compare your cumulative app.js with this in-memory version:
const STARTER_TERMS = [
{ id: "left", sign: "L", label: "Left", category: "Direction", custom: false },
{ id: "right", sign: "R", label: "Right", category: "Direction", custom: false },
{ id: "tightens", sign: "T", label: "Tightens", category: "Modifier", custom: false },
{ id: "crest", sign: "Cr", label: "Crest", category: "Feature", custom: false },
{ id: "caution", sign: "!", label: "Caution", category: "Hazard", custom: false },
];
const stageNameInput = document.getElementById("stageName");
const termPalette = document.getElementById("termPalette");
const stageLine = document.getElementById("stageLine");
const noteCount = document.getElementById("noteCount");
const customTermForm = document.getElementById("customTermForm");
const customSignInput = document.getElementById("customSign");
const customLabelInput = document.getElementById("customLabel");
const customCategoryInput = document.getElementById("customCategory");
const glossaryList = document.getElementById("glossaryList");
const statusMessage = document.getElementById("statusMessage");
const undoButton = document.getElementById("undoButton");
const clearButton = document.getElementById("clearButton");
const resetButton = document.getElementById("resetButton");
let idCounter = 0;
let state = freshState();
function freshState() {
return {
stageName: "",
terms: STARTER_TERMS.map((term) => ({ ...term })),
notes: [],
};
}
function createId(prefix) {
idCounter += 1;
return `${prefix}-${Date.now()}-${idCounter}`;
}
function setStatus(message) {
statusMessage.textContent = message;
}
function renderPalette() {
termPalette.replaceChildren();
state.terms.forEach((term) => {
const button = document.createElement("button");
const sign = document.createElement("span");
const label = document.createElement("span");
button.type = "button";
button.className = "term-button";
button.dataset.termId = term.id;
button.title = `${term.category}: ${term.label}`;
sign.className = "term-sign";
sign.textContent = term.sign;
label.className = "term-label";
label.textContent = term.label;
button.append(sign, label);
termPalette.append(button);
});
}
function renderStageLine() {
stageLine.replaceChildren();
if (state.notes.length === 0) {
const emptyState = document.createElement("p");
emptyState.className = "empty-state";
emptyState.textContent = "Choose a term to start the digital note line.";
stageLine.append(emptyState);
} else {
state.notes.forEach((note) => {
const token = document.createElement("button");
token.type = "button";
token.className = "note-token";
token.dataset.noteId = note.id;
token.textContent = `${note.sign} ${note.label}`;
token.setAttribute("aria-label", `Remove ${note.label} from the note line`);
stageLine.append(token);
});
}
const suffix = state.notes.length === 1 ? "token" : "tokens";
noteCount.textContent = `${state.notes.length} ${suffix}`;
}
function renderGlossary() {
glossaryList.replaceChildren();
state.terms.forEach((term) => {
const item = document.createElement("li");
const symbol = document.createElement("span");
const details = document.createElement("span");
const label = document.createElement("strong");
const category = document.createElement("span");
item.className = "glossary-item";
symbol.className = "glossary-symbol";
details.className = "glossary-details";
category.className = "glossary-category";
symbol.textContent = term.sign;
label.textContent = term.label;
category.textContent = term.custom
? `${term.category} | Custom`
: `${term.category} | Starter example`;
if (term.custom) {
category.classList.add("custom-badge");
}
details.append(label, category);
item.append(symbol, details);
if (term.custom) {
const removeButton = document.createElement("button");
removeButton.type = "button";
removeButton.className = "remove-term-button";
removeButton.dataset.removeTermId = term.id;
removeButton.textContent = "Remove";
item.append(removeButton);
}
glossaryList.append(item);
});
}
function renderAll() {
stageNameInput.value = state.stageName;
renderPalette();
renderStageLine();
renderGlossary();
}
function addTermToNote(termId) {
const term = state.terms.find((item) => item.id === termId);
if (!term) {
return;
}
state.notes.push({
id: createId("note"),
termId: term.id,
sign: term.sign,
label: term.label,
});
renderAll();
setStatus(`Added ${term.label} to the note line.`);
}
termPalette.addEventListener("click", (event) => {
const button = event.target.closest("[data-term-id]");
if (button) {
addTermToNote(button.dataset.termId);
}
});
customTermForm.addEventListener("submit", (event) => {
event.preventDefault();
const sign = customSignInput.value.trim();
const label = customLabelInput.value.trim();
const category = customCategoryInput.value;
if (!sign || !label) {
setStatus("Enter both a short sign and a spoken term.");
return;
}
state.terms.push({
id: createId("term"),
sign,
label,
category,
custom: true,
});
customTermForm.reset();
renderAll();
setStatus(`Added ${label} to the crew lexicon.`);
});
glossaryList.addEventListener("click", (event) => {
const button = event.target.closest("[data-remove-term-id]");
if (!button) {
return;
}
const termId = button.dataset.removeTermId;
const term = state.terms.find((item) => item.id === termId);
state.terms = state.terms.filter((item) => item.id !== termId);
renderAll();
setStatus(
term
? `Removed ${term.label} from the palette. Existing note tokens remain.`
: "Removed the custom term.",
);
});
renderAll();
- Return to Microsoft Edge.
- Refresh the app.
- Enter !! in the Short sign field.
- Enter Double caution in the Spoken term field.
- Select Hazard from the Category field.
- Click Add crew term.
- Click the new !! palette button.
- Click Remove beside the custom glossary entry.
The custom source term disappears from the palette and glossary. Its copied !! Double caution token remains in the current note line.
Existing token disappeared too?
- Confirm that the removal handler filters state.terms only.
- Remove any line that filters or clears state.notes inside the glossary handler.
- Check that addTermToNote() copies the sign and label into each note object.
Ask for help if removing a glossary term also removes its note tokens: How do I keep copied note objects when removing their source term from state.terms?
The glossary removal path works. Now you can verify the complete custom-term loop before testing the current memory limit.
- Enter Forest stage in the Stage name field.
- Enter !! in the Short sign field.
- Enter Double caution in the Spoken term field.
- Select Hazard from the Category field.
- Click Add crew term.
- Click the new !! palette button.
You now see the custom palette button, the labeled glossary entry, and the copied note token. That proves one crew-specific term can travel through the complete interface.
Before you refresh, do you expect the stage name, custom term, and note token to remain?
- Refresh http://127.0.0.1:8000.
The stage name, custom term, and note sequence disappear. The app stores them in memory only, which exposes the persistence problem you will solve next.
Custom data still present after refresh?
Confirm that your current app.js does not load previously saved state. This step should start from freshState() after every full page refresh.
Ask for help if the refreshed app still restores old custom data: Why is my in-memory rally note state surviving a full browser refresh?
Your app now speaks the crew's language during the current session. Next, you will preserve that work across browser refreshes.
Keep Notes After a Refresh
Your crew can now add personal signs to the palette. Those terms can also become visible note tokens.
A refresh still erases the stage name, custom lexicon, and note sequence. This step saves the complete workspace in browser storage so each part returns unchanged.
In this step, get ready to:
- Load saved workspace data with a safe fallback.
- Save each meaningful change to the complete state object.
- Refresh the browser to prove the workspace returns unchanged.
Build the persistence loop
Browser storage keeps text values between sessions. The app will serialize the complete state as JSON before saving it under one stable key.
- In app.js, add this storage key directly above STARTER_TERMS:
const STORAGE_KEY = "rally-pace-note-prototype-v1";
Why use one storage key?
The STORAGE_KEY constant gives the complete workspace one predictable browser-storage name. Every save and load operation can now refer to the same entry.
- Save app.js.
- Switch back to Microsoft Edge.
- Refresh http://127.0.0.1:8000.
You should still see the full composer with its starter palette. The new constant leaves the existing interface unchanged.
Does the interface disappear?
- Check that the new constant appears above STARTER_TERMS.
- Check that the storage key uses matching quotation marks.
Ask for help with the storage-key edit.
The app also needs a safe loading path. Missing data or invalid saved JSON should produce a fresh workspace instead of a blank page.
- In app.js, replace the current state initialization and freshState() section with this code:
let state = loadState();
function freshState() {
return {
stageName: "",
terms: STARTER_TERMS.map((term) => ({ ...term })),
notes: [],
};
}
function loadState() {
try {
const savedState = localStorage.getItem(STORAGE_KEY);
if (!savedState) {
return freshState();
}
const parsedState = JSON.parse(savedState);
const hasExpectedShape =
parsedState &&
typeof parsedState.stageName === "string" &&
Array.isArray(parsedState.terms) &&
Array.isArray(parsedState.notes);
return hasExpectedShape ? parsedState : freshState();
} catch {
return freshState();
}
}
How does loading stay safe?
- localStorage.getItem() reads the value stored under STORAGE_KEY.
- JSON.parse() converts the saved text back into a JavaScript object.
- hasExpectedShape confirms that the stage name is a string.
- The same check confirms that terms and notes are arrays.
- freshState() supplies a working fallback when saved data is absent or invalid.
- Save app.js.
- Refresh http://127.0.0.1:8000.
You should see the starter terms with an empty note line. This confirms that the fallback state renders when no saved workspace exists.
Does the page stay blank?
- Check that let state = loadState(); appears after let idCounter = 0;.
- Check that loadState() closes with two braces.
- Check that freshState() still returns stageName, terms, and notes.
Help me check the loading logic.
Loading recovers a previous workspace. Saving must now capture the latest state whenever the learner changes it.
- Add this function directly below loadState() in app.js:
function saveState(message = "") {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
if (message) {
setStatus(message);
}
} catch {
setStatus("This browser could not save the prototype state.");
}
}
What does saving do?
- JSON.stringify() converts the complete state object into text.
- localStorage.setItem() writes that text under the stable storage key.
- The optional message updates the existing status area after a successful action.
- The catch block keeps a storage failure visible to the learner.
- Replace the existing addTermToNote() function with this saved version:
function addTermToNote(termId) {
const term = state.terms.find((item) => item.id === termId);
if (!term) {
return;
}
state.notes.push({
id: createId("note"),
termId: term.id,
sign: term.sign,
label: term.label,
});
renderAll();
saveState(`Added ${term.label} to the note line.`);
}
Why save copied token data?
- Each note keeps a copied sign value.
- Each note also keeps its copied label value.
- saveState() persists the updated notes array after the interface redraws.
- Save app.js.
- Click the L starter term.
Before you refresh, do you expect the copied Left token to remain in the note line?
- Refresh http://127.0.0.1:8000.
You should still see the copied Left token. That surviving token proves the app can save and reload the note sequence.
Does the token disappear?
- Check that addTermToNote() ends with a call to saveState().
- Check that saveState() passes the complete state object to JSON.stringify().
- Confirm that you are testing through http://127.0.0.1:8000.
Help me find why the note token is not surviving a refresh.
Save every workspace edit
A useful workspace contains more than note tokens. Custom terms, glossary removals, and the stage name must travel through the same persistence loop.
- Replace the existing customTermForm submission handler with this version:
customTermForm.addEventListener("submit", (event) => {
event.preventDefault();
const sign = customSignInput.value.trim();
const label = customLabelInput.value.trim();
const category = customCategoryInput.value;
if (!sign || !label) {
setStatus("Enter both a short sign and a spoken term.");
return;
}
state.terms.push({
id: createId("term"),
sign,
label,
category,
custom: true,
});
customTermForm.reset();
renderAll();
saveState(`Added ${label} to the crew lexicon.`);
});
What changed in the form handler?
- The handler still trims the sign and spoken term.
- The validation still stops empty values from becoming custom terms.
- The new saveState() call persists the expanded lexicon after rendering it.
- Save app.js.
- Enter Sq in the Short sign field.
- Enter Square in the Spoken term field.
- Click Add crew term.
- Refresh the page.
You should see Square in the palette and glossary after the refresh. The custom vocabulary now survives alongside the note sequence.
Does the custom term vanish?
- Check that the form handler calls saveState() after renderAll().
- Check that the custom term object still contains custom: true.
Help me debug the disappearing custom term.
- Replace the existing glossaryList click handler with this version:
glossaryList.addEventListener("click", (event) => {
const button = event.target.closest("[data-remove-term-id]");
if (!button) {
return;
}
const termId = button.dataset.removeTermId;
const term = state.terms.find((item) => item.id === termId);
state.terms = state.terms.filter((item) => item.id !== termId);
renderAll();
saveState(
term
? `Removed ${term.label} from the palette. Existing note tokens remain.`
: "Removed the custom term.",
);
});
How does removal stay consistent?
- The handler removes the selected source term from state.terms.
- Copied token objects remain in state.notes.
- The final saveState() call preserves both outcomes after a refresh.
- Save app.js.
- Click the Square palette button.
- Click Remove beside Square in the glossary.
- Refresh the page.
You should still see the copied Square token in the note line. Square should remain absent from the palette and glossary.
Does the removed term return?
- Check that the glossary handler calls saveState() after filtering state.terms.
- Check that the handler reads the term identifier from button.dataset.removeTermId.
Help me debug the returning glossary term.
The saved stage name also needs a route back into its field. The complete renderer can restore that value whenever the app loads state.
- Replace the existing renderAll() function with this restored-state version:
function renderAll() {
stageNameInput.value = state.stageName;
renderPalette();
renderStageLine();
renderGlossary();
}
What does the complete render restore?
- The first line restores the stage-name field from loaded state.
- The existing render functions rebuild the palette from saved terms.
- The note-line renderer restores copied tokens and recalculates the counter.
- The glossary renderer displays saved user-created values with textContent.
- Add this stage-name listener directly below the glossaryList click handler:
stageNameInput.addEventListener("input", () => {
state.stageName = stageNameInput.value;
saveState();
});
Why save on input?
Each input event copies the current field value into state.stageName. The following line saves that value without replacing the status message.
- Save app.js.
- Enter Waterfall stage in the Stage name field.
- Refresh the page.
You should see Waterfall stage restored in the field. The saved palette, note line, glossary, and token counter should also redraw.
Does the stage name reset?
- Check that the input listener copies stageNameInput.value into state.stageName.
- Check that renderAll() assigns state.stageName to the input field.
Help me trace the saved stage name.
Prove the workspace survives refresh
The persistence loop now covers every editable value introduced so far. One complete test confirms that the stage name, custom lexicon, and note sequence move through the save and load path together.
- Enter Forest stage in the Stage name field.
- Enter !! in the Short sign field.
- Enter Double caution in the Spoken term field.
- Select Hazard in the Category field.
- Click Add crew term.
- Click the new Double caution palette button.
- Click the Cr starter term.
Before you refresh, do you expect the stage name, custom term, and complete note sequence to return unchanged?
- Refresh http://127.0.0.1:8000.
You should see Forest stage in the stage field. Double caution should return in the custom lexicon while every note token and the counter retain their previous state.
✔️ Awesome, I've got everything!
That refresh is the proof. Your stage name, crew lexicon, and copied note sequence now survive between browser sessions.
ⓧ I'd like to double check the full code
Compare your saved app.js with the complete file for this step:
const STORAGE_KEY = "rally-pace-note-prototype-v1";
const STARTER_TERMS = [
{ id: "left", sign: "L", label: "Left", category: "Direction", custom: false },
{ id: "right", sign: "R", label: "Right", category: "Direction", custom: false },
{ id: "tightens", sign: "T", label: "Tightens", category: "Modifier", custom: false },
{ id: "crest", sign: "Cr", label: "Crest", category: "Feature", custom: false },
{ id: "caution", sign: "!", label: "Caution", category: "Hazard", custom: false },
];
const stageNameInput = document.getElementById("stageName");
const termPalette = document.getElementById("termPalette");
const stageLine = document.getElementById("stageLine");
const noteCount = document.getElementById("noteCount");
const customTermForm = document.getElementById("customTermForm");
const customSignInput = document.getElementById("customSign");
const customLabelInput = document.getElementById("customLabel");
const customCategoryInput = document.getElementById("customCategory");
const glossaryList = document.getElementById("glossaryList");
const statusMessage = document.getElementById("statusMessage");
const undoButton = document.getElementById("undoButton");
const clearButton = document.getElementById("clearButton");
const resetButton = document.getElementById("resetButton");
let idCounter = 0;
let state = loadState();
function freshState() {
return {
stageName: "",
terms: STARTER_TERMS.map((term) => ({ ...term })),
notes: [],
};
}
function loadState() {
try {
const savedState = localStorage.getItem(STORAGE_KEY);
if (!savedState) {
return freshState();
}
const parsedState = JSON.parse(savedState);
const hasExpectedShape =
parsedState &&
typeof parsedState.stageName === "string" &&
Array.isArray(parsedState.terms) &&
Array.isArray(parsedState.notes);
return hasExpectedShape ? parsedState : freshState();
} catch {
return freshState();
}
}
function saveState(message = "") {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
if (message) {
setStatus(message);
}
} catch {
setStatus("This browser could not save the prototype state.");
}
}
function createId(prefix) {
idCounter += 1;
return `${prefix}-${Date.now()}-${idCounter}`;
}
function setStatus(message) {
statusMessage.textContent = message;
}
function renderPalette() {
termPalette.replaceChildren();
state.terms.forEach((term) => {
const button = document.createElement("button");
const sign = document.createElement("span");
const label = document.createElement("span");
button.type = "button";
button.className = "term-button";
button.dataset.termId = term.id;
button.title = `${term.category}: ${term.label}`;
sign.className = "term-sign";
sign.textContent = term.sign;
label.className = "term-label";
label.textContent = term.label;
button.append(sign, label);
termPalette.append(button);
});
}
function renderStageLine() {
stageLine.replaceChildren();
if (state.notes.length === 0) {
const emptyState = document.createElement("p");
emptyState.className = "empty-state";
emptyState.textContent = "Choose a term to start the digital note line.";
stageLine.append(emptyState);
} else {
state.notes.forEach((note) => {
const token = document.createElement("button");
token.type = "button";
token.className = "note-token";
token.dataset.noteId = note.id;
token.textContent = `${note.sign} ${note.label}`;
token.setAttribute("aria-label", `Remove ${note.label} from the note line`);
stageLine.append(token);
});
}
const suffix = state.notes.length === 1 ? "token" : "tokens";
noteCount.textContent = `${state.notes.length} ${suffix}`;
}
function renderGlossary() {
glossaryList.replaceChildren();
state.terms.forEach((term) => {
const item = document.createElement("li");
const symbol = document.createElement("span");
const details = document.createElement("span");
const label = document.createElement("strong");
const category = document.createElement("span");
item.className = "glossary-item";
symbol.className = "glossary-symbol";
details.className = "glossary-details";
category.className = "glossary-category";
symbol.textContent = term.sign;
label.textContent = term.label;
category.textContent = term.custom
? `${term.category} | Custom`
: `${term.category} | Starter example`;
if (term.custom) {
category.classList.add("custom-badge");
}
details.append(label, category);
item.append(symbol, details);
if (term.custom) {
const removeButton = document.createElement("button");
removeButton.type = "button";
removeButton.className = "remove-term-button";
removeButton.dataset.removeTermId = term.id;
removeButton.textContent = "Remove";
item.append(removeButton);
}
glossaryList.append(item);
});
}
function renderAll() {
stageNameInput.value = state.stageName;
renderPalette();
renderStageLine();
renderGlossary();
}
function addTermToNote(termId) {
const term = state.terms.find((item) => item.id === termId);
if (!term) {
return;
}
state.notes.push({
id: createId("note"),
termId: term.id,
sign: term.sign,
label: term.label,
});
renderAll();
saveState(`Added ${term.label} to the note line.`);
}
termPalette.addEventListener("click", (event) => {
const button = event.target.closest("[data-term-id]");
if (button) {
addTermToNote(button.dataset.termId);
}
});
customTermForm.addEventListener("submit", (event) => {
event.preventDefault();
const sign = customSignInput.value.trim();
const label = customLabelInput.value.trim();
const category = customCategoryInput.value;
if (!sign || !label) {
setStatus("Enter both a short sign and a spoken term.");
return;
}
state.terms.push({
id: createId("term"),
sign,
label,
category,
custom: true,
});
customTermForm.reset();
renderAll();
saveState(`Added ${label} to the crew lexicon.`);
});
glossaryList.addEventListener("click", (event) => {
const button = event.target.closest("[data-remove-term-id]");
if (!button) {
return;
}
const termId = button.dataset.removeTermId;
const term = state.terms.find((item) => item.id === termId);
state.terms = state.terms.filter((item) => item.id !== termId);
renderAll();
saveState(
term
? `Removed ${term.label} from the palette. Existing note tokens remain.`
: "Removed the custom term.",
);
});
stageNameInput.addEventListener("input", () => {
state.stageName = stageNameInput.value;
saveState();
});
renderAll();
Does part of the workspace still reset?
- Check that every state-changing handler calls saveState() after updating state.
- Check that loadState() validates stageName, terms, and notes.
- Check that renderAll() restores the stage-name field before rendering the saved collections.
Help me trace the missing saved value.
Your browser now remembers the crew's workspace between sessions. Next, you will add controls for correcting mistakes and resetting saved prototype data.
Make the Note Line Safe to Revise
Your entire workspace now survives a refresh. The next goal is making those saved notes easy to correct.
You will use event delegation to remove exactly the token you choose.
Fast transcription invites mistakes. Each revision needs to redraw the interface before saving the corrected state.
In this step, get ready to:
- Remove individual tokens without changing the crew glossary.
- Undo the latest token or clear the current stage line.
- Reset every saved value to the starter state.
Remove individual note tokens
The note buttons already carry a unique data-note-id. A delegated listener can identify the clicked button before filtering its matching object from state.notes.
- Switch back to app.js in Visual Studio Code.
- Find the existing stageNameInput.addEventListener("input", () => { listener near the bottom of the file.
- Add the removable-token listener immediately above it by pasting:
stageLine.addEventListener("click", (event) => {
const token = event.target.closest("[data-note-id]");
if (!token) {
return;
}
state.notes = state.notes.filter((note) => note.id !== token.dataset.noteId);
renderAll();
saveState("Removed one token from the note line.");
});
How does token removal work?
- The listener watches clicks inside the entire stage line. This keeps working when renderStageLine() creates new token buttons.
- The closest() check finds the token button connected to the click.
- The filter() call keeps every note except the object with the selected token ID.
- The final two calls redraw the shorter line before saving it.
- Save app.js by pressing Ctrl+S.
- Return to Microsoft Edge.
- Refresh http://127.0.0.1:8000.
- Click four starter-term buttons.
- Click the second token in the current note line.
You should see only the selected token disappear. The counter should decrease to 3 tokens.
The status area should report that one token was removed. The glossary remains unchanged.
Token not disappearing?
- Confirm that the listener uses stageLine instead of termPalette.
- Check that data-note-id matches the attribute created inside renderStageLine().
- Save app.js before refreshing the browser.
Help me debug token removal in my pace-note app.
Add undo and clear controls
The state.notes.pop() operation removes the final note object. Assigning [] removes every note object.
- In app.js, find the complete stageNameInput listener.
- Add the undo listener immediately below it by pasting:
undoButton.addEventListener("click", () => {
if (state.notes.length === 0) {
setStatus("There is no note token to undo.");
return;
}
const removedNote = state.notes.pop();
renderAll();
saveState(`Removed the latest token: ${removedNote.label}.`);
});
How does undo work?
- The empty-array check protects the action when no note tokens exist.
- The pop() call removes the final object from state.notes.
- The removed object remains available long enough to report its label in the status message.
- Save app.js.
- Return to Microsoft Edge.
- Refresh the page.
- Click four palette buttons.
- Click Undo last.
You should see the fourth token disappear. The counter should return to 3 tokens.
Undo removing the wrong token?
- Confirm that the handler calls state.notes.pop() before renderAll().
- Check that the listener is attached to undoButton.
Help me fix the Undo last handler.
- Add the clear listener immediately below the undo listener by pasting:
clearButton.addEventListener("click", () => {
state.notes = [];
renderAll();
saveState("Cleared the current stage line.");
});
How does clearing work?
- The handler replaces only state.notes with an empty array.
- The stage name and crew terms remain inside the state object.
- The render and save calls update the visible line before persisting the empty array.
- Save app.js.
- Return to Microsoft Edge.
- Refresh the page.
- Click three palette buttons.
- Click Clear stage.
You should see the stage line return to its empty message. The counter should read 0 tokens.
Stage line not clearing?
- Confirm that the listener assigns an empty array to state.notes.
- Check that the listener is attached to clearButton.
- Confirm that renderAll() runs before saveState().
Help me debug the Clear stage control.
Reset saved prototype data
Reset All clears only this app's saved prototype entry. Your project files stay untouched.
The handler restores freshState() before removing the rally-pace-note-prototype-v1 browser storage entry.
- In app.js, find the complete clearButton listener.
- Add the reset listener immediately below it by pasting:
resetButton.addEventListener("click", () => {
state = freshState();
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
setStatus("The interface reset, but browser storage could not be cleared.");
}
renderAll();
setStatus("Reset the stage, note line, and custom crew terms.");
});
How does reset work?
- The freshState() call rebuilds the five starter terms with an empty stage name and note array.
- The storage call removes only the entry identified by STORAGE_KEY.
- The try block keeps the interface usable if the browser blocks storage removal.
- The final render displays the starter state before the status message confirms the reset.
- Save app.js.
- Return to Microsoft Edge.
- Refresh the page.
The completed revision handlers are now loaded. Build one final saved workspace to test the entire loop.
- Enter Forest stage in the Stage name field.
- Enter !! in the Short sign field.
- Enter Double caution in the Spoken term field.
- Select Hazard from the Category field.
- Click Add crew term.
- Click the new !! palette button.
- Click starter-term buttons until the line contains four tokens.
- Click one of the two middle tokens.
You should see the selected middle token disappear without removing its glossary term. The counter should read 3 tokens.
- Click Undo last.
- Refresh the browser.
You should see Forest stage, the custom !! term, and the remaining two-token line return.
Before you click Reset All, predict the stage field, palette, and counter you expect to see afterward.
- Click Reset all.
That's the complete revision loop working. You should see an empty stage name with the five starter terms and 0 tokens.
Reset state returning after refresh?
- Confirm that the handler assigns freshState() to state.
- Check that the storage removal uses the existing STORAGE_KEY constant.
- Confirm that the reset handler appears before the final renderAll() call.
Help me fix saved data returning after Reset all.
✔️ Awesome, I've got everything!
Your completed app can remove one token, undo the latest token, clear the stage line, and reset all saved prototype data.
ⓧ I'd like to double check the full code
- Compare your saved app.js with the complete file below:
const STORAGE_KEY = "rally-pace-note-prototype-v1";
const STARTER_TERMS = [
{ id: "left", sign: "L", label: "Left", category: "Direction", custom: false },
{ id: "right", sign: "R", label: "Right", category: "Direction", custom: false },
{ id: "tightens", sign: "T", label: "Tightens", category: "Modifier", custom: false },
{ id: "crest", sign: "Cr", label: "Crest", category: "Feature", custom: false },
{ id: "caution", sign: "!", label: "Caution", category: "Hazard", custom: false },
];
const stageNameInput = document.getElementById("stageName");
const termPalette = document.getElementById("termPalette");
const stageLine = document.getElementById("stageLine");
const noteCount = document.getElementById("noteCount");
const customTermForm = document.getElementById("customTermForm");
const customSignInput = document.getElementById("customSign");
const customLabelInput = document.getElementById("customLabel");
const customCategoryInput = document.getElementById("customCategory");
const glossaryList = document.getElementById("glossaryList");
const statusMessage = document.getElementById("statusMessage");
const undoButton = document.getElementById("undoButton");
const clearButton = document.getElementById("clearButton");
const resetButton = document.getElementById("resetButton");
let idCounter = 0;
let state = loadState();
function freshState() {
return {
stageName: "",
terms: STARTER_TERMS.map((term) => ({ ...term })),
notes: [],
};
}
function loadState() {
try {
const savedState = localStorage.getItem(STORAGE_KEY);
if (!savedState) {
return freshState();
}
const parsedState = JSON.parse(savedState);
const hasExpectedShape =
parsedState &&
typeof parsedState.stageName === "string" &&
Array.isArray(parsedState.terms) &&
Array.isArray(parsedState.notes);
return hasExpectedShape ? parsedState : freshState();
} catch {
return freshState();
}
}
function saveState(message = "") {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
if (message) {
setStatus(message);
}
} catch {
setStatus("This browser could not save the prototype state.");
}
}
function createId(prefix) {
idCounter += 1;
return `${prefix}-${Date.now()}-${idCounter}`;
}
function setStatus(message) {
statusMessage.textContent = message;
}
function renderPalette() {
termPalette.replaceChildren();
state.terms.forEach((term) => {
const button = document.createElement("button");
const sign = document.createElement("span");
const label = document.createElement("span");
button.type = "button";
button.className = "term-button";
button.dataset.termId = term.id;
button.title = `${term.category}: ${term.label}`;
sign.className = "term-sign";
sign.textContent = term.sign;
label.className = "term-label";
label.textContent = term.label;
button.append(sign, label);
termPalette.append(button);
});
}
function renderStageLine() {
stageLine.replaceChildren();
if (state.notes.length === 0) {
const emptyState = document.createElement("p");
emptyState.className = "empty-state";
emptyState.textContent = "Choose a term to start the digital note line.";
stageLine.append(emptyState);
} else {
state.notes.forEach((note) => {
const token = document.createElement("button");
token.type = "button";
token.className = "note-token";
token.dataset.noteId = note.id;
token.textContent = `${note.sign} ${note.label}`;
token.setAttribute("aria-label", `Remove ${note.label} from the note line`);
stageLine.append(token);
});
}
const suffix = state.notes.length === 1 ? "token" : "tokens";
noteCount.textContent = `${state.notes.length} ${suffix}`;
}
function renderGlossary() {
glossaryList.replaceChildren();
state.terms.forEach((term) => {
const item = document.createElement("li");
const symbol = document.createElement("span");
const details = document.createElement("span");
const label = document.createElement("strong");
const category = document.createElement("span");
item.className = "glossary-item";
symbol.className = "glossary-symbol";
details.className = "glossary-details";
category.className = "glossary-category";
symbol.textContent = term.sign;
label.textContent = term.label;
category.textContent = term.custom
? `${term.category} | Custom`
: `${term.category} | Starter example`;
if (term.custom) {
category.classList.add("custom-badge");
}
details.append(label, category);
item.append(symbol, details);
if (term.custom) {
const removeButton = document.createElement("button");
removeButton.type = "button";
removeButton.className = "remove-term-button";
removeButton.dataset.removeTermId = term.id;
removeButton.textContent = "Remove";
item.append(removeButton);
}
glossaryList.append(item);
});
}
function renderAll() {
stageNameInput.value = state.stageName;
renderPalette();
renderStageLine();
renderGlossary();
}
function addTermToNote(termId) {
const term = state.terms.find((item) => item.id === termId);
if (!term) {
return;
}
state.notes.push({
id: createId("note"),
termId: term.id,
sign: term.sign,
label: term.label,
});
renderAll();
saveState(`Added ${term.label} to the note line.`);
}
termPalette.addEventListener("click", (event) => {
const button = event.target.closest("[data-term-id]");
if (button) {
addTermToNote(button.dataset.termId);
}
});
customTermForm.addEventListener("submit", (event) => {
event.preventDefault();
const sign = customSignInput.value.trim();
const label = customLabelInput.value.trim();
const category = customCategoryInput.value;
if (!sign || !label) {
setStatus("Enter both a short sign and a spoken term.");
return;
}
state.terms.push({
id: createId("term"),
sign,
label,
category,
custom: true,
});
customTermForm.reset();
renderAll();
saveState(`Added ${label} to the crew lexicon.`);
});
glossaryList.addEventListener("click", (event) => {
const button = event.target.closest("[data-remove-term-id]");
if (!button) {
return;
}
const termId = button.dataset.removeTermId;
const term = state.terms.find((item) => item.id === termId);
state.terms = state.terms.filter((item) => item.id !== termId);
renderAll();
saveState(
term
? `Removed ${term.label} from the palette. Existing note tokens remain.`
: "Removed the custom term.",
);
});
stageLine.addEventListener("click", (event) => {
const token = event.target.closest("[data-note-id]");
if (!token) {
return;
}
state.notes = state.notes.filter((note) => note.id !== token.dataset.noteId);
renderAll();
saveState("Removed one token from the note line.");
});
stageNameInput.addEventListener("input", () => {
state.stageName = stageNameInput.value;
saveState();
});
undoButton.addEventListener("click", () => {
if (state.notes.length === 0) {
setStatus("There is no note token to undo.");
return;
}
const removedNote = state.notes.pop();
renderAll();
saveState(`Removed the latest token: ${removedNote.label}.`);
});
clearButton.addEventListener("click", () => {
state.notes = [];
renderAll();
saveState("Cleared the current stage line.");
});
resetButton.addEventListener("click", () => {
state = freshState();
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
setStatus("The interface reset, but browser storage could not be cleared.");
}
renderAll();
setStatus("Reset the stage, note line, and custom crew terms.");
});
renderAll();
Secret mission
Preview a Paper Note Beside the Editor
Add a local paper-note photo preview above the editor. You will create a browser file workflow that supports manual transcription without uploading or saving the selected image.
Clean Up Your Resources
Clean Up Your Resources
There are no ongoing cloud costs. Decide whether to keep your local resources running, pause them for later, or delete them entirely.
Resources you used:
- The local rally-notes-prototype folder containing index.html, styles.css, app.js, plus photo-preview.js.
- The browser localStorage entry named rally-pace-note-prototype-v1.
- The localhost-only Python development server at http://127.0.0.1:8000.
Keep everything running
No action is needed. Choose this if you are still actively developing the pace-note prototype.
The Python server continues to use one terminal. The project folder plus saved browser state remain available.
The selected paper-note photo remains only in the current preview. A page refresh removes it from the preview.
Pause - I'll come back to this later
Stop the Python server to free the terminal. Your project files plus saved browser state stay on your computer.
- Switch back to Visual Studio Code from earlier.
- Select the terminal panel running the localhost server.
- Press Ctrl+C.
You will see the terminal prompt return. Your rally-notes-prototype folder plus the rally-pace-note-prototype-v1 browser entry remain ready for later.
Delete - I don't want to use this again
Remove all project resources to start fresh. Permanent deletion removes your code plus saved note state.
- In Microsoft Edge, return to the completed app from earlier.
- Click Reset all in the control row.
You will see the counter return to 0 tokens. The saved rally-pace-note-prototype-v1 entry is removed.
The paper-note photo has no saved copy. It needs no separate cleanup.
- Switch back to Visual Studio Code from earlier.
- Select the terminal panel running the localhost server.
- Press Ctrl+C.
You will see the terminal prompt return. The localhost server is now stopped.
- Open File Explorer through Windows search.
- Navigate to the location where you created rally-notes-prototype.
- Delete the rally-notes-prototype folder permanently.
You will no longer see the project folder in that location. Every locally stored project resource is now removed.
Nice Work!
Nice Work!
You did it! Your responsive rally pace-note editor now turns a crew's own language into a digital stage line. The workspace stays editable after a browser refresh.
You've learned how to:
- Built a responsive pace-note interface with large starter-term controls. Each selected term appears in a visible stage line.
- Modeled the crew lexicon with JavaScript objects. Used arrays to hold custom terms plus note tokens.
- Created a browser-persisted workflow with localStorage plus JSON serialization. Added controls that revise or reset the saved stage workspace.
- Completed the optional Secret Mission by adding a local paper-note photo preview beside the editor. The selected image supports manual transcription during the current browser preview.
Ready to quiz yourself?