# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## What this is decePubClient is a Blazor WebAssembly PWA (.NET 10). It is meant as a Pleroma-FE-like client for **PrivaPub**, the owner's ActivityPub server, whose repo is `thepra/SocialPub`. It is deployed as a static site at https://decepub.thepra.dev, and its API base address is `https://privapub.thepra.dev` (`AppConfiguration:ApiBaseAddress` in `wwwroot/appsettings.json`). It talks to PrivaPub through both of its APIs with one sign-in (see **PrivaPub wiring** below). The feed page has three columns: navigation, the feed with a tab per kind of feed, and a 3D globe that follows the posts in view. Nerd stats sit in tooltips wherever there is data. Development builds point at a local PrivaPub on `http://127.0.0.1:6970` (`wwwroot/appsettings.Development.json`, never published). `Docs/Insomnia_APIs.json` is an old API collection for a localhost backend. Don't treat it as PrivaPub's API. ## Sibling repo dependency The project references `../SocialPub/PrivaPub.ClientModels/PrivaPub.ClientModels.csproj`, so SocialPub must be checked out next to this repo. CI clones it there. That project supplies: - the shared DTOs; - `Policies` (IsAdmin, IsUser, IsModerator); - the `FieldsNameResource` and `ErrorsResource` localization resources. Before wiring anything to the API, read `../SocialPub/CLAUDE.md`. PrivaPub's route names (`/peasants/...`, `/mouth`, `/anus`, ...) are deliberate. Never "correct" them. The API surface is the Mastodon client API plus PrivaPub's private `/clientapi` and `/api/privapub/v1`. To run against a local PrivaPub: start a mongod, run SocialPub's `PrivaPub` with the Development command in its CLAUDE.md on `127.0.0.1:6970` (`Registrations__Mode=Open` lets `/clientapi/user/signup` create a root), then create personas with `/clientapi/avatar/private/insert`. Federated posts come from SocialPub's pasture (`tools/pasture/run.sh up gts akkoma`, then `tools/pasture/interop.sh gts akkoma`), whose API is `127.0.0.1:6971`. Neither DB-IP nor PrivaPub locate private addresses, so for the globe set `RemoteInstance.Geo` in mongo and `Statistics:Geo:Self` in PrivaPub's configuration. ## Commands ```sh dotnet build decePubClient.csproj # what CI runs (-c Release) on every push to master and on PRs dotnet run # dev server: https://localhost:7046 / http://localhost:5046 dotnet publish decePubClient.csproj -c Release -o publish # static site lands in publish/wwwroot ``` `global.json` pins SDK 10.0.100 with `rollForward: latestFeature`. There is no linter. ## Testing: end to end against the pasture `tests/e2e` (Playwright for .NET 1.61.0 on xunit v3) runs the published app in Chromium against SocialPub's pasture: a private fediverse of PrivaPub and its peers (GoToSocial, Mastodon, Misskey, Sharkey, Akkoma, Lemmy) seeded with a fake community, the town (see `../SocialPub/CLAUDE.md`, "The town"). Never in CI: it needs podman and the pasture. ```sh ../SocialPub/tools/pasture/run.sh up gts mastodon misskey sharkey akkoma lemmy # PASTURE_PORT if 6971 is taken ../SocialPub/tools/pasture/town.sh seed village tests/e2e/run.sh [--filter ...] # publishes with WasmApplicationEnvironmentName=Pasture, serves https://decepub.test, runs ``` - **The app under test is the deploy's own build** with the environment baked in: Blazor on .NET 10 ignores the `Blazor-Environment` header, so `wwwroot/appsettings.Pasture.json` (API `https://privapub.test`) is chosen at publish time and published only then; the deploy fails if it ever ships. - Chromium resolves `*.test` to Caddy on `127.0.0.1:6443` (`--host-resolver-rules`) and ignores the pasture CA. The tests use the Chromium headless shell 1228 cached in `~/.cache/ms-playwright` and never install a browser. - **Guards on every test** (`Support/Browser.cs`): the app talks only to its own site and to PrivaPub, a root JWT never goes to `/api` nor a persona token to `/clientapi`, no 5xx, no Blazor error bar. - **The tests ask the town** (`Support/Pasture.cs`) which accounts exist and what each object holds (`town.sh expect`), and check the other side of the wire through `town.sh stored`. Each render and action test is also a cell of the town's report (`out/client.jsonl`). - Cards carry `data-uri` (their ActivityPub id) and the empty feed `data-feed-empty`, for the tests. - The root project globs `**/*.cs`, so it excludes `tests/**` (`DefaultItemExcludes`), and Tailwind does not scan it. ## CSS: Tailwind 4 + daisyUI 5, vendored, offline `Styles/app.css` is compiled into `wwwroot/css/app.css` by the Tailwind CSS standalone binary with the daisyUI plugin. `app.css` is gitignored and regenerated on every build. **Nothing in the build or the CI may download from github.com.** Tools live in the repo, and the CSS build runs without network access. NuGet restore still uses nuget.org. - `tools/tailwind/` holds the pinned tools: - Tailwind 4.3.3 as `tailwindcss-linux-x64.xz`; - daisyUI 5.7.47 (`daisyui.mjs`, `daisyui-theme.mjs`); - daisyUI's component reference, `daisyui-llms.txt`. - `.gitattributes` keeps these files byte-exact. - `tools/tailwind.sh prepare` checks them against the sha256 values pinned in the script, and unpacks the binary into the gitignored `.tools/tailwind/`. It needs `xz`. - Only linux-x64 is vendored. - The script's header explains how to upgrade or add a platform. - The `TailwindCompile` target in the csproj runs it, so `dotnet build` / `publish` are enough, in CI as well. Debug builds use `--optimize`, Release builds `--minify`. Pass `-p:SkipTailwind=true` to skip it. - `IncludeTailwindOutput` adds the generated file to `Content` before static web assets are resolved. Without that, a clean-clone publish would ship `app.css` without its `.gz`/`.br` copies and outside `service-worker-assets.js`. The deploy workflow checks for `css/app.css.gz`. - `bash tools/tailwind.sh watch` rebuilds the CSS live next to `dotnet watch`. - Tailwind scans every file `.gitignore` doesn't exclude (minus `tools/`, `wwwroot/vendor/`, `Docs/`), and only generates the classes it finds written out **whole**. Never build a class name by concatenation or interpolation (`"btn-" + size`, `$"btn-{size}"`). Write every variant as a literal, e.g. in a switch arm. Ionicons 4.5 (`wwwroot/vendor/ionicons.min.css`, ``) is loaded as its own stylesheet. ## Theme and neumorphism - `Styles/theme.css` holds two daisyUI themes, `neo-light` (default) and `neo-dark` (`prefersdark`). Every colour is an `hsl()` of `--neo-hue-light` / `--neo-hue-dark` / `--neo-chroma` (0 for the grey themes). - `wwwroot/js/theme.js` sets those variables and `data-theme` on `` from `PageSettings`. It runs in `` from localStorage, so there is no flash before Blazor boots. After that, CascadingState calls it through `AppStatusService.ApplyTheme` whenever the settings change. With `PreferSystemTheming` it leaves `data-theme` off, and the OS decides. - Change the theme flags through `PageSettings.SetDarkMode` / `SetGray` / `SetHue` and `CascadingState.UpdatePageSettings`, which keep the flags consistent: - choosing light or dark leaves system theming; - picking a hue leaves grey. - `AppStatusService.IsDarkTheme()` tells which variant is showing. - Settings → General edits every theme setting. NavMenu's toggle and slider are shortcuts to the dark and hue ones. The other sections of Settings belong to the persona (see **Settings** under PrivaPub wiring). - `Styles/neo.css` holds the neumorphic layer: - `neo-raised` / `neo-inset` / `neo-flat`, with sizes `neo-2xs` / `neo-xs` / `neo-sm` / `neo-md` (these replace the old `neomorph is-nxsmall` classes); - `neo-gradient(-pressed)` and `neo-hue-track`; - `@utility ` overrides that give btn, input, select, card, … the neo look. daisyUI puts its rules in sublayers of `utilities`, so these overrides beat **every** daisyUI rule. Each override must exclude the daisyUI variants it shouldn't touch, e.g. `:not(:where(.btn-ghost, .btn-link, ...))`, and call sites drop a neo shadow with `shadow-none!`. - Never write `@utility collapse`. It brings back Tailwind's own `collapse` utility (`visibility: collapse`), and every collapse disappears. ## daisyUI Blazor components (`Components/Daisy/`) Every daisyUI component has a `D`-prefixed wrapper (`DButton`, `DModal`, `DDropdown`, `DInput`, ...), grouped by daisyUI's categories into folders whose namespaces are imported in `_Imports.razor`. `/styleguide` (not linked) renders all of them in every theme. When you add or change one: - Map each enum to its classes in a switch with whole literal class names. - Value-holding fields derive from `DInputBase`, Blazor's `InputBase`, so `@bind-Value` and EditForm validation work. Everything else derives from `DComponentBase` (`Class`, pass-through attributes, `Cx(...)`). - Open/active state lives in C# (`@bind-IsOpen`, `@bind-Active`) and is rendered with daisyUI's `*-open` modifier classes. No JS interop. - What daisyUI does with scripts has a script-free equivalent here: `DCheckbox.Indeterminate` renders `aria-checked="mixed"`, and `DCarousel`'s navigation links to the slides' ids, which Blazor scrolls to. - `DCalendar` is the one exception: it wraps the vendored Cally web component (`wwwroot/vendor/cally.js`). - Images and links in `/styleguide` stay local (`wwwroot/imgs/styleguide/`). - Don't name a parameter after an HTML attribute (`Title`, `Style`, `Id`, `Label`, ...). Blazor matches attributes to parameters without regard to case, so the parameter would swallow the attribute. - Components don't use `Localizer` or `CascadingState`; their user-visible strings are parameters. ## Architecture **Startup (`Program.cs`).** - DI registration happens here. - The named `HttpClient` "default" uses `ApiBaseAddress` as its base address. It falls back to the host's base address when that setting is empty. - `SetDefaultCulture()` (`Extensions/ExtensionMethods.cs`) runs before the app starts. It picks the culture from the saved `PublicCacheData`, or on a first visit from the browser language. - Only English (`en-GB`) and Italian (`it-IT`) have resources; any other language gets English. - Settings → General changes the language, saves it, and reloads the app. **`LayerComponents/CascadingState.razor`** wraps the router in `App.razor`. It is the app-wide state object. It handles: - `PublicCacheData` (page settings: theme colours, dark/light/system theming, language), persisted in localStorage; - the theme, applied through `AppStatusService.ApplyTheme` (`wwwroot/js/theme.js`); - `IsOnline` / `IsOffline`, polled every 10 seconds; - the session: `IsSignedIn`, `Personas` (the root's), `PersonaId` and `Persona` (its Mastodon `Account`), with `LoadSession`, `SwitchPersona` and `SignOut`. A persona change re-renders every subscriber; pages compare `PersonaId` and reload what belongs to the persona. The JWT is renewed hourly when needed; - `ProcessError` / `ProcessWarning`, which log through `ILoggingService` and show a toast; - `LogFromJs`, which JS calls back into through a `DotNetObjectReference`. **Components and pages** inherit `LocalizableComponentBase`, the equivalent of b2b.next's `WidmannComponentBase`. It supplies: - `CascadingState`, `Localizer`, `Navigation` and `Toast`; - `IsLoading`, which starts true, and `IsDefaultDisabled`; - `AfterRenderAsyncJobs`, run once after the next render; - `WhenOnline` / `StartOnlinePolling`. `PagesBase` is an empty subclass of it that nothing uses. Never redeclare the base's members in a component. **UI feedback.** - `Services/ToastService.cs` is the snackbar: the SUtility extensions `AddResponseError(WebResult)` (silent on 410/cancelled; connectivity failures show as warnings), `AddResponseError(string)`, `AddResponseSuccess` (closes after 5 s), `AddWarning` and `AddException`. `Components/ToastHost.razor`, placed in `MainLayout`, shows the messages. - `Components/ErrorViewer.razor` shows in-page errors: the cascading `EditContext`'s validation messages, `AdditionalError` and `AdditionalErrors`. **HTTP (`Services/IHttpService.cs`).** - Routes are constants in `Models/APIs.cs` (b2b.next naming: `GET_`/`POST_`/`DELETE_`, `_M_` for routes built from parameters), relative to `ApiBaseAddress`. - The route picks the credentials: `clientapi/` sends the root's JWT, `api/` the current persona's Mastodon token (exchanged when missing, exchanged once more and retried when PrivaPub refuses it), `oauth/` nothing. - It also picks the JSON: `SUtility.MastodonSerializer` (snake_case, `MastodonJsonContext`) for `api/` and `oauth/`, `SUtility.DefaultSerializer` (camelCase, enums by name like PrivaPub, `ClientJsonContext`) for `clientapi/`. A payload can be an object (JSON), name/value pairs (a form) or a `Func` (multipart), built once per attempt. - Auth levels: `Get` / `Post` / `Put` / `Patch` / `Delete` need credentials and end the session when PrivaPub refuses them; `GetAnon` / `PostAnon` add them when there are some; `*TotallyAnon` send none (the login must not carry a JWT: PrivaPub redirects it). - `retryOnError` backoff: 1, 2, 4, 8, 16, 29 s, then 60 s, for at most 10 minutes. - Failures never throw. They come back as responses carrying an invalid `WebResult`, with an `ErrorCode` from `Models/FailureCodes.cs`; a cancelled request comes back as HTTP 410. - Read failures with `ReadWebResult` (it understands `WebResult`, validation ProblemDetails and Mastodon's `{"error": ...}`) and bodies with `DefaultReadFromJsonAsync` / `MastodonReadFromJsonAsync`; a body that doesn't fit comes back as the default and is written to the browser console. `PageCursors()` reads `max_id`/`min_id` from `Link`. - Every request PrivaPub answers is recorded in `RequestStats` (method, route template, status, duration, size, rate limit headers; never a body or a token), for the nerd stats. - PrivaPub's `WebResult` gets `IsInvalid` from `Extensions/WebResultExtensions.cs`, a C# 14 extension property. **PrivaPub wiring.** - **One sign-in** (`Services/AuthService.cs`, `Pages/Login.razor`): the root signs in on `/clientapi/user/login`, then each persona the client uses gets its own Mastodon token in exchange for the JWT (PrivaPub's `PersonaExchange`, RFC 8693 on `/oauth/token`, first-party client id `AppConfiguration:ClientId`). `AuthData` keeps the JWT and the persona tokens; logout revokes every persona token, then forgets the session and IndexedDB. - **Services** (`Services/`): `TimelineService` (every feed as `FeedPage`s of `FeedPost`s, paged by `max_id`/`min_id`; circles are read as ids from `/clientapi` and hydrated through `/api/v1/statuses?id[]=`; located posts come from `/clientapi/post/nearby` and have no Mastodon actions), `StatusService` (publish, favourite, boost, bookmark, delete, threads, mute, block, poll votes, emoji reactions through Pleroma's `/api/v1/pleroma/statuses/:id/reactions`; located and group posts go through `/clientapi/post/insert`), `AccountService` (profiles, relationships, follow, block, mute, follow requests, `/api/v2/search`, whose handles and URLs PrivaPub resolves), `ListService` (the persona's lists and their members; members are only accounts it follows, found with `accounts/search?following=true`), `FilterService` (the persona's v2 filters, their keywords and filtered posts), `DiscoveryService` (hashtags and following them, trends, the directory), `PortabilityService` (Mastodon's CSV exports and imports), `AdminService` (`/clientapi/moderator` and `/clientapi/admin`), `MediaService` (uploads), `GroupService`, `InstanceService` and `ProvenanceService`. - **Reactions and votes are public** (owner decision, PrivaPub's ROADMAP): before a persona's first reaction or poll vote, `PublicNoticeService` asks it once (`PublicNoticeHost` in `MainLayout`) and remembers the persona in localStorage (`PublicActionNotices`). Anything else that makes a persona's act visible to others gets the same notice. - **Profiles and search** (`Pages/Profile.razor` at `/account/{id}`, `Pages/Search.razor` at `/search?q=`): a card's author and a notification's account open the profile in the app; "Open original" is the only way to the remote site. A card in a fixed-height flex column with `overflow-hidden` needs `flex-none`, or it collapses to nothing. - **Lists** (`Pages/Lists.razor` at `/lists`): made, renamed, given a replies policy and made exclusive (their members leave the home feed) there, filled from the accounts the persona follows; a followed account's profile has a Lists menu too. A list is read as the feed's Lists tab (`FeedFilter.ListId`). Lists never leave PrivaPub. - **Filters** (`Pages/Filters.razor` at `/filters`, linked from Settings): PrivaPub marks every status with the filters it matches (`Status.Filtered`), whatever their context; a page tells each card where it is (`Content.FilterIn`: home and lists, notifications, public feeds, threads around the opened post, profiles; none for favourites, bookmarks, conversations and search), and the card leaves the post out (hide), folds it behind the filter's title (warn) or blurs its media (blur). - **The composer** (`Components/Composer.razor`) writes a post, a reply (`InReplyTo`), a quote (`Quoting`, offered on a card when the server says the persona may quote it) or an edit of the persona's own post (`Editing`: its words and warning from `/source`, a PUT that keeps its media, visibility and poll); a post can hold a poll instead of media. - **Threads** (`Pages/ExpandMessage.razor` at `/expand/{id}`): opening a remote thread makes PrivaPub read its replies from their servers (its FEP-7888 `context`, else `replies`), so the page reads the thread again after 4 and 15 seconds and adds what was found, in the thread's order. - **Interaction policies** (GoToSocial's): a reply or boost whose target's author must approve it shows so on its card (`privapub.approval`: pending, then nothing once approved, or rejected); one the policy shuts out is refused by PrivaPub (422) and shown as an error. - **Settings** (`Pages/Settings.razor`, `Components/Settings/`): the persona's profile (`ProfileEditor`: `update_credentials` as multipart, its words as written from `Account.Source`, pictures, fields, locked, bot, discoverable, indexable and posting defaults; the composer starts a new post at its default visibility), its mutes, blocks and blocked servers (`MutesAndBlocks`), and `DataTransfer`: follows, lists, blocks, mutes, blocked servers and bookmarks exported and imported as the CSV files Mastodon writes, so a persona moves between servers. An import resolves each row through PrivaPub and acts one row at a time; it can be stopped. - **Administration** (`Pages/Administration.razor`, `Components/Admin/`, for moderators): reports, then for administrators the servers blocked for everyone and the federation statistics. PrivaPub has no admin list of users, and adding an admin endpoint waits for the owner, so the Users section stays empty. - **Scheduled posts** (`Pages/Scheduled.razor` at `/scheduled`): the composer's clock button gives a Mastodon API post a time (`scheduled_at`, at least five minutes ahead; located and group posts have none); the page moves or drops them. - **Explore** (`Pages/Explore.razor` at `/explore`): trending hashtags, posts and links (PrivaPub's, from public posts it holds), the directory, and the hashtags the persona follows. A hashtag's feed tab follows it too. - **Mastodon DTOs** are client-side, in `Models/Mastodon/` (snake_case). `/clientapi` DTOs come from `PrivaPub.ClientModels`. - **Where a post is** (`InstanceService.Locate`, `Models/FeedPost.cs`): its own place (Pixelfed's, `privapub.place`), its event's venue, the reader for a located post, its author's server (to 0.1°, or a point in its country from `Helpers/CountryPoints.cs` when DB-IP located it only to a country), or, for a CDN-fronted server, its CDN's group (`LocationSource.Cdn`: no point, a `CdnKey` and the place `Before` the CDN). Server descriptions are cached a day in localStorage (`ServerInfos`); a server's weekly history (`/instances/:host/history`) and a CDN's page (`/cdns/:key`) are read on the first hover and kept for the session. PrivaPub's own host is the signed-in persona's account host, since the API may be reached under another name. - **The feed page** (`Pages/Index.razor`): `FeedTabs`, `Content` (the card, which does its own actions), `Composer`, `GlobePanel`. Older pages load as the end comes near (`wwwroot/js/feed.js`). Newer ones come through PrivaPub's streaming API (`Services/StreamingService.cs`: one WebSocket, the persona's token as its protocol, never in a URL) for home, lists, local, federated, hashtags and notifications, and wait behind a "new" button; a deletion or an edit it tells applies at once. Other feeds, and any feed while the stream is down, are polled every 45 s while visible. - **The globe** (`wwwroot/js/globe.js`, an ES module imported by `GlobePanel`) runs on globe.gl, vendored in `wwwroot/vendor/globe/` with its textures and `countries.json` (`SOURCES.md` has versions, licences and hashes; the service worker never precaches it). It looks at the spherical mean of the located posts in view, pulses and draws an arc to a hovered post, honours `prefers-reduced-motion`, and draws only while something moves (idle, it costs no GPU time; `wake()` before anything that animates). - **Map.** Each pixel computes its own map coordinates from its point on the sphere (`sharpen`), so the poles don't pinch; the maps wrap horizontally and get 8× anisotropic filtering (`keepSharp`). The day texture is NASA's, its seam repaired by `tools/globe/prepare-day-texture.py` (dev-only, needs Pillow). - **Sun.** `wwwroot/js/sun.js` (pure functions of a date: the subsolar point, Earth–Sun distance, seasons, a place's elevation, phase of the day, solar time and day length) lights the globe in the shader: the day map shaded by the Sun's height and distance, city lights on the night side, a twilight blend between. It shows now (refreshed every 30 s), or the moment of the hovered, focused or pinned post (`data-post-at` on the card; a click pins a card, `data-globe-pinned`), and tells `GlobePanel` through `SunChanged`, which shows the moment, the phase and the season in the panel's top corner. The theme changes only the atmosphere and the points. - **Countries.** Land borders as thin paths, and names placed by `placeLabels`: largest countries first, once they span enough pixels, away from the globe's edge and clear of each other, in the reader's language (`Intl.DisplayNames`). `countries.json` is built by `tools/globe/prepare-countries.py` from world-atlas (Natural Earth). The label renderer owns `display`, so labels hide through `visibility`. - CDN-fronted posts are labels per CDN on a ring at 68°N, never a place; hovering one draws its arc there and a ring where the server was before the CDN. The posts' places reach it in one call per feed change; cards are matched by `data-post-id`. - **Nerd stats** (`Components/Nerd/NerdTip.razor`, rows from `NerdRow`): a post's ids, times and provenance (read on hover), its author and server with the server's changes of place and CDN week by week, its place or its CDN (how PrivaPub found it, the place before it, the CDN's servers week by week), media and counts; the feed's paging and last request; the globe's camera, points, countries and frame rate, and its Sun (subsolar point, distance, irradiance, the place's elevation and day length, the next equinox or solstice). **Localization (`Services/CoalescingStringLocalizer.cs`).** - Lookup order: this repo's `Resources/AllStrings.resx` (+ `.it.resx`), then `FieldsNameResource`, then `ErrorsResource`. - A missing key renders as the key itself, formatted with its arguments, so a typo fails silently. - The class lives in namespace `collAnon.Client.Services`, a leftover from another project. - `Resources/ErrorMessages.resx` is read by `[Required]` / `[StringLength]` through `ErrorMessageResourceType`. A new key needs its `public static string` property in `ErrorMessages.Designer.cs` too, or validation throws at runtime. **Client storage** comes in two layers: - **localStorage** (Blazored.LocalStorage, camelCase JSON) holds `AuthData`, `PublicCacheData`, `FeedFilter` and the server cache `ServerInfos`, keyed by `nameof(Type)`. - **IndexedDB** (DnetIndexedDb, database `data`) is reached through `IStorage`. Its schema is declared in `GenericExtensions.AddIndexedDb()`, and you must bump `WithVersion` when you change it. - The schema is at version 3: the `ClientLogs` store that `IStorage.AddLog` writes (version 2 added it; version 3 left the mock feed's `Message` store out). **Auth.** `TokenAuthStateProvider` holds `AuthData` (localStorage, cached in memory) and builds the auth state from it: the root's name, its policies as claims, and the current persona. Pages that need a session carry `[Authorize]`; `RedirectToLogin` sends the reader to `/login?returnUrl=…`. **JS interop.** - `wwwroot/main.js` defines `window.*` helpers. `Services/AppStatusService.cs` calls them, mostly through the synchronous `IJSInProcessRuntime`. - Each helper reports its errors back through `window.logFromJs`. - The other scripts (IndexedDB, HeadElement, rxjs) are loaded by `