{"id":"0Zqq-t8Zqbf-oBAZz-a5A","title":"snippets.ix0.io UI/UX review (2026-09-16)","language":"markdown","tags":["ux-review"],"content":"# snippets.ix0.io: UI and UX review\n\nReviewed 2026-09-16, 19:42 to 20:15 UTC. Playwright 1.55's bundled Chromium and the installed Google Chrome stable, at 1440x900 and 390x844 (iPhone 12 size, touch), light and dark themes, plus axe-core 4.11 on every page. Test snippets were created with a patched `Origin` header (see finding 1 for why that was necessary) and deleted through the API afterwards. Scripts and the raw dumps live in `snippets-ux-review/`; the screenshots referenced below are in `snippets-ux-review/evidence/`.\n\n## Verdict\n\nIt looks like a competent pastebin. GitHub Primer colours, tidy cards, working dark mode, a real API with an OpenAPI document. Then you try to use it.\n\nThe create form does not work in Chrome. Syntax highlighting is switched off by the site's own Content Security Policy. Burn-after-reading burns on the creator's own redirect, so the link you share is already dead. Password-protected snippets never show the creator their edit token. The edit token itself is a secret the app makes you copy and then gives you nowhere to use. And Browse cards with tags come apart because the template nests an anchor inside an anchor.\n\nNone of that is visible in a screenshot. It is a nice-looking app that fails at the four things it advertises: paste, share, expire, protect. Fix the blockers below before touching a single colour.\n\n## Blockers\n\n### 1. The create form fails in Chrome with a CSRF error\n\nEvery form submission from Chromium and from Google Chrome stable ends on an error page:\n\n```\nError: CSRF check failed: missing Origin header\n```\n\nThe cause is in the response headers. The app sends two `Referrer-Policy` headers:\n\n```\nreferrer-policy: no-referrer-when-downgrade\nreferrer-policy: no-referrer\n```\n\nThe last one wins. Under `no-referrer`, browsers send `Origin: null` on same-origin POSTs. Both Chromium builds did exactly that:\n\n| browser | Origin header sent on POST /snippets | result |\n|---|---|---|\n| Playwright bundled Chromium | `null` | 403 CSRF error page |\n| Google Chrome stable (installed) | `null` | 403 CSRF error page |\n| curl, `Origin: null` | `null` | 403 `{\"error\":\"CSRF check failed: missing Origin header\"}` |\n| curl, `Origin: https://snippets.ix0.io` | correct | 201 |\n\nFirefox and WebKit were not available on this box, so I could not check them. The Fetch spec makes `Origin: null` the required behaviour under `no-referrer`, so expect Firefox to match.\n\nThe `no-referrer` value matches Helmet's default, and the rest of the header set (`x-dns-prefetch-control`, `x-download-options`, `origin-agent-cluster`) looks like Helmet too. The `no-referrer-when-downgrade` one comes from the proxy layer, since `Via: 1.1 Caddy` is in the response. Change the Helmet setting to `strict-origin-when-cross-origin` or `same-origin`, and remove the duplicate. `X-Content-Type-Options` and `Strict-Transport-Security` are also sent twice, same cause.\n\nThe error page also throws away everything the user typed. There is a \"Back to Create\" button and an empty form behind it.\n\nEvidence: `evidence/03-origin-null-csrf-error.png`, `snippets-ux-review/verify_origin.py`.\n\n### 2. Burn-after-reading burns on the creator's own view\n\nCreate a snippet with \"Burn after reading\" ticked. The app redirects you to the snippet, shows the content, and puts up a banner:\n\n> Burn after reading: This snippet will be permanently deleted after you view it. Copy the code now!\n\nThat view is the one read. A visitor opening the link afterwards gets \"Snippet Not Found\". The `DELETE` call during cleanup returned 404, so the snippet was already gone.\n\nThe banner is a confession. The feature cannot be used for sharing through the UI. The post-create redirect needs to skip the burn (a one-time cookie or a flag on the redirect), or the create flow needs to land on a \"here is your link\" page instead of the snippet.\n\nEvidence: `evidence/07-burn-creator-view.png`, `evidence/08-burn-visitor-first-view.png`.\n\n### 3. Password-protected snippets never show the creator the edit token\n\nAfter creating a password-protected snippet the app redirects the creator to the \"Password Required\" gate. The edit token banner (\"It will only be shown once\") is not rendered on that page, the token is not saved to localStorage, and after unlocking with the password the token is not shown either. The creator can never edit or delete the snippet.\n\nI could not delete my own password test snippet for this reason. It expires at 21:02 UTC on its own.\n\nEvidence: `evidence/09-password-creator-sees-gate.png`, `snippets-ux-review/shots4/pw-created.html` (no `edit-token` element).\n\n### 4. Syntax highlighting is dead under the real CSP\n\nShiki emits its token colours as inline `style` attributes. The CSP is `style-src 'nonce-…' 'self'`, which blocks inline style attributes. A single snippet page logs 44 CSP violations, and every token computes to the same colour in both themes:\n\n| page | theme | distinct token colours |\n|---|---|---|\n| snippet page | light | 1 (`rgb(36, 41, 47)`) |\n| snippet page | dark | 1 (`rgb(230, 237, 243)`) |\n| Browse previews | light | 1 |\n\nThe line numbers inherit the same colour as the code, so they blend in too. The stylesheet has a comment saying the site consumes `--shiki-light` and `--shiki-dark` variables per token. Those variables are set in the blocked attributes, so the comment describes something that never happens in production.\n\nOptions, in order of preference: run Shiki with a class-based output (its style-to-class transformer) and ship one generated stylesheet; or emit the per-token variables into a nonced `<style>` block; or add `'unsafe-hashes'` for the attribute hashes, which does not scale. Adding `'unsafe-inline'` works and gives up most of what the nonce buys.\n\nEvidence: `evidence/04-highlighting-with-csp.png` (what users see), `evidence/05-highlighting-csp-bypassed.png` (what was intended), `evidence/06-highlighting-dark-with-csp.png`.\n\n## Serious\n\n### 5. Browse cards with tags fall apart\n\nThe server renders each Browse card as `<a class=\"snippet-card\">` and puts the tag badges inside it as `<a class=\"badge badge-tag\">`. Nested anchors are invalid HTML. The parser closes the card at the first tag link, so the DOM ends up with the title in one box, a stray language badge in a second box, the tag pills floating loose, the date on its own line, and the code preview outside any link. With four snippets on the page the DOM held five `.snippet-card` elements, one with no title. Clicking the preview of a tagged snippet does nothing.\n\nEvidence: `evidence/10-browse-nested-anchors.png`, `snippets-ux-review/shots4/browse-raw-server.html`.\n\n### 6. The edit token has no home\n\nThe created page shouts:\n\n> Important: Save this edit token. It will only be shown once.\n\nThen it shows a 21-character secret in a dashed box with \"Copy Token\" as the only primary button on the page. There is no edit page. There is no delete button. My Snippets stores the token in localStorage without telling you and offers a list of links. The stylesheet still carries delete-modal and revision-history styles, so this existed or was planned.\n\nRight now the token is only usable with curl. Either build edit and delete into the snippet page (the token is already in localStorage, so the page can show the buttons to the creator), or stop presenting the token as the most important thing on the page.\n\n### 7. Server errors discard the paste, and the re-rendered form contradicts the original\n\nTwo different error paths:\n\n- CSRF and rate-limit errors render a generic error card with \"Back to Create\". The form contents are gone. About ten creates in two minutes hit the rate limit, and the visitor's plain GET of a snippet page got a 429 as well.\n- Validation errors re-render the form with values kept, which is good, but the labels change. \"Title\" and \"Language\" are marked required with a red asterisk on first load. On the re-render they read \"Title (optional)\" and \"Language (optional)\", the `required` attribute is gone, and the title placeholder becomes \"Untitled snippet\". The Fork page has the same labels. So the client insists on a title, the server does not, and the user sees both versions.\n\nThe validation message for an empty form is \"Content must be 1-1MB in size\". It does not mention that the empty title and language were accepted. The custom URL error says \"Alias must be 3-50 characters\" while the field is labelled \"Custom URL\".\n\nEvidence: `evidence/14-server-error-labels-optional.png`, `evidence/20-fork-page.png`.\n\n### 8. Ctrl+S and Ctrl+Enter skip validation\n\nBoth shortcuts call `form.submit()` directly, which bypasses the browser's `required` checks. On an empty form they post straight to the server and land on the \"Content must be 1-1MB in size\" error. Nothing on the page mentions that the shortcuts exist. Use `form.requestSubmit()` and add a hint next to the button.\n\n### 9. Language is free text, matched case-sensitively, with silent failure\n\nThe field is a text input backed by a datalist of 188 Shiki slugs such as `actionscript-3` and `angular-html`, no display names. The server accepts anything:\n\n| language entered | stored as | highlighted | line numbers | filter sidebar |\n|---|---|---|---|---|\n| `python` | python | yes | yes | python |\n| `Python` | Python | no | no | separate entry \"Python\" |\n| `klingon` | klingon | no | no | separate entry \"klingon\" |\n\nNo warning at any point. Lower-case the value on the server, validate against the Shiki list, and give the datalist real names.\n\nEvidence: `evidence/19-python-case-no-highlight.png`.\n\n### 10. Tags are dropped without a word\n\nEntering `React, Hooks!, ok-tag` stored `react` and `ok-tag`. `Hooks!` vanished. The helper text says \"Letters, numbers, and hyphens only\", so the server knows the rule and chose to swallow the violation rather than report it.\n\n### 11. Search reads the body of password-protected snippets\n\nBrowse hides the content of a locked snippet behind \"[Password protected]\", and both `/raw/{id}` and the JSON view return 401 without the password. Search does not get the memo. `/browse?q=secret` returned the locked snippet whose only occurrence of \"secret\" was inside its protected body. Anyone can confirm whether a word appears in a protected snippet, one query at a time. Exclude protected bodies from the search index.\n\n### 12. My Snippets\n\n- The \"Clear list\" button only appears in the empty state, where there is nothing to clear. With entries present the fetch result overwrites the container's HTML and removes the button.\n- Clicking it throws `ReferenceError: EMPTY_STATE is not defined` (the constant lives in a different closure). localStorage is cleared, the page does not change, and the error goes to the console.\n- If every stored id is stale (deleted or expired), the page says \"No matching snippets found\" and offers no way to clear them.\n- Dates render as raw ISO strings (`2026-09-16T19:55:30.955Z`) with no space after the language badge.\n- No edit, no delete, no expiry shown, even though the token that would allow it is in localStorage.\n\nEvidence: `evidence/12-my-snippets.png`, `evidence/13-my-snippets-empty-clear-list.png`.\n\n### 13. Expiry information is vague and once wrong\n\n| expiration chosen | snippet page badge | Browse badge |\n|---|---|---|\n| 10 minutes | \"Expires soon\" | \"soon\" |\n| 1 hour | \"Expires soon\" | \"soon\" |\n| never | nothing | nothing |\n\nNo actual time is shown anywhere. The JSON has `expires_at`, so the data is there.\n\nAt 19:43 UTC the one pre-existing snippet (`YLiqAwioCe_0YlPELGOJa`) was badged \"Expired\" on Browse and on its own page while the page, raw view and JSON all returned 200 with full content. By 19:57 the badge was gone and the JSON had no `expires_at`. Either the read path does not check expiry, or the badge fires on a snippet with no expiry, or the owner edited it in between. Check the badge logic and the read path. I could not tell which from outside.\n\nTimestamps show as \"Sep 16, 2026, 07:39 PM\". That is UTC with no zone label. Anyone outside UTC reads the wrong time.\n\nEvidence: `evidence/24-expired-badge-live-snippet.png`.\n\n## Design and copy\n\n### 14. The paste box is the last of nine fields\n\nOn the create page the code textarea sits at y=1199 on a 1440x900 desktop and y=1349 on a phone. Above it: Title (required), Language (required), Tags, Custom URL, Description, Expiration, Burn after reading, Password. A pastebin where the paste is below the fold, behind two required fields that the server itself treats as optional.\n\nPut the code box first. Make title optional in the UI, since the server already defaults it to \"Untitled snippet\". Make language optional or detected. Collapse the six sharing options under one disclosure.\n\nEvidence: `evidence/01-home-desktop.png`, `evidence/02-home-phone.png`.\n\n### 15. The snippet page has no link to itself\n\nSeven equal-weight secondary buttons: View Raw, Copy Code, Copy Raw, Download Snippet, QR Code, Fork Snippet, New Snippet. The snippet's URL appears exactly once on the page, inside the QR modal. There is no \"Copy link\". \"Copy Code\" and \"Copy Raw\" copy the identical string (their `data-content` attributes are equal).\n\nSuggested: Copy link (primary), Copy code, Raw, Download, then a menu for QR, fork and new.\n\n### 16. QR modal\n\n- The URL text overflows the dialog. On desktop the text ends 60px past the modal's right edge. On a phone it ends at 440px on a 390px screen, so the end of the URL is off-screen.\n- Escape does not close it.\n- Focus stays on the \"QR Code\" button. The dialog has `aria-modal=\"true\"` but no focus management, so a keyboard user has to tab past the whole page to reach Close.\n- Backdrop click closes it, which is fine.\n\nEvidence: `evidence/15-qr-modal-overflow.png`, `evidence/16-qr-modal-phone.png`.\n\n### 17. \"Copied!\" is dark text on dark green\n\nThe success state only swaps the background to `--color-success` and keeps the secondary button's dark text. Contrast is 2.88:1. White text fixes it.\n\nEvidence: `evidence/17-copied-contrast.png`.\n\n### 18. Title and tag badges fight for one row\n\nThe snippet header is a flex row with the title, language badge and tag pills. On desktop the badges squeeze the title to about 470px so a long title wraps to three lines, and the tag `ux-review` breaks inside its own pill as \"ux-\" / \"review\". On a phone every tag becomes its own line. Let the title take the full width and put the badges on the line below, with `white-space: nowrap` on pills.\n\nEvidence: `evidence/05-highlighting-csp-bypassed.png`, `evidence/18-snippet-phone.png`.\n\n### 19. Tags are links on Browse and plain spans on the snippet page\n\nPick one. Links, presumably.\n\n### 20. Fork copies everything and says nothing\n\n`/?fork=<id>` prefills title, language, tags, description and code. The title is copied verbatim, so the fork shows up in Browse as a duplicate. No \"Forked from\" notice, no link back, and the same \"(optional)\" label bug as finding 7.\n\n### 21. Browse on a phone is a wall of filters\n\nWith six languages and four tags the sidebar is 709px tall. The first result starts at y=963 on an 844px-tall viewport. Create button, search box, search button, tag list and language list all come before any content. Collapse the filters behind a button on narrow screens, and drop the full-width secondary \"Search\" button; Enter already submits.\n\nEvidence: `evidence/11-browse-phone.png`.\n\n### 22. Empty search says it three times\n\n\"0 results for \"zzzzqqqq\"\", then a removable chip \"Search: zzzzqqqq\", then \"No snippets found for \"zzzzqqqq\"\". One line and the chip would do.\n\nEvidence: `evidence/21-browse-no-results.png`.\n\n### 23. Search scope is undocumented\n\n| query type | matches |\n|---|---|\n| word in title | yes, case-insensitive |\n| word in body | yes |\n| tag name | no |\n| language | no |\n| word in description | no |\n\nNothing on the page says what search covers. `?page=2` is ignored and there is no pagination control, which is fine at four snippets and will not be at forty.\n\n### 24. Two different 404 pages\n\n`/anything` gives a red alert reading \"Error: Not Found\" with a \"Back to Create\" button. `/s/anything` gives \"Snippet Not Found\" with a sentence of explanation and a \"Create New Snippet\" button. The second one is the good one. Use it for both.\n\nEvidence: `evidence/22-404-generic.png`, `evidence/23-404-snippet.png`.\n\n### 25. Heading case is mixed\n\n\"Create New Snippet\", \"Browse Snippets\", \"My Snippets\", \"Password Required\" in title case. \"API reference\" in sentence case. Browse also jumps from the h1 site name to h3 sidebar headings.\n\n### 26. Small tap targets and a static theme toggle\n\n| control | size on phone |\n|---|---|\n| nav links | 27 to 29px tall |\n| theme toggle | 38x26px |\n| snippet action buttons | 31px tall |\n\nAll under the 44px guideline. The theme toggle is an emoji with `aria-label=\"Toggle theme\"` that never changes and no `aria-pressed`, so a screen reader cannot tell which theme is active.\n\n### 27. Description links\n\nLinks in the rendered description open a new tab without saying so, and are distinguished from surrounding text by colour alone (1.23:1 against the grey body text). Underline them.\n\n### 28. Contrast, from axe\n\n| element | theme | ratio |\n|---|---|---|\n| active filter, white on `#58a6ff` | dark | 2.52:1 |\n| \"Copied!\" button | light | 2.88:1 |\n| unhighlighted preview text `#899097` | light | 3.23:1 |\n| comment token `#6a737d` on `#24292e` | dark | 3.04:1 |\n| orange type token `#e36209` | light | 3.48:1 |\n\nThe last two are GitHub's own Shiki themes and affect nobody until finding 4 is fixed. The first two are yours.\n\n### 29. The home page does not say what the site is\n\nThe only description of the product is the footer: \"Snippets - Code and text sharing\". For a personal tool that is fine. One sentence above the form would still help someone who lands on a shared link and clicks the logo.\n\n## What is good\n\n- One consistent visual system across every page, both themes, no layout breaks at 390px, no horizontal scroll.\n- Server-side validation exists, and the re-render keeps the user's values.\n- Raw view, JSON view, download with a sensible extension map, OpenAPI document, QR code.\n- The password gate autofocuses, reports \"Invalid password\" inline, keeps the field, and both `/raw` and JSON return 401 without the cookie.\n- Native form validation on the primary path. No JavaScript needed to paste, view or copy.\n- Nonced CSP, HSTS, frame denial. The security posture is what broke finding 4, which is the right kind of problem to have.\n- No console errors beyond the CSP reports.\n\n## Fix order\n\n1. Referrer-Policy (finding 1). One header. Nothing else matters until the form posts.\n2. Burn on creator redirect (2) and the missing token for password snippets (3). Both are data-loss.\n3. Shiki output under CSP (4).\n4. Nested anchors on Browse (5).\n5. Search over protected bodies (11).\n6. Give the edit token a use, or demote it (6). Fold My Snippets fixes (12) into the same work.\n7. Form: code first, honest required markers, same labels on every render, `requestSubmit` for shortcuts (7, 8, 14).\n8. Language and tag validation (9, 10).\n9. Everything from 13 down.\n\n## How this was tested\n\n| script | what it does |\n|---|---|\n| `snippets-ux-review/capture1.py` | every page, 2 viewports x 2 themes, full-page screenshots, console and network capture |\n| `snippets-ux-review/verify_origin.py` | records the `Origin` header each browser sends on the create POST |\n| `snippets-ux-review/capture2.py` | first interaction pass, which is where the CSRF failure showed up |\n| `snippets-ux-review/capture3.py` | same pass with `Origin` patched via request interception: validation, create, copy, QR, download, fork, browse, My Snippets, burn, phone, axe with CSP bypassed, real-CSP colour check, cleanup |\n| `snippets-ux-review/capture4.py` | password flow, nested-anchor check against server HTML, search scope, language case, dark real-CSP |\n| `snippets-ux-review/capture5.py` | wrong and right password on the surviving test snippet, cookie, raw and JSON status, search leak |\n\nEvery test snippet was titled \"UX review … (safe to delete)\" and deleted through `DELETE /snippets/{id}` at the end of each run, except the password snippet `pgrV_4pQF4g72977k09FX` (see finding 3), which expires at 21:02 UTC.\n","created_at":"2026-09-16T20:51:48.203Z","description":"Browser review of this service. Evidence screenshots and scripts: https://gitlab.ixl0.net/carl/utils/-/tree/main/snippets-ux-review","custom_url":"snippets-ux-review"}