The new "Development rules" section covers the rules and the patterns that go with them: - the quick-reference table; - CancellationToken and its exemptions; - state initialisation; - the page loading/offline pattern; - early return with toasts; - client service patterns, ErrorViewer, autocomplete values, localization, formatting and JSON; - the steps to wire a feature to PrivaPub; - what was deliberately not adopted, and why: the tw: prefix, server-only rules, b2b.next-specific features, and mandatory parameter docs. The architecture notes now describe the component base, toasts, ErrorViewer, HttpService, the mock data and the two cultures. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
19 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 is still a prototype and is not connected to the server:
- The feed and threads come from
Helpers/Faker.cs:Storage.GetMessages()returns the seed feed while IndexedDB is empty, andFaker.Threadbuilds the messages around an expanded one. MessagesServicehas the realTask<WebResult>signatures but builds its results in memory.HttpServiceis registered but nothing calls it yet.- The nginx vhost sends
X-Robots-Tag: noindexwhile 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
FieldsNameResourceandErrorsResourcelocalization 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
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.
- Tailwind 4.3.3 as
.gitattributeskeeps these files byte-exact.tools/tailwind.sh preparechecks them against the sha256 values pinned in the script, and unpacks the binary into the gitignored.tools/tailwind/. It needsxz.- Only linux-x64 is vendored.
- The script's header explains how to upgrade or add a platform.
- The
TailwindCompiletarget in the csproj runs it, sodotnet build/publishare enough, in CI as well. Debug builds use--optimize, Release builds--minify. Pass-p:SkipTailwind=trueto skip it. IncludeTailwindOutputadds the generated file toContentbefore static web assets are resolved. Without that, a clean-clone publish would shipapp.csswithout its.gz/.brcopies and outsideservice-worker-assets.js. The deploy workflow checks forcss/app.css.gz.bash tools/tailwind.sh watchrebuilds the CSS live next todotnet watch.- Tailwind scans every file
.gitignoredoesn't exclude (minustools/,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.cssholds two daisyUI themes,neo-light(default) andneo-dark(prefersdark). Every colour is anhsl()of--neo-hue-light/--neo-hue-dark/--neo-chroma(0 for the grey themes).wwwroot/js/theme.jssets those variables anddata-themeon<html>fromPageSettings. It runs in<head>from localStorage, so there is no flash before Blazor boots. After that, CascadingState calls it throughAppStatusService.ApplyThemewhenever the settings change. WithPreferSystemThemingit leavesdata-themeoff, and the OS decides.- Change the theme flags through
PageSettings.SetDarkMode/SetGray/SetHueandCascadingState.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.cssholds the neumorphic layer:neo-raised/neo-inset/neo-flat, with sizesneo-2xs/neo-xs/neo-sm/neo-md(these replace the oldneomorph is-nxsmallclasses);neo-gradient(-pressed)andneo-hue-track;@utility <daisy-component>overrides that give btn, input, select, card, … the neo look. daisyUI puts its rules in sublayers ofutilities, 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 withshadow-none!.
- Never write
@utility collapse. It brings back Tailwind's owncollapseutility (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'sInputBase, so@bind-Valueand EditForm validation work. Everything else derives fromDComponentBase(Class, pass-through attributes,Cx(...)). - Open/active state lives in C# (
@bind-IsOpen,@bind-Active) and is rendered with daisyUI's*-openmodifier classes. No JS interop.- What daisyUI does with scripts has a script-free equivalent here:
DCheckbox.Indeterminaterendersaria-checked="mixed", andDCarousel's navigation links to the slides' ids, which Blazor scrolls to. DCalendaris the one exception: it wraps the vendored Cally web component (wwwroot/vendor/cally.js).
- What daisyUI does with scripts has a script-free equivalent here:
- Images and links in
/styleguidestay 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
LocalizerorCascadingState; their user-visible strings are parameters.
Architecture
Startup (Program.cs).
- DI registration happens here.
- The named
HttpClient"default" usesApiBaseAddressas 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 savedPublicCacheData, 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;User, which isFaker.CurrentUseruntil the login is wired;ProcessError/ProcessWarning, which log throughILoggingServiceand show a toast;LogFromJs, which JS calls back into through aDotNetObjectReference.
Components and pages inherit LocalizableComponentBase, the equivalent of b2b.next's WidmannComponentBase. It
supplies:
CascadingState,Localizer,NavigationandToast;IsLoading, which starts true, andIsDefaultDisabled;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.csis the snackbar: the SUtility extensionsAddResponseError(WebResult)(silent on 410/cancelled; connectivity failures show as warnings),AddResponseError(string),AddResponseSuccess(closes after 5 s),AddWarningandAddException.Components/ToastHost.razor, placed inMainLayout, shows the messages.Components/ErrorViewer.razorshows in-page errors: the cascadingEditContext's validation messages,AdditionalErrorandAdditionalErrors.
HTTP (Services/IHttpService.cs), registered but not called yet, since the app still runs on mock data:
- Auth levels:
Get/Post/Deleteneed a valid token and sign out on 401;GetAnon/PostAnonadd the token if there is one;*TotallyAnonsend none. retryOnErrorbackoff: 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 anErrorCodefromModels/FailureCodes.cs; a cancelled request comes back as HTTP 410. - Read failures with
ReadWebResultand bodies withDefaultReadFromJsonAsync(Extensions/ExtensionMethods.cs). - JSON goes through
SUtility.DefaultSerializer, which consultsClientJsonContextfirst. - PrivaPub's
WebResultgetsIsInvalidfromExtensions/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.
Localization (Services/CoalescingStringLocalizer.cs).
- Lookup order: this repo's
Resources/AllStrings.resx(+.it.resx), thenFieldsNameResource, thenErrorsResource. - 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.resxis read by[Required]/[StringLength]throughErrorMessageResourceType. A new key needs itspublic static stringproperty inErrorMessages.Designer.cstoo, or validation throws at runtime.
Client storage comes in two layers:
- localStorage (Blazored.LocalStorage, camelCase JSON) holds
AuthDataandPublicCacheData, keyed bynameof(Type). - IndexedDB (DnetIndexedDb, database
data) is reached throughIStorage. Its schema is declared inGenericExtensions.AddIndexedDb(), and you must bumpWithVersionwhen you change it. - The schema is at version 2, which added the
ClientLogsstore thatIStorage.AddLogwrites. Before it, every log write failed silently.
Auth.
TokenAuthStateProviderbuilds the auth state from theAuthDatatoken in localStorage.SetTokenis mostly commented out, so there is no real login flow yet.Pages/Logout.razorsigns out throughTokenAuthStateProvider.LogoutAsync, which clears the token and the local database.AddApiAuthorization,Pages/Authentication.razor(/authentication/{action}) andRedirectToLoginare leftovers from the Blazor OIDC template. That route'sRemoteAuthenticatorViewthrowsInvalidCastException, because the registeredAuthenticationStateProviderisTokenAuthStateProvider. The Login links that point there need a real login page first.
JS interop.
wwwroot/main.jsdefineswindow.*helpers.Services/AppStatusService.cscalls them, mostly through the synchronousIJSInProcessRuntime.- Each helper reports its errors back through
window.logFromJs. - The other scripts (IndexedDB, HeadElement, auth, rxjs) are loaded by
<script>tags inwwwroot/index.html.
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<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:
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
IsDefaultDisabledmust setIsLoading = falseonce 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 onresult.IsInvalidinstead:
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();, callhttpServiceinside a try/catch, and on a non-success statusresult = await response.Content.ReadWebResult(response.StatusCode, localizer, ct). Log withILoggingService. - Return
result(mutations) or[](lists), and setresult.Dataon 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-passwordwhen setting a password;one-time-codefor 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], neverstring.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:
- Add the request/response DTOs to
PrivaPub.ClientModelsin the SocialPub repo:View*for responses,*Requestfor requests,*Formfor forms with validation. Lists= [], no string defaults. - Register them in
ClientJsonContext. - Add a client service method that calls
IHttpServiceagainst the server's route, following the patterns above. - 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:
- Publish the site.
- Write
build.json(commit, ref, time). - Snapshot the live directory to
/var/backups/decepub.thepra.dev. rsync --deleteinto/var/www/decepub.thepra.dev.- Check that
build.jsonserves 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.
Nullableis disabled andImplicitUsingsis enabled.- Global Razor usings are in
_Imports.razor. - Styling uses the D components, Tailwind utilities and the
neo-*utilities. Usegap-*rather thanspace-x/y-*on flex containers; the feeds useflex-col-reverse, whichspace-*gets wrong. SUtility.IfTrueThen(cond, "class")is the helper for conditional classes.