2026-10-03 12:08:52 +02:00
# 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 is still a prototype and is not connected to the server:**
2026-10-03 15:26:21 +02:00
- The feed and threads come from `Helpers/Faker.cs` : `Storage.GetMessages()` returns the seed feed while IndexedDB is
empty, and `Faker.Thread` builds the messages around an expanded one.
- `MessagesService` has the real `Task<WebResult>` signatures but builds its results in memory.
- `HttpService` is registered but nothing calls it yet.
2026-10-03 12:08:52 +02:00
- The nginx vhost sends `X-Robots-Tag: noindex` while the site shows mock posts.
`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 intended API surface is the Mastodon client API plus PrivaPub's
private `/clientapi` .
## 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.
2026-10-03 12:43:07 +02:00
## CSS: Tailwind 4 + daisyUI 5, vendored, offline
2026-10-03 12:08:52 +02:00
`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.
2026-10-03 12:43:07 +02:00
**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.
2026-10-03 12:08:52 +02:00
- 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` .
2026-10-03 12:43:07 +02:00
- `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
2026-10-03 12:08:52 +02:00
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.
2026-10-03 12:43:07 +02:00
- 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.
2026-10-03 12:08:52 +02:00
- `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
2026-10-03 12:43:07 +02:00
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/` ).
2026-10-03 12:08:52 +02:00
- 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
2026-10-03 15:26:21 +02:00
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.
2026-10-03 12:08:52 +02:00
2026-10-03 15:26:21 +02:00
** `LayerComponents/CascadingState.razor` ** wraps the router in `App.razor` . It is the app-wide state object. It handles:
2026-10-03 12:08:52 +02:00
- `PublicCacheData` (page settings: theme colours, dark/light/system theming, language), persisted in localStorage;
- the theme, applied through `AppStatusService.ApplyTheme` (`wwwroot/js/theme.js` );
2026-10-03 15:26:21 +02:00
- `IsOnline` / `IsOffline` , polled every 10 seconds;
- `User` , which is `Faker.CurrentUser` until the login is wired;
- `ProcessError` / `ProcessWarning` , which log through `ILoggingService` and show a toast;
2026-10-03 12:08:52 +02:00
- `LogFromJs` , which JS calls back into through a `DotNetObjectReference` .
2026-10-03 15:26:21 +02:00
**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, and only `Authentication.razor` uses it. 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`)** , registered but not called yet, since the app still runs on mock data:
- Auth levels: `Get` / `Post` / `Delete` need a valid token and sign out on 401; `GetAnon` / `PostAnon` add the token
if there is one; `*TotallyAnon` send none.
- `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` and bodies with `DefaultReadFromJsonAsync` (`Extensions/ExtensionMethods.cs` ).
- JSON goes through `SUtility.DefaultSerializer` , which consults `ClientJsonContext` first.
- PrivaPub's `WebResult` gets `IsInvalid` from `Extensions/WebResultExtensions.cs` , a C# 14 extension property.
**Mock data.** `MessagesService` has the real signatures (`Task<WebResult>` , `Data` = the updated entity), but builds
its results in memory. `Helpers/Faker.cs` holds the current user, the seed feed and the thread around an expanded
message.
2026-10-03 12:08:52 +02:00
**Localization (`Services/CoalescingStringLocalizer.cs`).**
2026-10-03 15:26:21 +02:00
- 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.
2026-10-03 12:08:52 +02:00
- The class lives in namespace `collAnon.Client.Services` , a leftover from another project.
2026-10-03 15:26:21 +02:00
- `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.
2026-10-03 12:08:52 +02:00
**Client storage** comes in two layers:
- **localStorage** (Blazored.LocalStorage, camelCase JSON) holds `AuthData` and `PublicCacheData` , 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.
2026-10-03 12:43:07 +02:00
- The schema is at version 2, which added the `ClientLogs` store that `IStorage.AddLog` writes. Before it, every log
write failed silently.
2026-10-03 12:08:52 +02:00
**Auth.**
- `TokenAuthStateProvider` builds the auth state from the `AuthData` token in localStorage.
- `SetToken` is mostly commented out, so there is no real login flow yet.
2026-10-03 12:43:07 +02:00
- `Pages/Logout.razor` signs out through `TokenAuthStateProvider.LogoutAsync` , which clears the token and the local
database.
- `AddApiAuthorization` , `Pages/Authentication.razor` (`/authentication/{action}` ) and `RedirectToLogin` are leftovers
from the Blazor OIDC template. That route's `RemoteAuthenticatorView` throws `InvalidCastException` , because the
registered `AuthenticationStateProvider` is `TokenAuthStateProvider` . The Login links that point there need a real
login page first.
2026-10-03 12:08:52 +02:00
**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, auth, rxjs) are loaded by `<script>` tags in `wwwroot/index.html` .
**PWA.** Published builds use `wwwroot/service-worker.published.js` , and `service-worker.js` is the dev version.
2026-10-03 15:26:21 +02:00
## 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:
```csharp
List < Message > Messages { get ; set ; } = []; // DO
@Messages . Count // DO
@ ( Messages ?. 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:
```csharp
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:
```csharp
var result = await MessagesService . DeleteMessage ( message );
if ( result . IsInvalid )
{
Toast . AddResponseError ( result );
return ;
}
Messages . Remove ( message );
Toast . AddResponseSuccess ( Localizer [ "Message deleted." ]);
```
**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.
**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);
- omit it for login credentials;
- 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.
- 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 type that crosses HTTP or goes into localStorage in `ClientJsonContext.cs` . Keep it
camelCase: `theme.js` reads `pageSettings` straight from localStorage.
**Wiring a feature to PrivaPub:**
1. 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.
2. Register them in `ClientJsonContext` .
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.
2026-10-03 12:08:52 +02:00
## Deployment
2026-10-03 12:43:07 +02:00
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.
2026-10-03 12:08:52 +02:00
`.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/` , 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.