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-virtualand 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.tsjoins the two tables in the panel.buildGallery()makes the list of images with the date and the title of the post, andgroupByMonth()cuts that list into sections. The two functions are pure, andtests/gallery.test.tsexamines them without a browser. - The grid measures itself. A
ResizeObservergives the width of the grid, and the panel calculates two to six columns and the size of one square tile from it.@tanstack/react-virtualshows 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: stickyelement 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 join. A media record has no date; the date belongs to the post. Thus
- The image viewer (
components/media.tsx). The viewer fills the surface and paints its own dark chrome, thus it takesDialogPopupand notDialogContent(components/ui/dialog.tsx). The dialog holds the focus, locks the scroll of the panel behind it, and closes withEscape. 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
HomeandEnd, 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
windowon 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-pxwhile a user presses it, and one element has one translation. Withtop-1/2 -translate-y-1/2the arrow moved 19 px down at the press, and the press then landed on the surface behind it. The arrow usesinset-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_CONTROLremoves 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 ARIAgridand moves the same tab stop over the tiles. There the arrow keys move in two dimensions, andEnteropens the image viewer. The/key moves the focus to the search field. TheEscapekey closes the image viewer, or goes back to the list of memberships. In the image viewer the arrow keys andHomeandEndchange the picture, and+,-, and0change the zoom.Ctrl+Shift+Yopens the panel. The panel has its own search field becauseCtrl+Ffinds 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(usePersistentStateincomponents/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.tsxholds one table that gives the action that repairs each condition. The words are in the catalogs, understatus.{code}.titleandstatus.{code}.detail. The table has the key of the code, thus the compiler fails if a new failure code has no text (see localization).SyncStatusalso carriesvariant:errorpaints the red box, andinfopaints the neutral one. A correct answer with no memberships is therefore not a failure. The banner readsupdatedAtand shows nothing afterSTATUS_STALE_MS(ten minutes,lib/messages.ts). The status is in the Dexiemetatable, 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/uiand Tailwind CSS 4 (ADR-017). Every part of the panel is ashadcn/uicomponent with Tailwind utilities. The components are incomponents/ui/, and they belong to Gopher. They come from the Aqua registry (ADR-018):components.jsongives the namespace@aquathe addresshttps://aqua.michi.onl/r/{name}.json.npx shadcn@latest add @aqua/<name>writes the file. Thestylefield (base-nova) now applies only to an item from the@shadcnregistry. There is no stylesheet that we wrote for the panel. The one exception is therich-textutility incomponents/globals.css. The body of a post is HTML that Patreon wrote, and its rules must select elements and not classes.components/ui/dialog.tsxalso carries one export that the registry does not have.DialogPopupis the popup with the portal, the backdrop, and the focus trap, and with no card. A laternpx shadcn@latest add @aqua/dialogremoves it, so write it again.- Colours and the face (ADR-018).
components/globals.csscarries the Aqua theme:npx shadcn@latest add @aqua/themewrites 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-schemeislight, the panel does not follow the browser, and there is nodarkvariant. This is a change: before ADR-018 the panel had a dark palette underprefers-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. -
--warningis gone. The AquaAlertcarries its own colours for thewarningvariant, which the pace limit of Patreon uses. -
The focus mark is always visible.
forced-colorsgets an outline over the ring of a component, because a ring is a shadow and a forced-colours mode removes a shadow. The panel obeysprefers-reduced-motion.
-
- Text in a
components/ui/file. A file from the registry can contain English words. The AquaLoaderdid: it carriesaria-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_texton each image and shows theis_nsfwflag 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). Thecontentfield is HTML and not markdown.DOMParserreads 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 onlyhttps:inhrefandsrc. The code never usesdangerouslySetInnerHTML.
Viewer for each media type
| Type | Viewer | Notes |
|---|---|---|
| Images | Grid of images, image viewer | Signed CDN URLs. A tile that fails renews (see media). The viewer pages, zooms, and shows a strip. |
| Audio | <audio> element | From the audio relationship. |
| Text | components/RichText.tsx | From the content attribute of the post. |
| Files | Table that the user can sort | Name, type, and size. Gopher shows the data but does not download the file. |
| Video from Patreon | hls.js | Only for an .m3u8 manifest that the browser cannot play. Play only. |
| Video from other server | Opens in a browser tab | The 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.