Editing a CV should show the CV: put the preview on the page, not in another tab #293

Closed
opened 2026-09-19 17:45:32 +00:00 by tiagoagueda · 0 comments
Owner

Editing a CV and seeing a CV are two tabs and a manual refresh. The preview should be on the page you are editing.

What the loop is today

templates/documents/cv_detail.html is where a CV is actually built: the entries in order, the arrows that move them, the link to edit one, the box that adds more. Every one of those is a plain form post that redirects back, and the page carries no htmx at all — hx- appears zero times in it.

The preview is a button at :16-17:

<a href="{% url 'documents:cv_preview' cv.pk %}" target="_blank" rel="noopener"
   class="btn" data-variant="outline">{% translate "Preview" %}…</a>

So the loop is: move an entry, wait for the page to reload, switch tab, reload that, look, switch back. Judging a document's shape — whether the summary is too long, whether a section broke badly, whether the thing fits a page — is the one task where the answer must be visible while the change is made, and it is the one place Postulo makes you leave the page to get it.

Everything needed already exists. CVPreviewView (documents/views.py:209) renders the CV exactly as the PDF renderer sees it, which is the property that makes an in-page preview worth having rather than approximate.

Why a frame, and the two traps in it

The preview is a whole document, not a fragment. templates/documents/themes/base_cv.html begins <!doctype html> and carries the theme's entire stylesheet inlined in a <style>, because the renderer must fetch nothing. It cannot be dropped into the editing page: its stylesheet would land on the application's own. A frame is the only vehicle that keeps two documents apart, and there is no <iframe> anywhere in Postulo today.

Trap one: the content security policy blocks frames outright. The policy sets default-src 'none' and names no frame-src or child-src (config/settings/base.py:582-600), so frames fall back to 'none' and even a same-origin <iframe src="…/preview/"> is refused. It fails silently — a blank box — and since #232 every browser test fails on a policy refusal, so this will be discovered as a red suite rather than a blank frame. The fix is deliberate: add frame-src 'self' and say in the comment beside it why a same-origin frame is now wanted. frame-ancestors 'none' is untouched and stays: it governs who may frame Postulo, not what Postulo may frame.

Trap two: it must be src, never srcdoc. The preview's <style> carries a per-request nonce (#232), and the header names that nonce on that response alone. A srcdoc frame inherits the parent document's policy, where the nonce does not match, so the preview would render unstyled — which is exactly the bug #232 just fixed in production. Fetching the real address keeps the response, its header and its nonce together.

The shape

The version that needs no JavaScript is the whole feature. Every edit on this page is already a full page load, so a frame whose src is the preview address re-renders on every save by itself. No htmx, no polling, no refresh button: move an entry, the page comes back, the preview beside it is current. That is the first commit, and it is most of the value.

What is worth deciding after that:

  • Where it sits. Side by side on a wide screen, the preview stacked under the entries when there is not room. #273 gave this page the full width to work with.
  • How it is scaled. A CV is A4 and the column is not. The frame needs an aspect ratio and a scale, and the scale cannot be an inline style — the policy forbids the attribute, which is why stored column widths already go through the CSSOM. So a class or a custom property set in the stylesheet, not markup.
  • Whether it follows an unsaved change. It cannot without script, and with script it means re-rendering on each keystroke. Out of scope for the first pass: the preview shows what is saved, which is what the PDF would be.
  • The letter has the same shape, and already has a preview form with an application chooser (letter_detail.html:41). It should get the same treatment, and whether that is this issue or its sibling is worth saying out loud rather than letting it drift.

Accessibility, which is not free here

  • A frame needs a title naming what is in it, or a screen reader announces an unlabelled frame.
  • The frame holds a second document, so it is its own reading context. The entries list stays the operable thing; the preview is there to be looked at. Whether it should be reachable in the tab order at all is a real decision — a scrollable frame is a legitimate stop under SC 2.1.1, and #275 made every scrollable box a named stop for exactly that reason.
  • It must not become a keyboard trap, which tests/e2e/test_keyboard_and_focus.py is the place to prove.
  • Reflow at 320 — tests/e2e/test_reflow.py walks this page in English, Greek and German. A fixed-width frame is precisely what that catches, and a scaled one must still not push the page sideways.
  • Target size, axe in both themes, the text-spacing override and 200% zoom all walk this page already.
  • Note tests/e2e/test_accessibility.py exempts /preview/ from axe only (AXE_EXEMPT_SUFFIXES), because what axe would be reading there is a print document. Once a preview is framed inside an application page, axe reads the outer page — so the exemption still holds for the frame's contents and does not excuse the page around it.
  • Print: printing the editing page should not print the frame. app.css's @media print block already hides the chrome; the frame needs a line there.

Also worth knowing

  • Every render is real work — render_cv_html builds the sections and runs the theme template under a language override. A preview beside every save is one extra render per save, which is cheap; a preview that re-renders per keystroke is not, and is the reason the first pass shows what is saved.
  • Related and deliberately not this: #257 (the application page reloads for every action) is the same complaint on another screen; #236 wants draft PDFs and version comparison for these documents; #258 guards unsaved text.

Done when

  • The CV page shows its preview beside the entries, current after every edit, with no JavaScript needed to make it so.
  • frame-src 'self' is in the policy with its reason written beside it, and tests/security/test_requests.py asserts it.
  • A browser test opens the page, edits an entry, and sees the preview change; another proves the frame is not a keyboard trap; the reflow, target-size and axe walks pass unchanged.
  • The frame is titled, and printing the editing page leaves it out.
Editing a CV and seeing a CV are two tabs and a manual refresh. The preview should be on the page you are editing. ## What the loop is today `templates/documents/cv_detail.html` is where a CV is actually built: the entries in order, the arrows that move them, the link to edit one, the box that adds more. Every one of those is a plain form post that redirects back, and **the page carries no htmx at all** — `hx-` appears zero times in it. The preview is a button at `:16-17`: ```html <a href="{% url 'documents:cv_preview' cv.pk %}" target="_blank" rel="noopener" class="btn" data-variant="outline">{% translate "Preview" %}…</a> ``` So the loop is: move an entry, wait for the page to reload, switch tab, reload *that*, look, switch back. Judging a document's shape — whether the summary is too long, whether a section broke badly, whether the thing fits a page — is the one task where the answer must be visible while the change is made, and it is the one place Postulo makes you leave the page to get it. Everything needed already exists. `CVPreviewView` (`documents/views.py:209`) renders the CV *exactly as the PDF renderer sees it*, which is the property that makes an in-page preview worth having rather than approximate. ## Why a frame, and the two traps in it **The preview is a whole document, not a fragment.** `templates/documents/themes/base_cv.html` begins `<!doctype html>` and carries the theme's entire stylesheet inlined in a `<style>`, because the renderer must fetch nothing. It cannot be dropped into the editing page: its stylesheet would land on the application's own. A frame is the only vehicle that keeps two documents apart, and there is **no `<iframe>` anywhere in Postulo today**. **Trap one: the content security policy blocks frames outright.** The policy sets `default-src 'none'` and names no `frame-src` or `child-src` (`config/settings/base.py:582-600`), so frames fall back to `'none'` and even a same-origin `<iframe src="…/preview/">` is refused. It fails silently — a blank box — and since #232 **every browser test fails on a policy refusal**, so this will be discovered as a red suite rather than a blank frame. The fix is deliberate: add `frame-src 'self'` and say in the comment beside it why a same-origin frame is now wanted. `frame-ancestors 'none'` is untouched and stays: it governs who may frame Postulo, not what Postulo may frame. **Trap two: it must be `src`, never `srcdoc`.** The preview's `<style>` carries a per-request nonce (#232), and the header names that nonce on that response alone. A `srcdoc` frame inherits the *parent* document's policy, where the nonce does not match, so the preview would render unstyled — which is exactly the bug #232 just fixed in production. Fetching the real address keeps the response, its header and its nonce together. ## The shape **The version that needs no JavaScript is the whole feature.** Every edit on this page is already a full page load, so a frame whose `src` is the preview address re-renders on every save by itself. No htmx, no polling, no refresh button: move an entry, the page comes back, the preview beside it is current. That is the first commit, and it is most of the value. What is worth deciding after that: - **Where it sits.** Side by side on a wide screen, the preview stacked under the entries when there is not room. #273 gave this page the full width to work with. - **How it is scaled.** A CV is A4 and the column is not. The frame needs an aspect ratio and a scale, and **the scale cannot be an inline `style`** — the policy forbids the attribute, which is why stored column widths already go through the CSSOM. So a class or a custom property set in the stylesheet, not markup. - **Whether it follows an unsaved change.** It cannot without script, and with script it means re-rendering on each keystroke. Out of scope for the first pass: the preview shows what is saved, which is what the PDF would be. - **The letter has the same shape**, and already has a preview form with an application chooser (`letter_detail.html:41`). It should get the same treatment, and whether that is this issue or its sibling is worth saying out loud rather than letting it drift. ## Accessibility, which is not free here - A frame needs a **`title`** naming what is in it, or a screen reader announces an unlabelled frame. - The frame holds a **second document**, so it is its own reading context. The entries list stays the operable thing; the preview is there to be looked at. Whether it should be reachable in the tab order at all is a real decision — a scrollable frame is a legitimate stop under SC 2.1.1, and #275 made every scrollable box a named stop for exactly that reason. - **It must not become a keyboard trap**, which `tests/e2e/test_keyboard_and_focus.py` is the place to prove. - **Reflow at 320** — `tests/e2e/test_reflow.py` walks this page in English, Greek and German. A fixed-width frame is precisely what that catches, and a scaled one must still not push the page sideways. - **Target size, axe in both themes, the text-spacing override and 200% zoom** all walk this page already. - Note `tests/e2e/test_accessibility.py` exempts `/preview/` **from axe only** (`AXE_EXEMPT_SUFFIXES`), because what axe would be reading there is a print document. Once a preview is framed *inside* an application page, axe reads the outer page — so the exemption still holds for the frame's contents and does not excuse the page around it. - **Print**: printing the editing page should not print the frame. `app.css`'s `@media print` block already hides the chrome; the frame needs a line there. ## Also worth knowing - Every render is real work — `render_cv_html` builds the sections and runs the theme template under a language override. A preview beside every save is one extra render per save, which is cheap; a preview that re-renders per keystroke is not, and is the reason the first pass shows what is saved. - Related and deliberately not this: **#257** (the application page reloads for every action) is the same complaint on another screen; **#236** wants draft PDFs and version comparison for these documents; **#258** guards unsaved text. ## Done when - The CV page shows its preview beside the entries, current after every edit, with no JavaScript needed to make it so. - `frame-src 'self'` is in the policy with its reason written beside it, and `tests/security/test_requests.py` asserts it. - A browser test opens the page, edits an entry, and sees the preview change; another proves the frame is not a keyboard trap; the reflow, target-size and axe walks pass unchanged. - The frame is titled, and printing the editing page leaves it out.
tiagoagueda added this to the 0.5.0 milestone 2026-09-19 17:45:32 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Postulo/postulo#293
No description provided.