# 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 are no tests and no linter. ## 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. - `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` / `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; located and group posts go through `/clientapi/post/insert`), `MediaService` (uploads), `GroupService`, `InstanceService` and `ProvenanceService`. - **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 nowhere (a CDN-fronted server). Server descriptions are cached a day in localStorage (`ServerInfos`). 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 are polled every 45 s while the page is visible (PrivaPub has no streaming) and wait behind a "new" button. - **The globe** (`wwwroot/js/globe.js`, an ES module imported by `GlobePanel`) runs on globe.gl, vendored in `wwwroot/vendor/globe/` with its textures (`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, follows the theme (day/night texture), honours `prefers-reduced-motion` and pauses while hidden. 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, its place, media and counts; the feed's paging and last request; the globe's camera, points and frame rate. **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 `