On touch the "on the wall of" link gets a 44px hit area (it truncates, so padding), an event's venue address wraps,
and on a phone toasts sit above the round New post button. A table's scroll box and the administration's statistics
strip are marked as strips that scroll sideways on purpose (data-scroll-x), which the phone sweep allows. CLAUDE.md gains
a Phones section: width variants for layout and the pointer for hit sizes, touch.css, the 44px rule, the bottom sheets
(ResponsiveBottom, SheetOnNarrow, PhoneSheet), Back closing overlays (IDOverlayHost, OverlayService), ConfirmService,
what a tap does on touch, the feed on a phone; and the phone tests under Testing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
A card's time is a "Post" button now (its nerd stats open with a tap), so the poll test submits through the composer's
own submit button instead of the last button named Post; the feed-memory test opens a thread from the Expand button,
not from the first expand/ link, which on a desktop is now the phone-only Thread entry. The Back flow presses Back with
history.back(): a Back held to close an overlay loads nothing, which GoBackAsync waited for.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
On opening, the feed showed "Empty" until the session had been read and a persona chosen, then loaded: a flash of a
wrong answer on every start (and the end-to-end sign-in took it for a loaded feed). CascadingState says once the session
has been read (IsSessionLoaded), and until then a feed with no persona yet is loading; a login without any persona still
gets its empty feed. The persona-switch test waits for the sibling's home read with another token than the first
persona's, instead of any home read after the switch was asked for.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
sun.js gains analemma(date, days): where the Sun is overhead at the same UTC time on each coming day, the figure-8 the
globe will draw. tools/globe/check.mjs checks the pure functions against almanac values with Node's own test runner
(dev only, nothing installed): the 2026 June solstice's declination, the March equinox, perihelion's distance, Turin's
longest day, the season in October, and the analemma spanning the tropics and the equation of time.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
On a phone the Back button left the page under an open menu, sheet or dialog. An open overlay now says so (new
IDOverlayHost, cascaded by MainLayout; OverlayService keeps them newest last): DModal and DDropdown through the
cascade, so the D components stay free of app services, and the nav menu, PhoneSheet and the full-screen globe through
the service. MainLayout's location-changing handler lets Back close the newest overlay instead of leaving the page; a
link followed or the app's own navigation (signing out after its question, say) closes them all and goes on. Blazor
can't tell Back from the app's navigation, so a capture-phase popstate listener in main.js marks Back
(AppStatusService.TookBack). No history entries are pushed. Phone flow: Back closes the menu, a card's More sheet, the
composer and the full-screen globe one after another without leaving the feed, and with nothing open goes back.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
A tap on a picture opened its file in a new tab, leaving the app (and, on a phone, its place in the feed). It now opens
MediaViewer: the whole screen on a phone, a large dialog from md, the picture whole with its alt text under it, the
post's other pictures by the previous and next buttons or the arrow keys, and "Open original" for whoever wants the
file. Phone flow: a picture of a town post opens the viewer over the whole screen, which closes from its button.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
On a phone the only way into a post's thread was a small Expand button (and now an entry in More), while a tap on the
post pinned it to a globe that is usually hidden there, outlining the card. Where the pointer can't hover, a tap on a
post that isn't on its links, controls or menus and doesn't end a text selection opens its thread through the card's
own thread link (main.js, data-thread on the card); cards are pinned to the globe with a mouse only. The globe's corner
stats have 12px text on touch. Phone flow: a tap in a post's margin opens its thread and pins nothing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
The posts before and after the opened one are indented 1.5rem, and they kept their full width besides (w-full beat the
w-auto meant to undo it), so on a phone the thread scrolled sideways by the indent. They are capped at the column's
width less the indent.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
On a touch screen a nerd-stats tooltip showed only through the browser faking hover, unreliably, and couldn't be
scrolled. Where the pointer can't hover (AppStatusService.CanHover), a tap on a NerdTip now opens its rows in a sheet
from the bottom; the trigger is focusable, says it opens a dialog, and is at least 44px on touch. A closed tooltip takes
no room any more (daisyUI only made it transparent, and one past a card's edge made a phone's feed scroll sideways),
and on touch it never shows. The rows are a component of their own (NerdRows), at 12px on phones. A card's More menu
has a Details entry on phones that gathers the post's, its author's, its place's or CDN's and its counts' rows in one
sheet. A picture's tip doesn't open on a tap, the picture does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
On touch a poll's options are at least 44px tall with 8px between them, and a long option wraps. The phone sweep
ignores a probe that lands on a fixed floating control (the round New post button covers the card under it by design)
instead of failing the target for it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Below xl every feed tab but the selected one was a lone icon with no name, for a screen reader or a finger. DTab takes
an AccessibleName, which DTabs renders as the tab's label and tooltip, and FeedTabs names each tab; on a phone every
tab shows its name too. The strip is marked as one that scrolls sideways (data-scroll-x), says it holds the feeds, and
on a phone fades at the edge where more tabs wait (edge-fade-x, a scroll-driven animation where the browser has one);
the selected tab is brought into view when it changes (window.revealActive). Explore's tabs get the same.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Deleting a post, taking it off the persona's wall, muting or blocking an account (from a card or a profile), leaving
a group, dropping a scheduled post and signing out acted at the first tap, a few pixels from their neighbours. They now
ask first: new ConfirmService (Ask returns whether the reader confirmed, as PublicNoticeService does) and ConfirmHost in
MainLayout, a sheet from the bottom on a phone and a dialog from md, with Cancel and the action named, in red when it
deletes or cuts off. Logout is a button that asks, then goes to /logout as before. The reversible ones (unmute,
unblock, removing a conversation from the list, a picture from the composer) don't ask; the inline confirms of lists,
filters and the login's deletion stay. The desktop tests that delete a reply, take a post off a wall or drop a
scheduled post confirm; a phone flow deletes a post after cancelling once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
On a phone the globe is a banner above the feed; a tap on it now opens it full screen over everything, with a close
button (Escape closes it too), the focus moving to that button and back, and the feed underneath kept as it was but out
of reach (inert). The camera refits the new shape. Held sideways, a phone (a window at most 30rem tall, the new short:
variant) has no room for the banner: the banner and its toggle give way to a button that opens the globe full screen.
isMobileMedia, which picks the first visit's default, agrees with Tailwind's md now (it was off by one) and counts a
phone held sideways. GlobePanel tells its state (data-globe-state). Phone flows: the globe opens full screen and
closes, and a sideways phone keeps a feed at least 180px tall with no sideways scroll.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
FlowTests drive the narrowest phone through what landed so far, each opened menu, sheet or dialog checked to be whole
on the screen with 44px targets (fit.js measures inside a scope now): the menu closes after a choice, an outside tap
and Escape; "Liked by" rises from the bottom and closes from its button; a new post is written in the sheet the round
button opens from a scrolled feed, with the words focused; a reply is written in a sheet; a reaction is picked from the
React sheet, through the one-time public notice, and taken back. Cells client.mobile.flow.*. The menu's dark-mode
toggle gets a 44px hit area and a name, and the public notice dialog marks itself for the tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
On a phone the composer opened at the top of the feed whether or not the feed was scrolled there, and a reply, quote
or edit opened inside its card's 228px column. New PhoneSheet shows them as a sheet from the bottom of the screen below
md, over a dimmed page, with a heading and a close button (a tap outside or Escape closes it too); from md they stay in
place as before. On a phone a new post starts from a round button under the thumb (the feed keeps room for it at the
bottom), and the tabs' Post button shows from md. Opening the composer scrolls the feed to its top, and the composer's
words take the focus (DTextarea.Element). Picking a suggested mention or hashtag keeps the focus, and the phone's
keyboard, in the words (DButton.KeepFocus, and the words are focused again). The character count sits under the words
instead of over their last line, and the visibility menu names each choice.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
The profile card clipped its overflow, so the Lists menu (where a followed account is put into lists) was cut at the
card's edge at every width, and on a phone could also run past the screen's left side. The card no longer clips (its
header picture rounds the top corners itself), and on a phone the menu opens as a sheet from the bottom.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
The tab strip sat in a flex column taller than the page, and a sideways-scrolling box may shrink to nothing there: on a
phone the five tabs had no height at all and couldn't be reached. It keeps its size now (flex-none). Found by the phone
sweep.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
On a phone the card's action row was about 371px wide against a 228px text column, so the feed scrolled sideways and
More and Expand fell off the screen. Below md the author's picture moves from its own column into the card's header,
which gives the text, media and polls the whole width (288px at 360), and the actions are five equal cells: Reply,
Boost, Favourite, React and More. Bookmark and the thread are entries in More there; the downvotes, the counts icon and
Expand show from md. Counts are compacted on phones (1.2k, 12k, 1.2M, GenericExtensions.Compact). React and More open
as sheets from the bottom of the screen, the reactions as a grid of four. The author's name and handle stack and are
14px, the place badge is narrower, and "edited" and the booster's name have 44px hit areas; the booster's name opens
their profile in the app instead of their site. On touch the extra small buttons' text is 14px, and their hit area is
a full 44px (the ::after sits against the padding box, inside the border). Desktop keeps its row.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
A saved sign-in whose session expired landed on the login page and every route its persona opened timed out; both the
desktop and the phone sign-in now see that and sign in afresh. A route that fails to open leaves its screenshot and
text, as a failed sign-in does. A target's probes stay inside what its scroller shows (a card half under the feed's
top edge was failed for the part out of sight), a failed probe names what it hit, and the element blamed for sideways
scroll is never one inside a nested clipping box, which can't widen the page. The desktop guard reads its probe sizes
as a list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
A closed dropdown's content stayed displayed whenever it carried a display class (flex, grid), only invisible, so it
still took room: the React picker past a card's right edge made the whole feed scroll sideways on a phone (294px at
360px). A closed one is now not displayed at all. DDropdown moves the focus into its content on opening, which also
scrolls a menu cut off by the feed into view, and gives it back to its trigger after Escape or an outside tap. New
SheetOnNarrow makes the content a sheet from the bottom of the screen below md, over a dimmed page, at most 80dvh tall.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
On a phone the menu stayed open over the page after a link was tapped, and only the hamburger closed it. It now closes
on any navigation, on a persona switch, on a tap outside it (a dimmed backdrop) and on Escape; its height is capped at
the screen's (dvh) with its own scroll, and its width follows a narrow screen. The hamburger has a name ("Menu") and says
whether the menu is open. The header's logo is a link home and the persona's picture a 44px link to Settings (it was
30px). From md, the sidebar scrolls on its own when the window is short, as on a phone held sideways.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
DModal draws a close button (✕, named by CloseText) beside its heading, in a header that stays in view while the box
scrolls; a long list of who liked a post left only the thin margins around the box to tap out of it. A new
ResponsiveBottom placement makes the box a sheet from the bottom below md and keeps it in the middle from md; the
report, liked-by and boosted-by, edit history and public notice dialogs use it. Boxes are at most 100dvh tall, so a
phone's browser bars don't cut them, and a bottom sheet leaves 3rem above itself and clears the home indicator. The
report dialog gets a Cancel button.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Under (pointer: coarse) only, so desktop keeps daisyUI's sizes: buttons, tabs, inputs and selects are 44px tall and
icon buttons and tabs 44px wide; extra small buttons and the badges that act are 36px with a transparent ::after that
gives them a 44px hit area, and the rows of reactions and hashtags leave 8px between them so those areas don't overlap.
Checkboxes and radios are 24px, toggles 28, range thumbs 28, and a toggle's or checkbox's label at least 44px tall. At
every width, the label of a checkbox, radio or toggle now wraps: daisyUI's .label never does, and long ones (Groups,
the profile editor, Settings) ran past a phone's edge.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
The viewport no longer forbids zooming (maximum-scale=1, user-scalable=no kept text at 10-12px out of reach), and it
covers the screen with the keyboard resizing the content. The app is 100dvh tall instead of 100vh, so its bottom isn't
under a phone browser's bar, and a pull past the top no longer reloads it (overscroll-behavior-y). New Styles/touch.css
holds what a touch screen gets, under (pointer: coarse) only: for now field text at 16px, so iOS doesn't zoom into a
focused field.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
The browser fixture opens phones from Playwright's own device presets (360x800, iPhone SE 375x667, Pixel 7 412x839,
and the Pixel 7 held sideways), with touch, a coarse pointer and reduced motion, and a phone session fails at once if
the page doesn't see a touch screen. Mobile/fit.js measures a page one screen at a time down its main scroller:
sideways scroll, boxes past the screen's sides, open menus and dialogs cut or outside the screen, targets without a
44px hit area (hit-tested at their centre and 21px around it, so a crowding neighbour fails them too) and control text
under 12px. RouteSweepTests runs every feed tab, threads of each kind the town holds, profiles, search, Explore,
lists, groups, filters, scheduled posts, settings and administration (every section opened) and the login page on the
three portrait phones, as client.mobile.fit.* and client.mobile.touch.* cells; screenshots, findings and a contact
sheet go to tests/e2e/out/mobile/. DesktopUnchangedTests holds desktop's control sizes. Category=Mobile.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Inputs, selects and file inputs follow the textareas: --radius-box (.75rem) instead of daisyUI's --radius-field
(2rem), which stays the buttons', so a pill is something to press and a field is a box to fill. The reason sits once
above the input utility; a rounded-* class at the call site still wins (the composer's warning field keeps its joined
corners).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Every daisyUI field reads --radius-field, 2rem in our themes: a pill on a one-line input, but on a textarea several
lines tall the curve ate into its first and last lines. A textarea now takes --radius-box (.75rem, the cards'); it
changes the variable daisyUI reads, so a rounded-* class at the call site still wins (the composer keeps its 1.4rem).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
daisyUI keeps a 1px border on its controls, which the neo layer makes transparent, and paints their gradient as a
background image. A background image starts at the padding box and repeats, so the border strip showed the gradient's
far end: a thin seam along each button's, field's and selector's edges, as if it sat in a second frame (most visible on
the nav menu's pills in the dark theme). The gradients are now painted from the border box, on buttons, inputs,
textareas, file inputs, checkboxes, radios, toggles and the neo-gradient utilities. Checked by rendering each control
in both themes at 4x.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
A mention or hashtag being written at the end of the words is offered, once typing pauses, the accounts or tags
PrivaPub already knows (/api/v2/search, never resolving); a click writes it out. e2e: a town persona's handle,
begun, is offered and completed. CLAUDE.md also says that FEP-5624's moderated comments (PeerTube) show as pending
on a card, as GoToSocial's policies do.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Settings has a Notifications section, also at /notifications/filtered: what notifications from people the persona does
not follow, people who do not follow it, new accounts, private mentions it did not ask for and limited accounts become
(accept, filter, drop), and the filtered ones, one request per account, let in (with that account from then on) or
dismissed. The Notifications tab says when requests wait. e2e: a stranger's like waits in a request reached from the
tab and comes back once let in; the policy and the stranger's permission are put back afterwards (Pasture.Mongo).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Another's profile and posts can be reported from the client (/api/v1/reports): a category, words, and for a remote
account a copy to its server, which PrivaPub sends from its own actor, never naming the persona. Who liked or boosted
a post opens from its card's menu (favourited_by, reblogged_by). e2e: a report from a Mastodon account's profile
reaches PrivaPub's moderators forwarded, and a post's likes and boosts list the persona who gave them; the admin
helper the tests share moves to Support.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
The card marks an edited post with a button that opens its versions, newest first (/statuses/:id/history), each with
its time, warning and words; PrivaPub now has a remote post's earlier versions from Pleroma's formerRepresentations
too. The composer's e2e test opens the history after its edit. The wall test retries smuser's post while Smithereen
has not yet had the persona's Accept.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
PrivaPub hosts what a persona's followers write on its wall (Smithereen's, FEP-400e) and shows posts written on walls
elsewhere to their owners' followers. A card names the wall (privapub.wall, data-wall, linking the profile), and one
written on the persona's own wall is taken off from the card's menu, which PrivaPub tells as a Remove and Smithereen
applies as a deletion. WallTests checks it against Smithereen.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Leaving the feed keeps it in Services/FeedMemory.cs (posts, waiting posts, cursors and scroll; ten minutes, for the same
feed of the same persona), so coming back from a thread or a profile shows it again at once, older pages included,
instead of reading it anew; newer posts come behind the "new" button and through the stream as before.
tests/e2e FeedMemoryTests goes to a thread and back without a new read of the home timeline; the whole suite passes
(123, 5 skipped).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
An Article or a Page with its title, or a titled post longer than a few paragraphs (WriteFreely sends its posts as Notes
with a name), shows its cover, its excerpt and a Read button in a feed, and its whole text on demand; the post opened
in its thread shows whole. A feed of Ghost, WordPress and WriteFreely long reads stays a feed. tests/e2e ArticleTests
checks it with a WriteFreely blog; the render matrix still passes (70).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
GlobePanel built a string of every located post's place on each of its renders, the stats overlay's once a second
included: with two thousand posts loaded, a hundred kilobytes of formatting every second while the page sat idle. It
now looks at the posts only when the feed hands them over, and compares a hash. The feed perf test (Category=Perf)
loads 2000 posts as before (49 s, against 55.6 s for 1740 in the baseline run).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
A profile shows the posts the account pins above its posts (pinned=true: for an account elsewhere, what it features,
which PrivaPub now keeps); the persona pins and unpins its own public and unlisted posts from the card's menu, and
PrivaPub tells its followers. tests/e2e/Actions/PinTests.cs pins from the card and finds the post first on the profile;
the whole e2e suite passes against the reseeded village (122, 4 skipped).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
A Perf test (tests/e2e/Perf/FeedPerfTests, Category=Perf) scrolls load0's
home, thousands of posts from SocialPub's load runs, to 2000 posts. Before:
100 cards took 12 s to reach, 1000 took 437 s, 2000 took 29 minutes; the
main thread was busy almost all of that time, and the page held 224 000 DOM
nodes and 920 MB of .NET heap. Every card re-rendered whenever the list did.
- A card re-renders only when its own events run or its render key changes
(Content.RenderKey: the post, its status, notification, conversation,
place and grouping, where it is shown, the persona and the connection).
2000 cards then took 122 s.
- The list is a <Virtualize>: only the cards near the view are rendered.
The page now holds about 3 000 DOM nodes and 145 MB however long the feed
is, and each page of older posts costs the same (about 1.9 s in the test's
scroll loop) at the 100th post as at the 1700th. The feed tells how many
posts it holds in data-feed-count.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
PrivaPub now fills `moved` once a move is believed; the profile shows it, linking
the new account's profile. The e2e opens the account SocialPub's moves scenario
moved and finds the new one named.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
A Login section in Settings changes the root's password (the current one
given) and deletes the whole login, every persona with it, after its
password and an explicit "I understand"; the client then signs out. Both
use PrivaPub's existing /clientapi/user routes.
The e2e signs up a login of its own, changes its password and deletes it,
checking each against PrivaPub's login. Signing in now waits for either
the feed or an enabled persona chooser, so a login with one persona (which
goes on by itself) and a new persona's empty feed both sign in.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
The Groups page (/groups, in the menu) is where a persona makes a community
or a circle (with its invitation code and an optional password), joins one
by invitation and leaves one. For the groups it runs it edits them, shows
the invitation, lets in or declines the requests to join, and takes members
out, through PrivaPub's new /clientapi/group/members, reject and remove.
The e2e makes a circle, joins it as another persona with the code, and has
the owner take that member out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
What other platforms send now shows as they meant it:
- custom emoji (a post's or account's :shortcode:) as their pictures in
posts, names, content warnings, bios and fields: only the shortcodes the
server listed, only http(s) pictures, never inside a tag, the shortcode
kept as alt text;
- a threadiverse post's downvotes next to its favourites;
- blurhash placeholders until a picture loads (wwwroot/js/blurhash.js, a
small decoder that watches the page), and a picture's focal point;
- an event's online link, participants and capacity, and whether it is
tentative or cancelled.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
PrivaPub lets a persona join another server's event (owner decision
2026-10-05). The card of a remote event shows the persona's participation
(pending, accepted, rejected) with Join or Leave, except for events by
invitation or taken on another site. Joining is public on the event's
server, so the first join asks the persona once, as reactions and votes do,
remembered apart from them (PublicAct).
The e2e joins the event SocialPub's Mobilizon scenario keeps, checks the
participant on Mobilizon and PrivaPub's answer, and leaves it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
GoToSocial lets an author approve replies, likes and boosts first. PrivaPub now asks that author and marks the
persona's reply or boost privapub.approval: pending until the answer, or rejected; the card shows which, with an
hourglass or a cross. One the policy shuts out is refused (422) and shown as the error it is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
A post went out in the reader's own interface language whatever it was
written in. The composer now has a language picker (each language named in
itself), starting at the persona's default from Settings, else the
interface's; a reply starts in its thread's language. The e2e poll test
picks Italian and finds it on PrivaPub.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Settings, which held only the theme, now belong to the persona too:
- its profile (update_credentials, a new PATCH in IHttpService): pictures,
name, biography and fields as written, locked, bot, discoverable,
indexable, and how its posts start (visibility, language, sensitive
media, who may quote them); the composer starts at that visibility;
- the root's personas, each used in turn, and a new one made;
- the accounts it muted or blocked and the servers it blocked, each undone
there, a server blocked from a pasted address or handle;
- its follows, lists, blocks, mutes, blocked servers and bookmarks exported
and imported as the CSV files Mastodon writes (PortabilityService): an
import resolves each row through PrivaPub, one at a time, and can stop.
Administration opens to moderators: reports, resolved there; and for
administrators the servers blocked for everyone and the federation
statistics (traffic by server, delivery success and latency, the crawler).
The Direct tab pages conversations, badges the unread ones, and a card
marks one read or takes it off the list; opening one reads it.
A remote thread is read again 4 and 15 seconds after it opens, since
PrivaPub then fetches the replies its servers hold. A thread its
community locked shows a lock instead of a reply button, reactions stay
visible below a content warning, and a searched handle opens its
account's profile whatever else matched.
The e2e tests cover each: the profile saved and read back, a persona made,
servers blocked and imported, follows exported, an instance block and the
statistics as the pasture's admin, a conversation read and removed, and a
reply only Mastodon held appearing in its thread.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
A test whose failure an open gap of the town's gaps.json names (a peer's
own bug, such as Misskey keeping its renote count after our Undo, G-0005)
is now skipped with the gap's id instead of failing the run; the report
still counts the cell an expected failure. The reaction test starts by
withdrawing a reaction a run cut short left behind, so one failure no
longer makes the next run fail too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
The composer can hold a poll instead of media (two to four choices, a
length, one choice or several), quote a post (offered on a card when the
server says the persona may quote it), and edit the persona's own post: its
words and warning come from /source, and saving keeps its media,
visibility and poll. A card's menu offers Edit and Quote, and now opens
sideways: upwards, the first card's menu was cut off by the top of the
feed. The e2e tests publish a poll, edit a post and quote it, each checked
on PrivaPub.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
The Notifications tab reads grouped notifications (/api/v2/notifications):
likes and boosts of one post, or follows of one hour, are one card naming
both accounts of a pair, or the newest and how many others, with their
avatars. A notification the stream tells joins its group and waits on top.
The tab's badge counts what is unread; opening the tab moves the
notifications marker to the newest shown, which reads it on PrivaPub.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
decePub opens PrivaPub's streaming API (one WebSocket for the persona, its
token given as the socket's protocol, never in a URL) and subscribes to the
stream of the feed in view: home, a list, local, federated, a hashtag or
notifications. New posts wait behind the "new" button as polled ones did;
a deletion or an edit applies at once. Polling stays for the feeds PrivaPub
does not stream and for any feed while the socket is down, which opens again
after a pause growing to a minute. The e2e test sees a post made elsewhere
in seconds and its deletion take the card away.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
The composer's clock button gives a post a time (scheduled_at, at least five
minutes ahead; located and group posts have none), and /scheduled moves or
drops what waits. A hashtag's feed tab shows its week and follows it, so its
public posts come home. Explore (/explore) shows what PrivaPub says is
trending, hashtags, posts and links from public posts it holds, the
directory by activity or by joining, and the hashtags the persona follows.
DiscoveryService does the calls; the e2e tests check each against PrivaPub's
own answer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
The persona's filters (PrivaPub's /api/v2/filters): made, given words and
whole-word rules, contexts, an action and an expiry on /filters (linked from
Settings), and deleted; posts filtered one by one can be taken out. Every
status says which filters it matches; each page tells its cards where they
are (home and lists, notifications, public feeds, the thread around the
opened post, profiles), and a card leaves its post out (hide), folds it
behind the filter's title with a way to show it anyway (warn), or blurs its
media (blur). The e2e test folds and then hides a post of the persona's own.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
A persona's lists (PrivaPub's /api/v1/lists): made, renamed, given a
replies policy and made exclusive on /lists, filled from the accounts it
follows (accounts/search?following=true), and read as the feed's Lists tab.
A followed account's profile has a Lists menu to put it in or take it out.
ListService does the calls; the e2e tests check each step against
PrivaPub's own answer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
tests/e2e (Playwright for .NET 1.61.0 on xunit v3, the Chromium 1228 already
cached, never downloaded, never in CI) runs the published app against
SocialPub's pasture and its town: run.sh publishes it with the Pasture
environment (wwwroot/appsettings.Pasture.json, published only then; the
deploy fails if it ever ships) and serves it as https://decepub.test. The
tests sign in through the real login page and persona chooser, check that
every request goes only to decepub.test and privapub.test with the right
credential on the right API, render one post of each kind from each
platform as its author wrote it, and check every action on the other
side of the wire (town.sh stored): favourites, boosts and replies with
their undoing, follows from a profile, votes, reactions. Each test is also
a cell of the town's report (out/client.jsonl). They found two PrivaPub
bugs fixed there (a blocked author's status answered null; sensitive
remote media invented a content warning) and a reply addressed to nobody.
New in the app, all checked by those tests:
- profiles at /account/{id}: header, bio, fields, counts, the account's
posts as posts, posts and replies, or media, and follow, unfollow,
cancel a request, mute and block (AccountService); post authors and
notifications open them in the app;
- search at /search: accounts, posts and hashtags, a handle or an address
resolved by PrivaPub;
- poll voting on the card, and emoji reactions shown and given with one
tap; before a persona's first reaction or vote it is told once that
both are public (PublicNoticeService, the owner's decision);
- follow requests answered from their notification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
The Sun lights the globe where it really is (wwwroot/js/sun.js, the Astronomical Almanac's low-precision solar
coordinates): the day map shaded by the Sun's height and the Earth–Sun distance, warm city lights on the night side,
and a twilight blend about 13° wide between them. It shows now, refreshed every 30 s, or the moment of the hovered,
focused or pinned post: a click on a card pins it, and the subsolar point travels there the shorter way round. The
panel's top corner shows that moment, the phase of the day and solar time at the post's place, and the season in its
hemisphere (or in both, for a post without a place). Its tooltip shows the subsolar point, declination, axial tilt,
distance, irradiance, day length and the next equinox or solstice.
Land borders are thin paths. Country names are in the reader's language and appear as the camera comes closer:
largest countries first, away from the globe's edge, never overlapping. They come from world-atlas 2.0.2 (Natural
Earth 1:50m), prepared by tools/globe/prepare-countries.py, and are vendored with their hashes in SOURCES.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
The globe is a UV sphere: towards the poles its triangles narrow to
slivers, and texture coordinates interpolated across them pinched the
map into a star around each pole. Each pixel now computes its own
coordinates from its exact point on the sphere, with seam-safe mipmap
gradients; the map wraps horizontally and gets 8x anisotropic
filtering.
That showed a flaw in the day texture: NASA's mosaic has a lighter
strip at its east edge near the poles, a line from each pole once drawn
exactly. The day texture is now built from NASA's original Blue Marble
(December 2004) by tools/globe/prepare-day-texture.py, which blends
that strip into the map where both sides of the seam agree; same
4096x2048 size, provenance and hashes in SOURCES.md.
globe.gl redrew 60 times a second even when nothing moved; it now draws
only during camera flights, hover pulses, auto-rotation and the
reader's drags, so an idle globe costs no GPU time.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
A post whose server is behind a CDN has no place of its own: the globe
groups such posts per CDN in labels on a ring above the Arctic, and
hovering one draws its arc to the CDN's group and a ring where the
server was before the CDN. The card's CDN chip names the CDN, its
domain, how PrivaPub found it (published ranges, response headers or
network), the place before the CDN and the CDN's servers week by week
(/api/privapub/v1/cdns/:domain); the author's tooltip lists the server's
changes of place and CDN week by week (/instances/:host/history). Both
are read on the first hover and kept for the session.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
A display utility on a dropdown's content (flex, grid) beats daisyUI's
display:none on a closed dropdown, so the content stayed invisible
(opacity 0) but in the way of the pointer. The post card's menu, which
opens upwards, covered the card's place and CDN chips and blocked their
tooltips. Closed dropdown content is now hidden from the pointer too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
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
One sign-in for both of PrivaPub's APIs: the root logs in on /clientapi,
picks a persona, and each persona gets its own Mastodon token in
exchange for the JWT (PersonaExchange). HttpService picks credentials
and JSON by route (JWT and camelCase for /clientapi, the persona token
and snake_case for /api, nothing for /oauth), retries a refused persona
token once, and records every request for the nerd stats.
The feed page is three columns: navigation with a persona switcher, the
feed with a tab per kind (home, local, federated, nearby, communities
and circles, direct, notifications, favourites, bookmarks, hashtags),
and a 3D globe that looks at the spherical mean of the posts in view
and draws a pulse and an arc to a hovered one. Posts sit at their own
place, their event's venue, the reader (located posts) or their
author's server as PrivaPub publishes it. Cards favourite, boost,
bookmark, reply, mute, block and delete; the composer posts with a
content warning, visibility, media and alt text, or as a located post.
Older pages load as the end nears; newer ones are polled while visible.
Nerd stats in tooltips: provenance, author and server, place, media,
counts, paging, the globe's camera and frame rate.
The mock (Faker, MessagesService, the Message models and store) and the
OIDC template leftovers are gone. Strings in English and Italian.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
globe.gl 2.46.2 (MIT, three.js r185 bundled) and the NASA Blue Marble
and Black Marble textures from three-globe 2.45.3, copied once from the
npm registry; SOURCES.md records versions, licences and sha256, and the
bundled libraries' notices are in LICENSES-bundled.txt. The service
worker leaves the folder out of the offline precache: the feed loads it
the first time it shows the globe.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
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
AllStrings.resx was empty, so all 74 UI keys rendered as their own text. It now
holds every key the app uses, with English values. That includes:
- the members of the enums shown through Localizer[value.ToString()], such as
FollowersOnly and PlainText;
- the new toast, HttpService, ErrorViewer and Settings strings.
AllStrings.it.resx has the same keys in Italian.
ErrorMessages.resx gains MaxLength, with its Designer property, and an Italian
sibling. MessageForm's [StringLength] referenced that key, which didn't exist,
so failing that validation threw at runtime.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Ported from the owner's b2b.next and adapted from MudBlazor to the daisyUI
components:
- LocalizableComponentBase provides CascadingState, Navigation, Toast,
IsLoading/IsDefaultDisabled, WhenOnline/StartOnlinePolling, and after-render
jobs that actually run.
- CascadingState starts with initialised state and polls the online status
with a PeriodicTimer. It reports errors through ILoggingService plus a toast,
and disposes what it holds.
- New pieces:
- ToastService + ToastHost, the snackbar, with AddResponseError/Success/
Warning/Exception in SUtility;
- ErrorViewer, which shows the validation messages plus AdditionalError(s);
- LoggingService, which writes to the console and the IndexedDB client log.
- HttpService is rebuilt and registered:
- auth levels Get/Post/Delete, *Anon and *TotallyAnon;
- retry with backoff;
- classified failures (FailureCodes) instead of exceptions;
- HTTP 410 on cancel;
- the "default" client.
ReadWebResult and DefaultReadFromJsonAsync read its responses.
- ClientJsonContext is source-generated, and SUtility.DefaultSerializer and
Blazored.LocalStorage use it.
- WebResult gains a C# 14 IsInvalid extension property.
The code follows the imported rules:
- Every service and async helper takes a CancellationToken last.
- Primary constructors replace constructor-plus-fields.
- MessagesService and IStorage return Task<WebResult> / Task<List<T>>.
MessagesService is still mock, building its results in memory; the seed
feed and thread moved to Faker.
- Index and ExpandMessage follow the page pattern: an offline guard with
WhenOnline, LoadData, and early returns with toasts on the results they used
to ignore. ExpandMessage reloads on route change and shows "not found"
instead of a null message.
- Model and state collections are [], and the fake identity defaults moved
out of the models.
- Components:
- no ?. chains over initialised state;
- [Inject] instead of @inject;
- IsDefaultDisabled on the actions;
- autocomplete on every text input;
- Localizer[key, args] instead of string.Format;
- the localizer no longer leaks its inner _pLocalizer.
- Settings → General gains a language picker (English/Italiano), and
SetDefaultCulture limits the culture to those two.
Bugs fixed along the way:
- A compose with untouched (null) content passed the empty check.
- File errors were joined with <br/> and rendered as markup.
- ClientLogs.ErrorMessage() threw on warnings.
- CoalescingStringLocalizer[key, args] showed a raw "{0}" for missing keys.
- AppStatusService exposed a call to a JS function that doesn't exist.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
The styleguide loaded 42 images from picsum.photos and linked demo items to
daisyui.com. It now uses six small SVG landscapes in wwwroot/imgs/styleguide/
(the diff demo greys one with the grayscale utility), and its demo links stay on
/styleguide.
It also gains a carousel demo with navigation and indicators.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
These were left out because daisyUI does them with scripts or with links that
<base href="/"> breaks.
DCarousel:
- ShowNavigation adds previous/next links on each slide, and ShowIndicators adds
a row of numbered links under the strip.
- The links point at the slides' ids under the current path, so Blazor treats
them as same-page fragments and scrolls the strip.
DCheckbox:
- Indeterminate renders aria-checked="mixed", which daisyUI draws like
:indeterminate.
- A click clears it through IndeterminateChanged.
- The neo inset well leaves the mixed state alone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Logout:
- It navigated to "authenticatio/logout", a misspelled route.
- The correct template route, authentication/logout, would throw anyway. Its
RemoteAuthenticatorView casts the AuthenticationStateProvider to the OIDC
template's service, and the app registers TokenAuthStateProvider instead.
- It now signs out through TokenAuthStateProvider.LogoutAsync, as the code
commented out in the page intended, and returns home. LoginDisplay's sign-out
goes through it as well.
- This also drops the obsolete SignOutSessionStateManager, the build's last
warning.
The IndexedDB schema never declared the ClientLogs store that IStorage.AddLog
writes to, so every client log write failed inside its try/catch. Version 2 adds
the store, keyed by the Id AddLog assigns.
DInputBase.NameAttribute renders no name instead of name="" when the form maps
no field names.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
With the default settings (follow the system, light grey) the nav's hue slider
and dark toggle did nothing visible, and no UI could leave that mode. The slider
also always wrote the light hue, even in dark mode.
PageSettings gains SetDarkMode, SetGray and SetHue, and CascadingState gains
UpdatePageSettings. They keep the flags consistent:
- choosing light or dark leaves system theming;
- picking a hue leaves grey;
- grey follows the variant shown.
theme.js reports which variant is showing (AppStatusService.IsDarkTheme), so the
nav toggle and slider edit that one, including the OS's choice in system mode.
Settings > General, which was empty, now edits every theme setting:
- follow the system;
- dark mode;
- grey;
- the light and dark hues.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Builds used to download the Tailwind binary and the daisyUI plugins from GitHub
releases. They are now committed in tools/tailwind/:
- the Tailwind 4.3.3 standalone binary for linux-x64, xz -9e compressed (28 MB);
- daisyui.mjs and daisyui-theme.mjs 5.7.47;
- daisyUI's llms.txt component reference;
- Tailwind's MIT license.
tools/tailwind.sh no longer touches the network. "prepare" checks every file
against its pinned sha256 and unpacks the binary into .tools/. MSBuild reruns it
only when the archive or the script changes. A build from scratch was checked
inside a network namespace with no network at all.
.gitattributes keeps the vendored files byte-exact, and Tailwind no longer scans
tools/, whose llms.txt names every daisyUI class.
Both workflows used actions/checkout, which a Gitea runner downloads from
github.com. They now git fetch the commit from this Gitea, as they already did
for SocialPub.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Nothing loads the old bundle any more, so it goes, along with everything that
built it and the csproj entries that pointed at it:
- SCSS/, including the vendored Bulma 0.9.4 sass;
- compilerconfig.json and bundleconfig.json;
- tailwind.config.js and terminal-script.txt;
- wwwroot/css/style.min.css, main.css, main.min.css and tailwind-override.css;
- wwwroot/vendor's bulma, tailwind and toggle-dark-light-mode CSS.
The unused Font Awesome and Open Iconic assets stay.
The deploy workflow now requires css/app.css and its .gz in the publish output.
The .gz only exists when the generated file was registered as a static web
asset, so the check catches a silent regression of that. It also checks for
js/theme.js.
CLAUDE.md, new on this branch, documents the repo:
- the no-Node Tailwind/daisyUI build;
- the theme runtime;
- the neo layer, including the cascade-layer rule its overrides must follow;
- the rules for the D components.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
The pages and components now use the daisyUI wrappers and the neo-* utilities
instead of Bulma and the neomorph classes:
- buttons are DButton, with pressed states as Active;
- nav links are btn / btn-active;
- the filter and composer selects are DSelect with a prefix icon;
- the composer's fields are DInput and DTextarea;
- the message options and scope pickers are DDropdown, which now close on an
outside click and on Escape;
- the settings and admin sections are DCollapse;
- the dark-mode switch is a DToggle with sun and moon icons;
- the hue slider is a DRange on the rainbow track.
DropdownButton, OpenDownContainer and LoadingData are gone. The loader is
DLoading, whose animation runs; the old one's keyframes were never defined.
Icons coloured with Bulma's has-text-* now get daisyUI's text-info, success,
warning and error, which had no rules before.
Tailwind 3 to 4 fixes:
- space-x/y become gap, which also handles the flex-col-reverse feeds;
- !rounded-lg becomes rounded-lg!;
- drop-shadow becomes drop-shadow-sm;
- text-md becomes text-base.
The thread page marks its message with Content's new Highlighted parameter
rather than a second shadow size.
neo.css: a select's prefix icon gets the room daisyUI leaves for a span.label,
and the inner select lets the well show through.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Components/Daisy/ wraps all 69 components in daisyUI's llms.txt, grouped by
daisyUI's categories (Actions, DataDisplay, Navigation, Feedback, DataInput,
Layout, Mockup), with part components where daisyUI has repeating parts (DTab,
DMenuItem, DTimelineItem, ...).
- Each enum maps to whole literal class names in a switch, so Tailwind's scanner
generates them. Nothing is concatenated.
- Value-holding fields derive from DInputBase<TValue>, Blazor's InputBase, so
@bind-Value and EditForm validation work. Invalid fields get the component's
*-error class.
- Open and active state lives in C# (@bind-IsOpen, @bind-Active) and is rendered
with daisyUI's *-open modifiers. DDropdown adds an outside-click layer and
Escape. No component uses JS interop.
- The one exception is DCalendar, which wraps the Cally web component, vendored
as wwwroot/vendor/cally.js (0.9.2, MIT).
/styleguide (not linked) renders every component in all its colours, sizes and
states, with a theme and hue picker that previews without saving.
neo.css follows what the styleguide showed:
- select keeps daisyUI's arrow;
- skeleton-text isn't sunk;
- the phone mockup and dock are raised;
- active menu items and OTP digits are inset;
- a joined accordion is raised as one block.
The primary, secondary and accent colours are deeper, so they still read as
text.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
wwwroot/js/theme.js now picks the theme. It sets data-theme (neo-light or
neo-dark, or none when the OS decides) and the hue and chroma variables on
<html>, using the same flag precedence as the old CascadingState branches. It
also keeps <meta name="theme-color"> in sync. Because it runs in <head> from the
settings saved in localStorage, a reload no longer flashes the default palette
before Blazor boots. With system theming it also follows the OS when the colour
scheme changes.
CascadingState loses its ~200 lines of generated <style> and <Meta> tags. It now
calls AppStatusService.ApplyTheme on load and whenever the settings change. The
--fa-* icon colour variables are gone; nothing read them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Styles/theme.css defines two daisyUI themes, neo-light (default) and neo-dark
(prefersdark). Every colour is an hsl() of three runtime variables:
--neo-hue-light, --neo-hue-dark and --neo-chroma. One formula replaces the six
hard-coded branches CascadingState writes today. Chroma 0 gives the grey themes,
and the fallbacks match PageSettings' defaults.
Styles/neo.css ports neomorph.scss:
- neo-raised, neo-inset and neo-flat, with sizes neo-2xs, neo-xs, neo-sm and
neo-md. The size variables don't inherit, and Tailwind variants replace the
old -sm..-2xl classes, some of which were never compiled.
- The pressed and raised gradients, and the rainbow hue-slider track.
- @utility overrides that give the neumorphic look to daisyUI's buttons, fields,
selectors and surfaces. They sit above daisyUI's own cascade layers, so each
one leaves out the variants it must not touch: ghost, link, outline and the
rest.
Styles/base.css carries over base.scss: scrollbars, selection colours, the
focus outline reset, and link underlines everywhere except on buttons.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
tools/tailwind.sh downloads the pinned Tailwind binary and daisyUI's .mjs
plugins into the gitignored .tools/tailwind/, checking each against a pinned
sha256, so neither developers nor CI need Node. MSBuild compiles
Styles/app.css into wwwroot/css/app.css (also gitignored) on every build,
skipping the step when no source changed. The output is added to Content before
ResolveProjectStaticWebAssets, so a clean-clone publish still gets its .gz/.br
copies and a service-worker-assets.js entry.
index.html now loads css/app.css, plus ionicons as its own stylesheet. The old
bundle stays in the tree until the markup is migrated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
The project referenced ../SocialPub/SocialPub/SocialPub.ClientModels, a path
that has not existed since the server became PrivaPub; it references
../SocialPub/PrivaPub.ClientModels now, and CI clones SocialPub beside it.
Retargeted to net10.0 with the Microsoft packages at 10.0.9.
BlazorDownloadFileFast moves to 1.0.0.1 because 0.2.0 used
IJSUnmarshalledRuntime, which .NET 9 removed. The Visual Studio bundler and
web compiler packages are gone; the bundle they produced, css/style.min.css,
is committed and stays the stylesheet, and its stale .gz is deleted because
.NET's static-asset compression writes that path itself.
AppConfiguration.ApiBaseAddress (https://privapub.thepra.dev) is the API
client's base address; the template's placeholder OIDC section and the
Toolbelt.Blazor.HotKeys script tag, whose package is not referenced, are
removed. The vhost sends noindex while the feed shows the prototype's mock
posts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB