Introduction
Moonroom is a small exhibition of real Apollo photographs. Its collection is ready, but the current exhibition has no photo cards. You will build one meaningful card, expand it from data, and make the complete grid readable on narrow and wide screens. A separate Study collection shows the expected viewing experience before you start.
You need basic HTML structure, CSS selectors and layout, and JavaScript objects, arrays and DOM events. This lab introduces figure, figcaption, native buttons, stable photo IDs, forEach, dataset and the responsive rules used here. The detailed viewer is prepared in this first lab; the next lab builds that behavior. The time is an author estimate of 50 minutes, including reading and ordinary editing mistakes.
After opening or refreshing a website page, let it finish loading before activating its photo controls. Use WebIDE to edit /home/labex/project/moonroom, and Web 8080 to use the website. Only edit index.html, gallery.js and styles.css in this lab. Preserve photos.js, viewer.js, images/ and credits.html. The supplied image files and metadata must remain associated with their original IDs. All website labels stay in English. These practical keyboard checks do not constitute a complete accessibility certification.
Connect One Photograph to Its Meaning
In this step, use a working exhibition with the keyboard, then build the first card in your own exhibition.
Switch to Web 8080 and choose the page link Study collection. This is a separate prepared example; changing it is outside your workspace. Press Tab until the first Earthrise photograph button has a visible outline, then press Enter. The detailed view shows Earth above the lunar horizon. Press Esc: focus returns to the same card. Notice the other Earthrise card has a different photograph and caption. A title is a label, not a unique identity.
Choose the page link Current exhibition to return to your own page. This link navigates within the website; it does not switch a LabEx Tab. Your page initially has the heading and credits but no cards. That is the incomplete state you will change.
Switch to WebIDE. In its Explorer, open the moonroom folder under /home/labex/project. If the Explorer is hidden, choose View → Explorer. Open photos.js. The photos array contains one object per photograph. Each object keeps these related values together:
| Field | Meaning |
|---|---|
id |
Stable identity, such as earthrise-eight; two titles may be identical. |
thumbnail |
A real, smaller local rendition used in a card. |
large |
The corresponding larger rendition for the viewer. |
alt |
Meaningful text describing the image for people who cannot see it. |
title and caption |
Visible name and context. |
Do not edit this file. For the first record, the thumbnail is images/earthrise-eight-thumb.jpg; the larger image is images/earthrise-eight.jpg. A relative image path is resolved from the HTML page, not from the editor's current folder. Capitalization and the .jpg extension matter.
A figure groups an image with its figcaption. The button makes opening the photograph a native keyboard action: it can receive Tab focus and activates with Enter. A div with a mouse click handler would not provide those behaviors automatically. type="button" makes the intended button behavior explicit. The visible title and caption remain available without hovering. The aria-label gives the button its short accessible name, Open Earthrise; alt describes what its image contains. Because two photographs share that name, aria-describedby also connects the button to its caption through the caption's unique id. The caption supplies distinguishing context such as Apollo 8 or Apollo 11. An accessible name identifies a control; an associated description adds context. This avoids making two identically named controls indistinguishable to someone navigating without the pictures. A placeholder or a background image would not supply that image description.
The data-photo-card attribute identifies the complete card container; data-photo-id identifies its opening button. Both use the record's stable ID. We keep those small hooks so data, cards and the viewer refer to the same photograph; they are not visible text. A card may have additional wrappers, but its button and caption must belong to the nearest container with that matching data-photo-card.
Open index.html, select all its text with Ctrl+A or Cmd+A, and replace it with the complete HTML below. Keep the script lines: defer runs these scripts after the HTML has been parsed, in their listed order. photos.js supplies data, gallery.js will build the collection in the next step, and the prepared viewer.js handles this lab's detailed view. The dialog markup is the panel you just tried; its behavior will be explained and built in the next lab.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Moonroom — A little farther from home</title>
<link rel="stylesheet" href="styles.css">
<script src="photos.js" defer></script>
<script src="gallery.js" defer></script>
<script src="viewer.js" defer></script>
</head>
<body>
<main>
<header>
<nav aria-label="Exhibitions"><a href="/">Current exhibition</a> · <a href="/example/">Study collection</a></nav>
<p class="eyebrow">Moonroom / Photographic studies</p>
<h1>A little farther from home.</h1>
<p class="intro">Three moments from the Apollo archive. Take a closer look.</p>
</header>
<section id="gallery" class="photo-grid" aria-label="Photographs">
<figure data-photo-card="earthrise-eight">
<button type="button" class="photo-button" data-photo-id="earthrise-eight" aria-label="Open Earthrise" aria-describedby="caption-earthrise-eight">
<img src="images/earthrise-eight-thumb.jpg" alt="Blue and white Earth above the grey lunar horizon">
<span class="photo-name">Earthrise</span>
</button>
<figcaption id="caption-earthrise-eight">Apollo 8 · Earth above the Moon, December 1968.</figcaption>
</figure>
</section>
<footer>Photographs: NASA. An independent educational exhibition. <a href="credits.html">Image credits</a></footer>
</main>
<dialog id="viewer" aria-labelledby="photo-title" aria-describedby="photo-caption">
<button id="close" type="button" autofocus>Close</button>
<h2 id="photo-title"></h2>
<img id="large-photo" alt="">
<p id="photo-error" role="status"></p>
<p id="photo-caption"></p>
<nav aria-label="Photo navigation">
<button id="previous" type="button">Previous</button>
<p id="photo-position"></p>
<button id="next" type="button">Next</button>
</nav>
</dialog>
</body>
</html>
Save with Ctrl+S or Cmd+S. Switch to Web 8080, choose Current exhibition if necessary, and refresh that website page. You should see one Earthrise card, its real image and the Apollo 8 caption. The layout is still plain; you will style it later. Use Tab to reach the card, Enter to open it and Esc to return. The large image should show the same scene with more detail.
If the image is broken or the page is empty, return to WebIDE. Confirm the file is named index.html, is saved inside moonroom, and that the src, quotation marks and closing tags match the example. Reopen Current exhibition and refresh after saving. Do not reset the environment or replace the picture with another file to hide a bad path. You can restore the whole file by copying the complete HTML again.
Run this step's check after the real card is visible. It checks your current exhibition, never the separate example.
Render Every Record Without Losing Identity
In this step, replace the hand-written single card with cards generated from the complete array.
Stay in WebIDE and reopen photos.js for comparison. Its first and third records are both called Earthrise, but their IDs, image paths and captions differ. Looking up photographs by title would lose that distinction. The finished grid must include exactly one visible opening button for each of the three supplied IDs, with the matching thumbnail, title, alternative text and caption. Keep the supplied order and data.
Now open gallery.js. document.querySelector('#gallery') selects the element whose id is gallery. replaceChildren() empties that element, removing the temporary hand-written card so you do not display it twice. photos.forEach(function (photo) { ... }) calls the enclosed function once for each array record, in order; photo is the current object. This is the same repeated-work idea as a loop, with each record passed into the function.
document.createElement() makes a real HTML element in memory. Assigning textContent puts text into it rather than treating the text as HTML. append() inserts the prepared elements, in the order listed. The browser sees the same relationships as your first figure, button, image and caption.
dataset reads and writes data- attributes. JavaScript uses camel case: figure.dataset.photoCard writes data-photo-card, and button.dataset.photoId writes data-photo-id. Both receive photo.id, while the displayed title receives photo.title. Each caption also gets a unique id by joining caption- with photo.id; the button's aria-describedby refers to that caption ID. The visible caption stays outside the button but is associated with it. setAttribute() sets an attribute such as aria-label; the + operator joins the text Open with the title. No string template syntax is needed.
Replace all of gallery.js with this complete code, then save it:
const gallery = document.querySelector('#gallery');
gallery.replaceChildren();
photos.forEach(function (photo) {
const figure = document.createElement('figure');
figure.dataset.photoCard = photo.id;
const button = document.createElement('button');
button.type = 'button';
button.className = 'photo-button';
button.dataset.photoId = photo.id;
button.setAttribute('aria-label', 'Open ' + photo.title);
const image = document.createElement('img');
image.src = photo.thumbnail;
image.alt = photo.alt;
const title = document.createElement('span');
title.className = 'photo-name';
title.textContent = photo.title;
const caption = document.createElement('figcaption');
caption.id = 'caption-' + photo.id;
button.setAttribute('aria-describedby', caption.id);
caption.textContent = photo.caption;
button.append(image, title);
figure.append(button, caption);
gallery.append(figure);
});
Switch to Web 8080 and refresh Current exhibition. You should see Earthrise, A trace of a visitor, and the second Earthrise in that order. Each caption remains visible below its button. The bootprint is a portrait photograph; the Earthrise images are square. The grid will soon frame their thumbnails consistently while preserving the different proportions in detailed viewing.
Use Tab and Enter to open each card separately. The first Earthrise has the Apollo 8 caption; the last has the Apollo 11 caption. Press Esc after each one and check that focus returns to the card you used, including the last card rather than always the first.
If only one card remains, the script may not be saved, may have a syntax error, or may be in the wrong folder. Return to WebIDE, compare gallery.js with the complete block, and check the closing }); at the end of forEach. Do not change photos.js to make its records look alike. Save, refresh the website, and repeat the card comparison before running the check.
Make the Grid Adapt and Show Keyboard Focus
In this step, make the complete exhibition readable on a narrow screen and make the focused card easy to locate.
Return to WebIDE and open styles.css. A responsive layout changes to fit available space instead of forcing one fixed page width. This stylesheet uses flexbox, which you have met in CSS basics. display: flex places cards in a row; flex-wrap: wrap allows a new row; gap leaves space between cards. flex: 1 1 260px lets a figure grow, shrink and start from a preferred width of 260 pixels. min-width: 0 allows long content to shrink inside a card instead of forcing the page wider.
The card image uses width: 100% and aspect-ratio: 4 / 3 for a consistent frame. object-fit: cover fills that thumbnail frame by cropping its displayed edges; it does not change the file. In contrast, the detailed image uses object-fit: contain, max-width and max-height, so the whole picture stays visible at its own proportions. vw and vh are hundredths of the viewport width and height. min() caps a size, while clamp() lets the heading size grow between minimum and maximum values. overflow-wrap: anywhere lets a very long title wrap rather than widen the page.
:focus-visible selects a control when keyboard focus should be shown. A high-contrast outline and outline-offset make the current button visible without moving the layout. Do not remove the browser outline without providing another clear indicator. This project accepts a distinct focus outline of at least 2 pixels, a distinct visible border of at least 2 pixels, or a visible box shadow that appears when the control receives keyboard focus. You may change the colors or layout while retaining those observable behaviors.
The lower rules preserve the prepared viewer: ::backdrop styles the background behind a modal dialog, :disabled visually distinguishes a boundary button, and [hidden] must actually hide a failed image even though ordinary viewer images use display: block. The reduced-motion media query respects the browser's motion preference; this exhibition has no animated transitions.
Replace all of styles.css with the following complete stylesheet and save it:
* { box-sizing: border-box; }
body { margin: 0; background: #f5f1e8; color: #252c28; font: 17px/1.6 system-ui, sans-serif; }
main { max-width: 1160px; margin: auto; padding: 40px 24px; }
header { max-width: 760px; margin-bottom: 40px; }
.eyebrow { color: #6e593f; font-size: 12px; letter-spacing: .16em; text-transform: uppercase; }
h1 { margin: 12px 0; font: clamp(2.5rem, 6vw, 5rem)/1.08 Georgia, serif; }
.intro { max-width: 40rem; color: #5c655e; }
.photo-grid { display: flex; flex-wrap: wrap; gap: 28px; }
figure { flex: 1 1 260px; min-width: 0; margin: 0; }
button { color: inherit; font: inherit; cursor: pointer; }
.photo-button { width: 100%; padding: 0; text-align: left; background: transparent; border: 0; }
.photo-button img { display: block; width: 100%; aspect-ratio: 4 / 3; object-fit: cover; border-radius: 4px; }
.photo-name { display: block; margin-top: 12px; font: 1.5rem/1.3 Georgia, serif; overflow-wrap: anywhere; }
figcaption { margin-top: 6px; color: #5c655e; font-size: 14px; }
a { color: #814820; text-underline-offset: 4px; }
button:focus-visible, a:focus-visible { outline: 3px solid #af421f; outline-offset: 5px; }
footer { margin-top: 48px; border-top: 1px solid #d1c8b8; padding-top: 18px; color: #5c655e; font-size: 13px; }
dialog { width: min(880px, 94vw); max-height: 94vh; overflow: auto; padding: 24px; border: 0; border-radius: 8px; background: #fffcf6; color: #252c28; }
dialog::backdrop { background: #19251fe6; }
dialog h2 { font: 1.8rem/1.3 Georgia, serif; overflow-wrap: anywhere; }
dialog img { display: block; max-width: 100%; max-height: 52vh; margin: auto; object-fit: contain; }
dialog img[hidden] { display: none; }
dialog button { padding: 8px 14px; border: 1px solid #6c746b; border-radius: 4px; background: #fffcf6; }
dialog nav { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
dialog button:disabled { opacity: .45; cursor: default; }
#photo-position { white-space: nowrap; }
#photo-error { color: #9b301c; }
#photo-error:empty { display: none; }
@media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto; } }
Switch to Web 8080 and refresh Current exhibition. At a wide viewport the cards share a row; at a narrow viewport they stack into a readable single column. Resize the browser or the website pane to approximately 390 pixels wide, then widen it again. There should be no horizontal page scrolling, clipped titles or missing card. The actual larger photograph should retain its portrait or square shape when opened.
Starting from the page, press Tab repeatedly. The website links and all three photo buttons should show a visible focus outline. Enter opens the focused photo; Esc returns to it. No description should require a mouse hover. Open Image credits to confirm that its real page lists the NASA image sources, then choose Current exhibition to return. These are website links, separate from switching between WebIDE and Web 8080.
If the layout is still plain, confirm the saved filename is styles.css, and that index.html still links to it. If a card is wider than the page, check min-width: 0, width: 100% on its image, and the wrapping flex rules. Restore the complete stylesheet if needed; keep every photo instead of hiding a difficult one. Run the final check after reviewing both screen sizes and the keyboard path.

In this final wide-screen view, compare the first and last Earthrise captions: the photographs come from different missions. The bootprint remains a separate middle card. The same three cards stack on a narrow screen.
Summary
You connected a real image to its description, expanded one semantic card into a complete data-driven collection, and kept two identically titled photographs distinct through stable IDs. Your responsive grid preserves all cards, real image content and visible keyboard focus. In the next lab you will build the detailed viewer yourself, including navigation boundaries and a recoverable image failure.
