Skip to content

Panel

components/App.tsx is the one panel. entrypoints/sidepanel/ and entrypoints/tab/ both show it. There is no second panel with different code. The layout changes with container queries: the panel is the container app, and the one point of change is --container-wide (45 rem) in components/globals.css. Thus the same components make a panel with one column at 360 px and a panel with two columns in a browser tab.

  • The panel shows a list of memberships, filters for each media type (components/Filters.tsx), and a list of posts (components/PostList.tsx). The list of posts uses @tanstack/react-virtual and shows only the posts on the screen. Attachments are a table that the user can sort (components/media.tsx).
  • Two views of one creator. A segmented control in the heading of the detail pane changes between the posts and the photos. The photo view (components/PhotoGallery.tsx) shows each cached image of the creator in a grid of squares, newest first, with one section for each month. The view hides the search field and the filter chips, because it always shows all the images that the cache holds. The panel keeps the selected view with its other state.
    • The join. A media record has no date; the date belongs to the post. Thus lib/gallery.ts joins the two tables in the panel. buildGallery() makes the list of images with the date and the title of the post, and groupByMonth() cuts that list into sections. The two functions are pure, and tests/gallery.test.ts examines them without a browser.
    • The grid measures itself. A ResizeObserver gives the width of the grid, and the panel calculates two to six columns and the size of one square tile from it. @tanstack/react-virtual shows only the rows on the screen, as the list of posts does.
    • The name of the month floats over the top of the grid and changes with the scroll. A position: sticky element cannot do this, because the virtualizer puts each row at an absolute position.
    • An image of a post that no answer ever opened is not in the grid. The membership cannot open that post, and the feed shows a lock notice in its place. An image that the cache already holds stays in the grid, also when the newest answer locks its post. A browser that is signed out of Patreon receives each post as locked. That answer describes the session and not the post (see the data layer).
    • A tile shows the thumbnail that Patreon gives. It reads the cache for media bytes (see media, CDN, and CSP) only if the media record has no thumbnail. Thus a long scroll does not pull the full images from the CDN.
    • The image viewer opens with the full gallery. Thus each control of the viewer moves through all the photos of the creator, from the newest to the oldest.
    • The end of the grid asks for the next page of posts, as the feed does. The photo view sends no request of its own, and it reads the same page-by-page function (see request limits).
  • The image viewer (components/media.tsx). The viewer fills the surface and paints its own dark chrome, thus it takes DialogPopup and not DialogContent (components/ui/dialog.tsx). The dialog holds the focus, locks the scroll of the panel behind it, and closes with Escape. The rest is the code of Gopher, because paging and zooming are not behaviours of a dialog.
    • Five mechanisms change the picture: the arrow keys with Home and End, the two arrows over the picture, and a swipe of the pointer. Two more: the strip of thumbnails below the picture, and the caller, which gives the place to open at.

      The strip holds 40 pictures on each side of the open one. It is not virtualized. A creator can have some thousands of photos, so a window is the bound.

    • Zoom. The wheel, +, -, 0, the three controls in the bar, and a double press all change one record: the zoom and the pan. A zoom holds the point below the pointer in its place, and a drag then moves the picture inside its own bounds. Each new picture starts at the size that fits the space.

    • The picture beside the open one is read in advance. It uses the same hook and the same cache for bytes, thus the next press shows a picture and not a spinner.

    • The bar carries the count, the name of the post, and the zoom. The line below the picture carries the date, the size in pixels, and the file name. Each control is an icon, with a name for a reader of the screen. The viewer must also operate in a side panel of 360 px.

    • The listener for the keyboard is in the capture phase. The dialog stops a key event before it reaches window on the way back up. A listener that waits for the way back up receives no arrow key at all.

    • An arrow does not find its centre with a transform. An Aqua button moves itself with active:translate-y-px while a user presses it, and one element has one translation. With top-1/2 -translate-y-1/2 the arrow moved 19 px down at the press, and the press then landed on the surface behind it. The arrow uses inset-y-0 my-auto.

    • A control on dark chrome removes the gel by name. An Aqua button paints a background image, a gloss, a shadow, and a white text shadow. A background colour does not remove a background image, thus VIEWER_CONTROL removes each part. Before that, the viewer showed white words on white controls.

  • Keyboard. The list of posts is an ARIA feed. One row at a time is in the tab sequence. This is necessary because the list removes the rows that are not on the screen, and that action can remove the row with the focus. The arrow keys move the focus and first move the target row onto the screen. The photo view is an ARIA grid and moves the same tab stop over the tiles. There the arrow keys move in two dimensions, and Enter opens the image viewer. The / key moves the focus to the search field. The Escape key closes the image viewer, or goes back to the list of memberships. In the image viewer the arrow keys and Home and End change the picture, and +, -, and 0 change the zoom. Ctrl+Shift+Y opens the panel. The panel has its own search field because Ctrl+F finds only the rows on the screen.
  • The footer of the membership list shows the size of the cache (see options and lifecycle). It reads the cache live. Each page of posts enlarges the cache, and a count that only changes when the panel opens again would be wrong. The commands that act on the cache live in the settings dialog, and not in this footer. Those commands are the switch for the byte cache and the purge.
  • The panel keeps its state in browser.storage.local (usePersistentState in components/hooks.ts). The selected membership, the view (posts or photos), the filters, and the search text stay after the panel closes. Chrome deletes the panel document each time the panel closes. The scroll position, the image viewer, and the video position do not stay.
  • The panel has text for each of the eleven failure codes. components/StatusBanner.tsx holds one table that gives the action that repairs each condition. The words are in the catalogs, under status.{code}.title and status.{code}.detail. The table has the key of the code, thus the compiler fails if a new failure code has no text (see localization). SyncStatus also carries variant: error paints the red box, and info paints the neutral one. A correct answer with no memberships is therefore not a failure. The banner reads updatedAt and shows nothing after STATUS_STALE_MS (ten minutes, lib/messages.ts). The status is in the Dexie meta table, and a status from a past session must not look like a live failure.
  • The panel is in English and German (see localization). Each component reads its text with useI18n(). The control for the language is in the header (components/LanguagePicker.tsx). The panel has no options page, and a user with an empty cache must be able to reach it. Dates, money, sizes, and the sequence of the membership list all obey the same locale (lib/format.ts). The panel is therefore never half translated.
  • shadcn/ui and Tailwind CSS 4 (ADR-017). Every part of the panel is a shadcn/ui component with Tailwind utilities. The components are in components/ui/, and they belong to Gopher. They come from the Aqua registry (ADR-018): components.json gives the namespace @aqua the address https://aqua.michi.onl/r/{name}.json. npx shadcn@latest add @aqua/<name> writes the file. The style field (base-nova) now applies only to an item from the @shadcn registry. There is no stylesheet that we wrote for the panel. The one exception is the rich-text utility in components/globals.css. The body of a post is HTML that Patreon wrote, and its rules must select elements and not classes. components/ui/dialog.tsx also carries one export that the registry does not have. DialogPopup is the popup with the portal, the backdrop, and the focus trap, and with no card. A later npx shadcn@latest add @aqua/dialog removes it, so write it again.
  • Colours and the face (ADR-018). components/globals.css carries the Aqua theme: npx shadcn@latest add @aqua/theme writes the palette, the face (Lucida Grande), and the --aqua-* variables that make the gel of a control. An Aqua component reads no token from that file; it paints its own colours. Thus the tokens (--primary, --muted, and the rest) reach only the parts of the panel that Gopher wrote.
    • The theme is light only. Each Aqua component paints light chrome, and a dark palette under that chrome gives light text on a white card. Thus color-scheme is light, the panel does not follow the browser, and there is no dark variant. This is a change: before ADR-018 the panel had a dark palette under prefers-color-scheme.

    • Three values are not the value that the registry gives. Gopher keeps WCAG 2.1 level AA. That is 4.5:1 for body text, and 3:1 for large text, for the border of a control, and for the focus mark. The panel puts those three tokens on text or on a focus mark:

      --primary (a link), --muted-foreground (a date, a count), and --ring (the focus mark of an element that is not a component). Each new value is a colour that Aqua itself uses, and the comment on the line names the change and gives the measured ratio.

    • --warning is gone. The Aqua Alert carries its own colours for the warning variant, which the pace limit of Patreon uses.

    • The focus mark is always visible. forced-colors gets an outline over the ring of a component, because a ring is a shadow and a forced-colours mode removes a shadow. The panel obeys prefers-reduced-motion.

  • Text in a components/ui/ file. A file from the registry can contain English words. The Aqua Loader did: it carries aria-label="Loading". Gopher removed it, because no component may contain text (see localization). The element that holds the loader carries the localized word and the role. Read a registry file before you add it.
  • Accessibility of media. The panel puts alt_text on each image and shows the is_nsfw flag on the membership and on the post. /api/clip_captions/{id} (WCAG 1.2.2) is not in the code yet.
  • Text of a post (components/RichText.tsx). The content field is HTML and not markdown. DOMParser reads it into a document that cannot execute code. The component then makes React elements from a list of permitted elements. It changes an unknown element into its text, it removes an unknown attribute, and it permits only https: in href and src. The code never uses dangerouslySetInnerHTML.

Viewer for each media type

TypeViewerNotes
ImagesGrid of images, image viewerSigned CDN URLs. A tile that fails renews (see media). The viewer pages, zooms, and shows a strip.
Audio<audio> elementFrom the audio relationship.
Textcomponents/RichText.tsxFrom the content attribute of the post.
FilesTable that the user can sortName, type, and size. Gopher shows the data but does not download the file.
Video from Patreonhls.jsOnly for an .m3u8 manifest that the browser cannot play. Play only.
Video from other serverOpens in a browser tabThe player opens the URL of the provider. It is not framed. Such a post has no <video> element (see the data layer).

Gopher sends no referrer with a media request. This prevents HTTP 403 from the CDN. The <img> element has the referrerpolicy attribute. The <audio> and <video> elements cannot have it, thus both panel documents declare <meta name="referrer" content="no-referrer">. This tag applies to all requests of the document.