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
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 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
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
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
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
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
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
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
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
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
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
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