House rules: numbers through one formatter, no made-up server errors, and the rules fail the build
Build / Build (push) Successful in 33s

- Every number shown on a page goes through NumbersExtensions: counts,
  percentages, the nerd readouts and the values written into a style.
- A failure the client builds itself (offline, timeout, retries run out, an
  answer it can't read) is a 400 that names its kind; only a server's own 5XX
  passes through.
- Login, sign-up, recovery and password confirmations leave autocomplete to
  the browser, so it can offer the saved login.
- The Daisy components start with empty lists and attributes, and a
  decorative image still renders alt="".
- Arasaka.HouseRules.Analyzers checks all of this in every build, together
  with PrivaPub.ClientModels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4YtEJrgxG12Pc39Fai8Bd
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-11-10 01:26:39 +01:00
1 parent 697b2c8897
commit 3827d903a3
37 files changed
+165 -111

No files matched your search

+1 -1
View File
@@ -34,7 +34,7 @@
}
@if (AskPassword)
{
<DInput TValue="string" Type="password" class="w-full" @bind-Value="password" Immediate="true" autocomplete="current-password" maxlength="200"
<DInput TValue="string" Type="password" class="w-full" @bind-Value="password" Immediate="true" maxlength="200"
placeholder="@(Staff ? Localizer["Your password"] : Localizer["Current password"])"
aria-label="@(Staff ? Localizer["Your password"] : Localizer["Current password"])"
data-admin-password="@(Staff ? "" : null)" data-own-password="@(Staff ? null : "")" />
+2 -2
View File
@@ -148,7 +148,7 @@
@if (ticket is null)
{
<p class="text-sm">@Localizer["It holds every persona's private keys and private posts: keep it where only you can read it. Your password, to get a link:"]</p>
<DInput TValue="string" type="password" @bind-Value="password" Immediate="true" autocomplete="current-password" maxlength="200"
<DInput TValue="string" type="password" @bind-Value="password" Immediate="true" maxlength="200"
placeholder="@Localizer["Password"]" data-backup-password />
<ErrorViewer AdditionalError="@dialogError" />
}
@@ -182,7 +182,7 @@
{
<p>@Localizer["{0} logins were deleted since; a restore deletes them again.", restoring.RootsDeletedSince]</p>
}
<DInput TValue="string" type="password" @bind-Value="password" Immediate="true" autocomplete="current-password" maxlength="200"
<DInput TValue="string" type="password" @bind-Value="password" Immediate="true" maxlength="200"
placeholder="@Localizer["Password"]" data-backup-password />
<DInput TValue="string" @bind-Value="typedHost" Immediate="true" autocomplete="off" maxlength="253"
placeholder="@Localizer["Type {0} to confirm", backups.Host ?? ""]" data-backup-host />
+1 -1
View File
@@ -43,7 +43,7 @@
<DStats class="w-full overflow-x-auto neo-raised neo-xs" data-statistics-overview data-scroll-x>
<DStat>
<Heading>@Localizer["Deliveries that arrived"]</Heading>
<ChildContent>@(overview.DeliverySuccess is { } success ? $"{success * 100:0.#}%" : "—")</ChildContent>
<ChildContent>@(overview.DeliverySuccess is { } success ? (success * 100).ToDefaultPercent() : "—")</ChildContent>
<Desc>@Localizer["p50 {0} ms, p95 {1} ms", Ms(overview.DeliveryLatencyMs.P50), Ms(overview.DeliveryLatencyMs.P95)]</Desc>
</DStat>
<DStat>
+1 -1
View File
@@ -162,7 +162,7 @@
}
else
{
<span>@Localizer["Shown only to people within {0} km of here, never sent to other servers.", form.RangeKm.ToString("0")]</span>
<span>@Localizer["Shown only to people within {0} km of here, never sent to other servers.", form.RangeKm.ToDefaultCount()]</span>
}
</span>
<DRange TValue="double" Size="DSize.Xs" Min="1" Max="50" Step="1" @bind-Value="form.RangeKm" />
+7 -7
View File
@@ -804,7 +804,7 @@
var option = poll.Options[i];
var share = poll.VotesCount > 0 && option.VotesCount is { } votes ? votes * 100d / poll.VotesCount : 0;
<div class="relative rounded-md overflow-hidden neo-inset neo-2xs px-2 py-1">
<div class="absolute inset-y-0 left-0 bg-primary/20" style="width:@(share.ToString("0.#", System.Globalization.CultureInfo.InvariantCulture))%"></div>
<div class="absolute inset-y-0 left-0 bg-primary/20" style="width:@(share.ToCssNumber())%"></div>
<span class="relative flex justify-between gap-2">
<span>
@option.Title
@@ -813,7 +813,7 @@
<i class="ion-md-checkmark-circle text-primary" title="@Localizer["Your vote"]" aria-label="@Localizer["Your vote"]"></i>
}
</span>
<span>@share.ToString("0")%</span>
<span>@share.ToWholePercent()</span>
</span>
</div>
}
@@ -1318,7 +1318,7 @@
// where a cropped picture keeps its subject: Mastodon's focus runs from -1 to 1, left to right and bottom to top
static string FocalPoint(MediaAttachment media) => media.Meta?.Focus is { } focus
? string.Create(System.Globalization.CultureInfo.InvariantCulture, $"object-position: {(focus.X + 1) * 50:0.#}% {(1 - focus.Y) * 50:0.#}%")
? string.Create(System.Globalization.CultureInfo.InvariantCulture, $"object-position: {((focus.X + 1) * 50).ToCssNumber()}% {((1 - focus.Y) * 50).ToCssNumber()}%")
: null;
static bool IsWebLink(string url) => Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.Scheme is "https" or "http";
@@ -1474,7 +1474,7 @@
yield return NerdRow.Of(Localizer["server"], server.NodeName is { Length: > 0 } name && name != server.Host ? $"{server.Host} ({name})" : server.Host);
yield return NerdRow.Of(Localizer["software"], $"{server.Software} {server.Version}".Trim());
yield return NerdRow.Of(Localizer["Users"], server.Users is { } users
? server.ActiveMonth is { } active ? $"{users:N0} ({active:N0} {Localizer["active this month"]})" : users.ToString("N0")
? server.ActiveMonth is { } active ? $"{users.ToDefaultCount()} ({active.ToDefaultCount()} {Localizer["active this month"]})" : users.ToDefaultCount()
: null);
yield return NerdRow.Of(Localizer["local posts"], server.LocalPosts);
yield return NerdRow.Of(Localizer["registrations"], server.OpenRegistrations is { } open ? open ? Localizer["open"] : Localizer["closed"] : null);
@@ -1553,7 +1553,7 @@
_ => Localizer["Near you"]
});
yield return NerdRow.Of("lat, lng", $"{Coordinate(location.Latitude)}, {Coordinate(location.Longitude)}");
yield return NerdRow.Of(Localizer["precision"], $"±{location.PrecisionKm:0} km");
yield return NerdRow.Of(Localizer["precision"], $"±{location.PrecisionKm.ToDefaultCount()} km");
if (location.Source is LocationSource.Server or LocationSource.Country && ServerGeo is { } geo)
{
yield return NerdRow.Of(Localizer["network"], geo.Network);
@@ -1561,7 +1561,7 @@
yield return NerdRow.Of(Localizer["located at"], geo.LocatedAt);
}
if (InstanceService.Known(InstanceService.SelfHost)?.Geo is { Latitude: { } selfLatitude, Longitude: { } selfLongitude } && location.Source is not LocationSource.NearYou)
yield return new(Localizer["from PrivaPub"], $"{NerdRow.Kilometres(selfLatitude, selfLongitude, location.Latitude, location.Longitude):N0} km");
yield return new(Localizer["from PrivaPub"], $"{NerdRow.Kilometres(selfLatitude, selfLongitude, location.Latitude, location.Longitude).ToDefaultCount()} km");
}
static IEnumerable<NerdRow> MediaRows(MediaAttachment media)
@@ -1570,7 +1570,7 @@
yield return NerdRow.Of("size", media.Meta?.Original?.Size);
yield return NerdRow.Of("aspect", media.Meta?.Original?.Aspect);
yield return NerdRow.Of("length", media.Meta?.Length);
yield return NerdRow.Of("focus", media.Meta?.Focus is { } focus ? $"{focus.X:0.##}, {focus.Y:0.##}" : null);
yield return NerdRow.Of("focus", media.Meta?.Focus is { } focus ? $"{focus.X.ToReadout()}, {focus.Y.ToReadout()}" : null);
yield return NerdRow.Of("blurhash", media.Blurhash);
yield return NerdRow.Of("served by", Uri.TryCreate(media.Url, UriKind.Absolute, out var served) ? served.Host : null);
yield return NerdRow.Of("origin", Uri.TryCreate(media.RemoteUrl, UriKind.Absolute, out var origin) ? origin.Host : null);
+1 -1
View File
@@ -36,7 +36,7 @@
[Parameter] public bool CloseOnContentClick { get; set; }
[Parameter] public string TriggerClass { get; set; }
/// <summary>Attributes of the trigger itself (an aria-label, a data- hook); the outer element takes the unmatched ones.</summary>
[Parameter] public IReadOnlyDictionary<string, object> TriggerAttributes { get; set; }
[Parameter] public IReadOnlyDictionary<string, object> TriggerAttributes { get; set; } = new Dictionary<string, object>();
[Parameter] public string ContentClass { get; set; }
/// <summary>Below md, the content is a sheet at the bottom of the screen instead of hanging from the trigger.</summary>
[Parameter] public bool SheetOnNarrow { get; set; }
+1 -1
View File
@@ -10,7 +10,7 @@ public abstract class DComponentBase : ComponentBase
{
[Parameter] public string Class { get; set; }
[Parameter(CaptureUnmatchedValues = true)] public IReadOnlyDictionary<string, object> AdditionalAttributes { get; set; }
[Parameter(CaptureUnmatchedValues = true)] public IReadOnlyDictionary<string, object> AdditionalAttributes { get; set; } = new Dictionary<string, object>();
protected static string Cx(params string[] classes) => DCx.Join(classes);
}
+2 -2
View File
@@ -7,7 +7,7 @@
<div class="@Class">
@if (Src is not null)
{
<img src="@Src" alt="@Alt" />
<img src="@Src" alt="@(Alt ?? "")" />
}
else
{
@@ -19,7 +19,7 @@
@code {
[Parameter] public string Src { get; set; }
// "" on purpose (the documented exception to "no string defaults"): an empty alt marks a decorative image.
[Parameter] public string Alt { get; set; } = "";
[Parameter] public string Alt { get; set; }
/// <summary>Shown in the frame when there is no Src, e.g. initials with Placeholder.</summary>
[Parameter] public RenderFragment ChildContent { get; set; }
[Parameter] public DAvatarStatus Status { get; set; }
@@ -4,19 +4,16 @@
across it shows the others. Up to 10 images of the same size, from Images or as <img> children; cap its width in
Class (max-w-60), or it fills the container. *@
<figure @attributes="AdditionalAttributes" class="@Cx("hover-gallery", Class)">
@if (Images is not null)
@foreach (var src in Images.Take(10))
{
foreach (var src in Images.Take(10))
{
<img src="@src" alt="@Alt" />
}
<img src="@src" alt="@(Alt ?? "")" />
}
@ChildContent
</figure>
@code {
[Parameter] public IEnumerable<string> Images { get; set; }
[Parameter] public IEnumerable<string> Images { get; set; } = [];
// "" on purpose (the documented exception to "no string defaults"): an empty alt marks a decorative image.
[Parameter] public string Alt { get; set; } = "";
[Parameter] public string Alt { get; set; }
[Parameter] public RenderFragment ChildContent { get; set; }
}
@@ -5,19 +5,16 @@
duration-* in Class (default 10s). *@
<span @attributes="AdditionalAttributes" class="@Cx("text-rotate", Class)">
<span class="@(Centered ? "justify-items-center" : null)">
@if (Items is not null)
@foreach (var item in Items.Take(6))
{
foreach (var item in Items.Take(6))
{
<span>@item</span>
}
<span>@item</span>
}
@ChildContent
</span>
</span>
@code {
[Parameter] public IEnumerable<string> Items { get; set; }
[Parameter] public IEnumerable<string> Items { get; set; } = [];
[Parameter] public RenderFragment ChildContent { get; set; }
/// <summary>Centre the lines horizontally.</summary>
[Parameter] public bool Centered { get; set; }
+5 -8
View File
@@ -7,19 +7,16 @@
<div @attributes="InputAttributes" class="@Cx("filter", CssClass)">
<input type="radio" name="@GroupName" class="@Cx("btn", "filter-reset", SizeClass)" aria-label="@ResetText"
disabled="@Disabled" @onchange="() => CurrentValue = default" />
@if (Options is not null)
@foreach (var option in Options)
{
foreach (var option in Options)
{
<input type="radio" name="@GroupName" class="@Cx("btn", SizeClass)" aria-label="@(TextOf?.Invoke(option) ?? option?.ToString())"
checked="@IsSelected(option)" disabled="@Disabled"
@onchange="() => CurrentValue = option" />
}
<input type="radio" name="@GroupName" class="@Cx("btn", SizeClass)" aria-label="@(TextOf?.Invoke(option) ?? option?.ToString())"
checked="@IsSelected(option)" disabled="@Disabled"
@onchange="() => CurrentValue = option" />
}
</div>
@code {
[Parameter] public IEnumerable<TValue> Options { get; set; }
[Parameter] public IEnumerable<TValue> Options { get; set; } = [];
/// <summary>An option's button text; ToString() when null.</summary>
[Parameter] public Func<TValue, string> TextOf { get; set; }
[Parameter] public string ResetText { get; set; } = "×";
+1 -1
View File
@@ -103,7 +103,7 @@
/// <summary>The choices can't be made now (the caller is busy with the last one).</summary>
[Parameter] public bool ChoicesDisabled { get; set; }
/// <summary>Passed to the dropdown: its class, a data- hook.</summary>
[Parameter(CaptureUnmatchedValues = true)] public IReadOnlyDictionary<string, object> AdditionalAttributes { get; set; }
[Parameter(CaptureUnmatchedValues = true)] public IReadOnlyDictionary<string, object> AdditionalAttributes { get; set; } = new Dictionary<string, object>();
bool isOpen;
string query;
+2 -2
View File
@@ -251,9 +251,9 @@
yield return NerdRow.Of(Localizer["axial tilt"], $"{Fixed(sun.Obliquity)}°");
yield return NerdRow.Of(Localizer["ecliptic longitude"], $"{Fixed(sun.EclipticLongitude)}°");
yield return NerdRow.Of(Localizer["Earth–Sun distance"],
$"{sun.Distance.ToString("0.00000", CultureInfo.InvariantCulture)} AU ({sun.Distance * 149_597_870.7:N0} km)");
$"{sun.Distance.ToFixed(5)} AU ({(sun.Distance * 149_597_870.7).ToDefaultCount()} km)");
yield return NerdRow.Of(Localizer["irradiance"],
$"{sun.Irradiance * 1361:0} W/m² ({Math.Round((sun.Irradiance - 1) * 100, 1).ToString("+0.0;-0.0;0.0", CultureInfo.InvariantCulture)}%)");
$"{(sun.Irradiance * 1361).ToDefaultCount()} W/m² ({Math.Round((sun.Irradiance - 1) * 100, 1).ToSignedPercent()})");
yield return NerdRow.Of(Localizer["Place"], sun.Place is { } place ? $"{place} ({Fixed(sun.PlaceLatitude)}, {Fixed(sun.PlaceLongitude)})" : null);
yield return NerdRow.Of(Localizer["elevation"], sun.Elevation is { } elevation ? $"{Fixed(elevation)}°" : null);
yield return NerdRow.Of(Localizer["day length"], sun.DayLength is { } hours ? NerdRow.Duration(TimeSpan.FromHours(hours)) : null);
+7 -5
View File
@@ -1,5 +1,7 @@
using System.Globalization;
using decePubClient.Extensions;
namespace decePubClient.Components.Nerd;
/// <summary>One line of a NerdTip; a row without a value is not shown.</summary>
@@ -9,7 +11,7 @@ public sealed record NerdRow(string Label, string Value)
{
null => null,
DateTime time => Time(time),
double number => number.ToString("0.###", CultureInfo.InvariantCulture),
double number => number.ToReadout(),
bool flag => flag ? "yes" : "no",
IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture),
_ => value.ToString()
@@ -27,8 +29,8 @@ public sealed record NerdRow(string Label, string Value)
span = span.Duration();
return sign + (span.TotalSeconds switch
{
< 1 => $"{span.TotalMilliseconds:0} ms",
< 60 => $"{span.TotalSeconds.ToString("0.0", CultureInfo.InvariantCulture)} s",
< 1 => $"{span.TotalMilliseconds.ToFixed(0)} ms",
< 60 => $"{span.TotalSeconds.ToFixed(1)} s",
< 3600 => $"{span.Minutes} min {span.Seconds} s",
< 86400 => $"{(int)span.TotalHours} h {span.Minutes} min",
_ => $"{(int)span.TotalDays} d {span.Hours} h"
@@ -40,8 +42,8 @@ public sealed record NerdRow(string Label, string Value)
{
null => null,
< 1024 => $"{bytes} B",
< 1024 * 1024 => $"{(bytes.Value / 1024d).ToString("0.0", CultureInfo.InvariantCulture)} KiB",
_ => $"{(bytes.Value / 1048576d).ToString("0.0", CultureInfo.InvariantCulture)} MiB"
< 1024 * 1024 => $"{(bytes.Value / 1024d).ToFixed(1)} KiB",
_ => $"{(bytes.Value / 1048576d).ToFixed(1)} MiB"
};
/// <summary>The great-circle distance between two points in kilometres (haversine, Earth radius 6371 km).</summary>
+5 -5
View File
@@ -58,9 +58,9 @@
{
<EditForm Model="change" OnValidSubmit="ChangeEmail" class="flex flex-col gap-2" data-account-email-change>
<DataAnnotationsValidator />
<DInput TValue="string" Type="email" @bind-Value="change.Email" Immediate="true" autocomplete="email" inputmode="email" autocapitalize="none"
<DInput TValue="string" Type="email" @bind-Value="change.Email" Immediate="true" inputmode="email" autocapitalize="none"
spellcheck="false" placeholder="@Localizer["New email address"]" data-account-email-new />
<DInput TValue="string" Type="password" @bind-Value="change.Password" Immediate="true" autocomplete="current-password"
<DInput TValue="string" Type="password" @bind-Value="change.Password" Immediate="true"
placeholder="@Localizer["Current password"]" data-account-email-password />
<ErrorViewer />
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
@@ -75,7 +75,7 @@
{
<EditForm Model="emailRemoval" OnValidSubmit="RemoveEmail" class="flex flex-col gap-2" data-account-email-remove>
<DataAnnotationsValidator />
<DInput TValue="string" Type="password" @bind-Value="emailRemoval.Password" Immediate="true" autocomplete="current-password"
<DInput TValue="string" Type="password" @bind-Value="emailRemoval.Password" Immediate="true"
placeholder="@Localizer["Current password"]" data-account-email-remove-password />
<ErrorViewer />
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
@@ -107,7 +107,7 @@
<DataAnnotationsValidator />
<b class="text-sm">@Localizer["Change the password"]</b>
<p class="text-xs opacity-70">@Localizer["Every other device signed in to this login is signed out."]</p>
<DInput TValue="string" type="password" @bind-Value="password.OldPassword" Immediate="true" autocomplete="current-password"
<DInput TValue="string" type="password" @bind-Value="password.OldPassword" Immediate="true"
placeholder="@Localizer["Current password"]" data-account-current />
<div class="flex flex-wrap gap-2">
<DInput TValue="string" type="password" class="flex-1 min-w-40" @bind-Value="password.NewPassword" Immediate="true" autocomplete="new-password"
@@ -135,7 +135,7 @@
<DataAnnotationsValidator />
<b class="text-sm text-error">@Localizer["Delete this login"]</b>
<p class="text-sm">@Localizer["Every persona of this login goes with it, and everything public they had, here and on the servers that hold it. It cannot be undone."]</p>
<DInput TValue="string" type="password" @bind-Value="removal.Password" Immediate="true" autocomplete="current-password"
<DInput TValue="string" type="password" @bind-Value="removal.Password" Immediate="true"
placeholder="@Localizer["Current password"]" data-account-delete-password />
<DCheckbox @bind-Value="understood" Size="DSize.Sm" LabelText="@Localizer["I understand: delete this login and every persona of it"]" data-account-delete-understood />
<ErrorViewer />
+2 -2
View File
@@ -65,7 +65,7 @@
{
<EditForm Model="disable" OnValidSubmit="Disable" class="flex flex-col gap-2" data-totp-disable>
<DataAnnotationsValidator />
<DInput TValue="string" Type="password" @bind-Value="disable.Password" Immediate="true" autocomplete="current-password"
<DInput TValue="string" Type="password" @bind-Value="disable.Password" Immediate="true"
placeholder="@Localizer["Current password"]" data-totp-disable-password />
@if (disableWithRecoveryCode)
{
@@ -133,7 +133,7 @@
{
<EditForm Model="setupPassword" OnValidSubmit="Setup" class="flex flex-col gap-2" data-totp-setup>
<DataAnnotationsValidator />
<DInput TValue="string" Type="password" @bind-Value="setupPassword.Password" Immediate="true" autocomplete="current-password"
<DInput TValue="string" Type="password" @bind-Value="setupPassword.Password" Immediate="true"
placeholder="@Localizer["Current password"]" data-totp-setup-password />
<ErrorViewer />
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">