Files
decePubClient/CLAUDE.md
T
thepraandClaude Opus 5.5 d552baa578 Document the PrivaPub wiring
CLAUDE.md: the two APIs and their credentials, the services, where a
post is placed, the feed page and the globe, nerd stats, running
against a local PrivaPub or the pasture, and that resx keys must not
differ only by case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-04 11:03:14 +02:00

24 KiB

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

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, <i class="ion-md-...">) 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 <html> from PageSettings. It runs in <head> 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 <daisy-component> 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<TValue>, 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<HttpContent> (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 FeedPages of FeedPosts, 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 (PrivaPub's public projection: 0.1° for servers with at least 10 users, else the country, drawn at Helpers/CountryPoints.cs), 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 <script> tags in wwwroot/index.html; the feed's modules (js/feed.js, js/globe.js) are imported on demand through IJSObjectReference.

PWA. Published builds use wwwroot/service-worker.published.js, and service-worker.js is the dev version.

Development rules

These rules are adapted from the owner's b2b.next project. They are enforced by review: the build doesn't check them, so follow them in every change.

Area Rule
All var everywhere; uninitialised locals are var x = default(T);
All Primary constructors for DI (class Foo(IBar bar)), not constructor + fields
All Every async method takes a CancellationToken as its last parameter
All Early return on error: guard, handle, return; never if/else for success vs failure
Models / state Lists and arrays = [], never null; no = string.Empty / "" defaults; explicit enum defaults
Components No @{ } blocks in markup: locals become @code members
Components [Inject] in @code, never @inject
Components Disable controls with IsDefaultDisabled (plus local conditions), never bare offline state
Components Every text/number input declares autocomplete
Services Task<WebResult> for mutations, Task<List<T>> ([] on error) for lists, Task<T> with a default for values
Localization Every user-facing string through Localizer, its key in both AllStrings.resx and AllStrings.it.resx

CancellationToken. It goes on every async method in services, CascadingState and helpers, defaulting to default; pass it on to what you call. Exempt: Blazor lifecycle overrides, [JSInvokable] methods, framework overrides (GetAuthenticationStateAsync), and private UI handlers bound from markup.

State initialisation. Initialise page and component state (= new(), = []) and bind it directly. No passthrough getters and no ?. chains over state that can't be null:

List<FeedPost> posts = [];                      // DO
@posts.Count                                    // DO
@(posts?.Count ?? 0)                             // DON'T

= "" is allowed only as a documented exception, with the reason in a comment (e.g. a decorative image's Alt).

Pages that load data. Pages fetch in LoadData and keep lifecycle concerns in the lifecycle method:

protected override async Task OnInitializedAsync()
{
	if (CascadingState.IsOffline)
	{
		WhenOnline(LoadData);
		StartOnlinePolling();
		IsLoading = false;
		return;
	}

	await LoadData();
	IsLoading = false;
}
  • Reuse the base IsLoading; don't add per-section loading flags.
  • A component that binds IsDefaultDisabled must set IsLoading = false once it is ready, even if it loads nothing; otherwise its controls stay disabled.
  • Never wrap a service call in try/catch. Services already catch and log, and return an invalid WebResult. Branch on result.IsInvalid instead:
var result = await StatusService.Delete(Status.Id);
if (result.IsInvalid)
{
	Toast.AddResponseError(result);
	return;
}

Toast.AddResponseSuccess(Localizer["Message deleted."]);
await OnDeleted.InvokeAsync(Post);

Client services follow b2b.next's two patterns (see its StoreService):

  • Create var result = new WebResult();, call httpService inside a try/catch, and on a non-success status result = await response.Content.ReadWebResult(response.StatusCode, localizer, ct). Log with ILoggingService.
  • Return result (mutations) or [] (lists), and set result.Data on success.
  • POST bodies are JSON payloads for /clientapi and the Mastodon API (snake_case DTOs), forms for /oauth.

In-page errors. ErrorViewer takes AdditionalError for one message and AdditionalErrors for a list. Never join a list into one string with <br/> or \n.

Autocomplete values:

  • autocomplete="off" for search, filter and free text;
  • new-password when setting a password;
  • one-time-code for OTP (DOtp sets it);
  • username and current-password on the login form, so password managers fill it;
  • inside Components/Daisy, attributes pass through from the caller.

Localization:

  • Keys are English source strings. Grep both resx files for an existing key before adding one; match keys exactly.
  • Never add a key that differs from an existing one only by case ("users" next to "Users"): resource lookups collide on them and one of the two silently shows the other's text. Reuse the existing key.
  • Use Localizer[key, args], never string.Format(Localizer[key], …). Don't write .Value.
  • Enum members shown through Localizer[value.ToString()] are keys too: when you add a member, add its key.
  • Language names in the language picker stay in their own language.

Formatting. Date formats come from SUtility.dateTimeFormat / timeFormat / dateTimeFormatExt. Relative times and sizes come from GetPassedTime / GetFileSize (Extensions/GenericExtensions.cs). No inline format strings for display.

JSON. Register every /clientapi or localStorage type in ClientJsonContext.cs (camelCase: theme.js reads pageSettings straight from localStorage), every Mastodon API type in MastodonJsonContext.cs (snake_case).

Wiring a feature to PrivaPub:

  1. For /clientapi, add the request/response DTOs to PrivaPub.ClientModels in the SocialPub repo: View* for responses, *Request for requests, *Form for forms with validation. Lists = [], no string defaults. For the Mastodon API, add the shape to Models/Mastodon/.
  2. Register them in ClientJsonContext or MastodonJsonContext, and the route in Models/APIs.cs.
  3. Add a client service method that calls IHttpService against the server's route, following the patterns above.
  4. Wire the page with early returns, toasts and/or an ErrorViewer.

Not adopted from b2b.next, on purpose:

  • The tw: Tailwind prefix. b2b.next needs it next to MudBlazor's CSS; here daisyUI is the only component CSS.
  • Server-only rules: no 5XX responses, the controller pattern, IOptionsMonitor, data access and caching.
  • b2b.next-specific features: Excel import/export, password policy, legacy porting rules, the {culture} route placeholder (PrivaPub's routes are absolute /clientapi/...), and the release/API-status worker.
  • Mandatory /// <summary> on every [Parameter]. Document only what isn't obvious from the name.

Deployment

Both workflows check the repo out with a plain git fetch from this Gitea, not actions/checkout, because the runner would download that action from github.com. Never add a uses: step.

.gitea/workflows/deploy.yml runs on a v* tag or a manual dispatch, on a self-hosted runner that is also the web host. The steps:

  1. Publish the site.
  2. Write build.json (commit, ref, time).
  3. Snapshot the live directory to /var/backups/decepub.thepra.dev.
  4. rsync --delete into /var/www/decepub.thepra.dev.
  5. Check that build.json serves the new commit and that a deep client route returns 200 (the SPA fallback). Otherwise, roll back to the snapshot.

The nginx vhost and headers are in deploy/nginx/ (it sends X-Robots-Tag: noindex, nofollow), and deploy/max/setup.sh is the one-time root setup on the server. If you add a file that must never be cached, add it to the expires -1 locations in the vhost.

Conventions

  • Indent with tabs.
  • Nullable is disabled and ImplicitUsings is enabled.
  • Global Razor usings are in _Imports.razor.
  • Styling uses the D components, Tailwind utilities and the neo-* utilities. Use gap-* rather than space-x/y-* on flex containers; the feeds use flex-col-reverse, which space-* gets wrong.
  • SUtility.IfTrueThen(cond, "class") is the helper for conditional classes.