Skip to content

Frontend foundation

  • Lesson 9
  • 100 min
  • Module 3: Build one complete user journey

What you will be able to do

Let Grace use the slice without a terminal: one plain page that uploads a document, shows its indexing status, asks a question, and shows the answer, an honest “no answer”, or a clear error.

Before you startWhat to know and to have readyAbout 100 minutes

What you need to know

The course Frontend Architecture and Performance for AI Apps is planned but not published yet. This lesson does not need it: it uses one HTML page, one CSS file and one JavaScript file, with no framework and no build step. It explains every part that it uses.

What you need on your computer

  • Your project from the last lesson, with the stand-ins (EMBEDDER=hash, ANSWER_PROVIDER=mock), the pack loaded and the API running: curl -s http://127.0.0.1:8000/ready prints "status":"ready".
  • A current browser: Chrome, Edge, Firefox or Safari.

You press the button for a lift. The button lights up, and a display shows the floor of the lift as it comes. You wait calmly, because you can see that something happens.

Now think of a lift button with no light and no display. You press it, and nothing seems to change. Most people press it again, and again. Some take the stairs, although the lift was on its way.

Suppose the page is a form and a result box, and nothing else. Grace uploads the holiday hours, sees no change, and clicks Upload again. The second upload gets an error, because the file is already there: you saw this 409 in the last lesson. The first click worked, but the page did not say so.

Now suppose the API stops while Grace has the page open. She asks a question, and the browser’s request fails before any answer arrives. A page that waits only for an answer shows nothing at all. For Grace, both cases look the same: a page that does not work.

Every action on the page goes through a few states, and the page shows each one. Waiting: the button is off, and a line says what happens (“Searching the documents…”). Done: the answer, or the new document in the table. Empty: a real result with nothing in it, such as “No answer in the documents”. Error: what went wrong, in words. When the API answered, the code and the request ID follow, and Sam can find that ID in the log.

These are often called the loading, empty and error states of a page. A page that shows only the happy result has one state of four. Here are the states of the Ask form, with the exact text that app.js shows:

State What the person sees What the code does
Ready the form nothing yet
Waiting “Searching the documents…”; the Ask button is off button.disabled = true while fetch runs
Answer “Answer”, the answer, the claims with their citations, the provider and the time showAnswer(data.answer)
No answer “No answer in the documents” in an amber frame the same function, with answerable: false
Error the message, then “(code …, request …)” showError(target, problem)
A state diagram of the Ask form. From Ready, Ask leads to Waiting, where the line says Searching the documents and the button is off. From Waiting, a 200 answer that is answerable leads to Answer with its evidence; a 200 answer that is not answerable leads to No answer in the documents; a 4xx, a 5xx or no connection leads to Error, with the message, the code and the request ID. From each of the three, Ask again leads back to Waiting.
The five states of the Ask form. The upload form has the same shape: Uploading…, then the new row in the table, or an error.
Text description of the diagram

The form starts ready. When the person asks, it waits: the line says “Searching the documents…” and the button is off, so a second click cannot send a second request. Then it ends in one of three states: an answer with its evidence, “No answer in the documents”, or an error with the message, the code and the request ID. From each of these, the person can ask again.

The upload has a fourth step, because the API answers before indexing ends. The table must show the status of each document, and change it without a reload. The page asks the API for the documents again every 1.5 seconds, as long as one of them is uploaded or parsing. When all of them are indexed or failed, it stops asking. This is polling: asking again and again at a fixed interval. It costs one small request every 1.5 seconds per open page, only while something is indexing. A server that pushes each change to the page would cost fewer requests, but it needs more code on both sides. For a few uploads a day, polling is enough.

The page is three files in doc_assistant/static/: index.html (65 lines), app.css (37 lines) and app.js (186 lines). The API serves them at /app/, and / sends the browser there. The page and the API therefore have the same address, so the browser sends its requests without any CORS settings. In Module 4, the same address also lets the sign-in cookie work.

Why no framework? The frontend course is not published yet, and a framework adds a build step, a second package manager and its own errors. One page with fetch does the whole journey. Its cost: you write the small things yourself, such as el(), which builds an element, and the page gets harder to change as it grows. For a slice of five actions, that is fine.

Two columns. Left, the parts of the page: the header with who you are, the shop list and your role, the Ask form with the answer and evidence, the upload form with the upload status, and the documents table with its counts. Right, the API call behind each part: GET /auth/session and GET /v1/me on load; the memberships in GET /v1/me; POST /v1/tenants/{t}/answers on Ask; POST /v1/tenants/{t}/documents on Upload; GET /v1/tenants/{t}/documents on load, after an upload, and every 1.5 seconds while a document is indexing. A box below: every call goes through one function, api(), which returns the JSON or an error with its message, code and request ID.
Each part of the page and the one API call behind it. Every call goes through the same function.
Text description of the diagram

The header calls GET /auth/session and GET /v1/me when the page loads, and shows who you act as. The shop list comes from the memberships in /v1/me. Ask sends POST /v1/tenants/{t}/answers. Upload sends POST /v1/tenants/{t}/documents. The documents table calls GET /v1/tenants/{t}/documents on load, after an upload, and every 1.5 seconds while a document is indexing. Every call goes through api(), which returns the JSON, or an error with its message, code and request ID.

Every request of the page goes through one function, api(). It turns each failure into one kind of error object, ApiProblem, with the code, message and request_id of the backend’s one error shape:

async function api(path, options = {}) {
const headers = { Accept: 'application/json', ...(options.headers || {}) };
if (options.method && options.method !== 'GET' && state.csrf) headers['X-CSRF-Token'] = state.csrf;
let response;
try {
response = await fetch(path, { credentials: 'same-origin', ...options, headers });
} catch {
throw new ApiProblem(0, { code: 'network', message: 'The assistant cannot be reached. Is the API running?' });
}
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new ApiProblem(response.status, body);
return body;
}

The catch around fetch is the second case of the problem above. When the API does not answer at all, fetch fails before there is any response. Now the page says so in words. The CSRF line does nothing yet: Module 4 adds the token.

You read a letter aloud to a friend. The letter says: “Give the reader 100 dollars.” You read the sentence, and you do not give anyone money. The letter is something you read, not an order you follow.

The page must treat the server’s text in the same way. A document’s title comes from its file, and anyone who may upload chooses it. Suppose a title is <img src=x onerror=alert(1)>. A page that puts the title on the page as HTML (innerHTML) makes the browser run the code in it, inside Grace’s page. This attack is cross-site scripting. The same is true of an answer: a model can repeat text from a passage, and Bramble’s Northwind note already hides an instruction.

app.js puts every text from the server on the page with textContent, through el(). The browser then shows the characters <img src=x onerror=alert(1)> as text, and runs nothing. A test keeps it so: test_server_text_is_never_put_on_the_page_as_html fails if innerHTML or insertAdjacentHTML appears in app.js.

The page uses plain HTML for what HTML does well. Each field has a <label>, so a screen reader names it and a click on the label selects the field. The status lines have role="status" and aria-live="polite", so a screen reader reads “Searching the documents…” and the answer when they appear. The table has a caption, the focus has a visible outline, and the colours follow the system’s light or dark theme. Below 40rem, the table hides its “In force” column, so the page fits a phone. Without JavaScript, the page says that it needs JavaScript, and that the API works without it.

Grace’s journey, with the stand-ins of the last lesson. Every picture is a real capture of the running page. The .docx file and the cut PDF are made up for the test.

  1. With the API running, open http://127.0.0.1:8000/app/ in your browser. Sam acts in Larkfield, the shop of their membership.

    The page at 1280 pixels wide. The header says Document assistant, Practice mode: you act as Sam. Below: the shop list with Larkfield and Your role: staff; the Ask a question form with a question box, a date field and an Ask button; the Documents section with a file chooser, three choices for who may read it, an Upload button, the line 37 documents (37 indexed), and the top of the documents table.
    The page as it loads, with the stand-ins: practice mode, the shop and the role, the Ask form, the upload form and the documents table.
  2. Upload holiday-hours.md again. The API answers 409 conflict, and the page shows the message, the code and the request ID. Nothing is added.

    The Documents section after an upload of holiday-hours.md. Under the Upload button, a red box: Version 1 of holiday-hours already exists. Upload it with a new version number, or delete the old one first. (code conflict, request 7d5a885962ad44af). Below, 37 documents (37 indexed).
    The error state of the upload: the API's message, then its code and request ID.
  3. Make a PDF that is cut in the middle, as if the upload had stopped. It is made up for this test.

    Terminal window
    printf '%%PDF-1.7\n1 0 obj << /Type /Catalog >> endobj\ncut during the upload' > warranty-cut.pdf

    Upload warranty-cut.pdf on the page. The API accepts it (202), because a cut PDF passes the checks at the door. The page says that indexing starts, and the counts above the table show one document uploaded.

    The Documents section after the upload of a cut PDF. A green line: Uploaded Warranty cut. Indexing starts now; the table shows its status. Below: 38 documents (37 indexed, 1 uploaded).
    Accepted, not yet indexed: the upload status and the counts.
  4. Wait a moment, without a reload. The table changes by itself: the worker could not read the file, and the row says why.

    One row of the documents table: Warranty cut, warranty-cut.pdf, staff, version 1, in force from 2026-10-11, status failed in red, with the reason: This file is not a readable PDF (PdfStreamError).
    The failed row, with the worker's reason. Polling stopped, because no document is indexing any more.
  5. Ask “Is the help desk open on 26 December?”.

    The Ask a question form with the question Is the help desk open on 26 December? Below, the answer box: On 24 December and 31 December, the help desk is open from 9 am to 2 pm. Under Evidence, one claim with the citation Help desk holiday hours, Short days, version 1. The last line: mock: mock, 4 ms, and a trace ID.
    An answer from the mock: the right shape, a real citation, and the label mock. It copied the “Short days” passage, so it does not answer the question about 26 December.

Common mistake

The API stopped while the page was open

Symptom: you ask a question, and the page says “The assistant cannot be reached. Is the API running?”.

The Ask a question form with a question, and under the Ask button a red box: The assistant cannot be reached. Is the API running?
Sam stopped the API, then asked: the error state for no connection.

Cause: the API process stopped, for example after an error at start-up, or because you pressed Ctrl+C in its terminal. fetch gets no response at all.

Fix: start the API again with fastapi dev doc_assistant/main.py, then ask again. The page keeps the question, so you do not type it twice.

Common mistake

The page says 'Loading…' and nothing else

Symptom: the header says “Loading…”, and the forms do nothing.

The page with JavaScript turned off. The header says Loading…. A red box below it: This page needs JavaScript. The API works without it: see /docs. The forms are shown, and the documents table is empty.
The same page with JavaScript turned off in the browser.

Cause: JavaScript is off in your browser, or app.js did not load. With JavaScript off, the page says so in a red line. If app.js is missing, look at the browser’s developer tools (F12), tab Console or Network: app.js has status 404.

Fix: turn JavaScript on for 127.0.0.1, or check that doc_assistant/static/app.js exists. You can still use the API without the page: http://127.0.0.1:8000/docs lists every endpoint.

Common mistake

You changed app.js, and the page did not change

Symptom: you edit app.js or app.css, reload, and see the old behaviour.

Cause: the browser kept the old file in its cache. The API sends Cache-Control: no-store for its data under /v1/, so data is never cached, but the page’s files may be.

Fix: reload without the cache: Ctrl+Shift+R (Windows, Linux) or Cmd+Shift+R (macOS).

Grace checks the page on her phone, in the dark theme. Nothing in the code is special for it: the CSS has one rule for narrow screens and one set of colours for the dark theme.

The page on a narrow phone screen in the dark theme: dark background, light text, a light blue Ask button. The header, the shop and role, the question Is the help desk open on 26 December?, and the start of the mock answer with its evidence.
The same answer on a 390-pixel-wide screen, in the dark theme.
  • Standard track. Use the page for the whole journey: upload a document of your own, watch its status, ask two questions, and cause one error on purpose. Then try the page with the keyboard only (Tab, Shift+Tab, Enter, Space). You must reach and use every control.
  • Own-idea track. Build the page of your one journey. Keep the four rules of this lesson: every action has its waiting, done, empty and error states; one function makes every call and handles the one error shape; every text from the server goes on the page as text, never as HTML; every field has a label. One HTML file and fetch are enough.

Evidence to keep. Screenshots of your page in each state: loaded, waiting or indexing, done, empty (“no answer”), error, and the phone width. Save them in docs/evidence/m03-page/, with one line each in docs/evidence/README.md that says what the picture shows. Your demo script in Module 7 uses them.

Checklist

  • Every action shows a waiting state, and its button is off while it waits.
  • An upload’s status changes on the page without a reload, and the page stops asking when nothing is indexing.
  • Every error shows the message, the code and the request ID, also when the API cannot be reached.
  • No innerHTML with text from the server.
  • Every control works with the keyboard, and every field has a label.
  • The page fits a phone screen.

Check your understanding

Grace clicks Upload twice because the page shows nothing after the first click. Name the two states that the page must show to stop this, and what the code does in each.

Show the answer

The waiting state: as soon as she clicks, the line says “Uploading…” and the button is off (button.disabled = true), so a second click sends nothing. The done state: when the API answers 202, the line says that the upload worked and indexing starts, and the table shows the new document with its status. If the API refuses the file, the error state shows the message, the code and the request ID instead.

Check your understanding

Predict: a member uploads a document whose title is <b>Returns</b>. What does the documents table show, and why?

Show the answer

The table shows the characters <b>Returns</b>, with the angle brackets, not the word Returns in bold. app.js puts every text from the server on the page with textContent, so the browser shows text and never reads it as HTML. That is what stops a title such as <img src=x onerror=…> from running code in another member’s page.

Check your understanding

Why does the page ask for the documents again every 1.5 seconds only while a document is uploaded or parsing, and not all the time?

Show the answer

Polling exists to show a change that the page cannot see by itself: a document moving from uploaded to indexed or failed. When every document is indexed or failed, nothing can change without a new upload, so more requests would only cost the API work. The page asks again after each upload, which starts the polling again when it is needed.

Recap

  • Every action has visible states: waiting, done, empty and error. A page that shows only the happy result makes people click again.
  • The page is three plain files, served by the API at /app/: the same address, no CORS settings, no build step.
  • One function, api(), makes every call and turns every failure into the message, the code and the request ID, also when the API cannot be reached.
  • Polling shows the indexing status: every 1.5 seconds, only while a document is uploaded or parsing.
  • Text from the server goes on the page with textContent, never as HTML. A test checks it.

Next, the stand-ins go: real retrieval, recorded answers of a real model, citations checked in code, and a trace of each question.

Next: Integrate retrieval