diff --git a/src/MUI.Catalog/Facets.cs b/src/MUI.Catalog/Facets.cs
index f2cf7fc..cc45750 100644
--- a/src/MUI.Catalog/Facets.cs
+++ b/src/MUI.Catalog/Facets.cs
@@ -41,6 +41,103 @@ public static class FacetKeys
public const string Family = "family";
public const string Genre = "genre";
+
+ ///
+ /// What order the listing comes back in. A filter parameter by spelling and by plumbing, so the
+ /// panel, the page and the read API cannot grow two words for one question.
+ ///
+ public const string Sort = "sort";
+}
+
+///
+/// The orders the catalogue can be read in.
+///
+///
+///
+/// Every one of these sorts on a fact already on the row. There is no "busiest" here and the
+/// word is deliberately not used: /rankings means something specific by it — a median over a
+/// window with a sample floor under it — and a sort that reads one instantaneous count would be a
+/// cruder question wearing the same name. This is "players on now", which is exactly what it orders
+/// by and exactly as much as it claims.
+///
+///
+/// There is no "recently listed". The only date we have for that is game.first_seen_at,
+/// which is when our crawler first reached a game — a picture of where the frontier has got
+/// to, not of anything happening in the hobby (the same reasoning that keeps it off the adoption
+/// curves, see EcosystemDashboard). Sorted to the top of the catalogue it would read as "new
+/// games", which is a claim we would be making out of our own schedule. The newly discovered
+/// feed publishes the same dates with the framing that makes them honest, and that is where it stays.
+///
+///
+public enum GameSort
+{
+ ///
+ /// Alphabetical, and the default.
+ ///
+ ///
+ /// The default is the one order that ranks nobody. Every other sort in this enum puts some games
+ /// above others on a measurement, and a listing that arrives pre-ranked is making an editorial
+ /// claim the reader never asked for — the same objection this project has to star ratings, one
+ /// step removed. Sorting is a thing a reader chooses.
+ ///
+ Name,
+
+ /// Most players counted on right now, first.
+ Players,
+
+ /// Most recently reached first.
+ Reached,
+}
+
+///
+/// The listing's order, and what it does with the games a sort cannot rank.
+///
+///
+///
+/// An unknown is never a zero and never sorts as one. Most of this catalogue answers with
+/// nothing we can count — we got in and the WHO was past our parser, or the game published no
+/// PLAYERS — and null ordered as 0 would pile every one of those at the bottom
+/// of "players on now" indistinguishably from the games we measured and found empty. That is the
+/// central claim of this project made backwards, on the page it is most likely to be read off.
+///
+///
+/// So the games a sort can rank come first, in order, and the ones it cannot follow as a group in
+/// the default order. is the same question the surfaces ask to know where to
+/// draw the line and what to call the group, so the ordering and the label cannot disagree about
+/// which games are in it.
+///
+///
+public static class GameSorting
+{
+ /// Whether this sort has nothing to rank a game by — never "whether it is zero".
+ public static bool IsUnranked(GameSummary game, GameSort sort)
+ {
+ ArgumentNullException.ThrowIfNull(game);
+
+ return sort switch
+ {
+ GameSort.Players => game.PlayersNow is null,
+ GameSort.Reached => game.LastReachableAt is null,
+ _ => false,
+ };
+ }
+
+ public static IReadOnlyList Apply(IEnumerable games, GameSort sort)
+ {
+ ArgumentNullException.ThrowIfNull(games);
+
+ // Ranked before unranked, always — then the sort's own key, then the name, so the order is
+ // total and a listing does not shuffle between two identical requests.
+ var ordered = games
+ .OrderBy(g => IsUnranked(g, sort) ? 1 : 0)
+ .ThenByDescending(g => sort is GameSort.Players ? g.PlayersNow ?? 0 : 0)
+ .ThenByDescending(g => sort is GameSort.Reached
+ ? g.LastReachableAt ?? DateTimeOffset.MinValue
+ : DateTimeOffset.MinValue)
+ .ThenBy(g => g.Name, StringComparer.OrdinalIgnoreCase);
+
+ return [.. ordered];
+ }
}
///
@@ -377,7 +474,10 @@ public static GameListing Search(IReadOnlyList rows, GameFilter fi
groups.AddRange(Presence(results, filter));
- return new GameListing([.. results.Select(r => r.Summary)], groups);
+ // Ordered after the counting, never before it. Every facet count is taken over a set, and a
+ // set has no order — so sorting here cannot move a number, which is what lets the panel go on
+ // promising exactly what a click returns whichever way the list is arranged.
+ return new GameListing(GameSorting.Apply(results.Select(r => r.Summary), filter.Sort), groups);
}
///
@@ -656,6 +756,9 @@ public static class FacetTokens
public static IReadOnlyList LastSeenBands { get; } =
[.. Enum.GetValues().Select(Of)];
+ public static IReadOnlyList Sorts { get; } =
+ [.. Enum.GetValues().Select(Of)];
+
/// The three windows that nest, widest last.
private static readonly string?[] Nested =
[Of(LastSeenBand.Day), Of(LastSeenBand.Week), Of(LastSeenBand.Month)];
@@ -684,10 +787,14 @@ public static class FacetTokens
public static string Of(LastSeenBand band) => Camel(band.ToString());
+ public static string Of(GameSort sort) => Camel(sort.ToString());
+
public static bool TryBand(string? text, out ActivityBand band) => TryRead(text, out band);
public static bool TryLastSeen(string? text, out LastSeenBand band) => TryRead(text, out band);
+ public static bool TrySort(string? text, out GameSort sort) => TryRead(text, out sort);
+
///
/// Reads one of the derived vocabularies, forgivingly about separators and strictly about
/// everything else.
diff --git a/src/MUI.Catalog/Views.cs b/src/MUI.Catalog/Views.cs
index 47c95b8..a1c2621 100644
--- a/src/MUI.Catalog/Views.cs
+++ b/src/MUI.Catalog/Views.cs
@@ -197,6 +197,17 @@ public sealed record GameFilter
///
///
public FacetChoice? CodebaseFamily { get; init; }
+
+ ///
+ /// What order the answer comes back in.
+ ///
+ ///
+ /// A question about presentation, and still part of the filter, because the URL is the whole of
+ /// this page's state: a sorted listing has to be linkable exactly as a filtered one is, and the
+ /// read API has to answer the same question the page asked. is the
+ /// default and is the one order that ranks nobody.
+ ///
+ public GameSort Sort { get; init; } = GameSort.Name;
}
///
diff --git a/src/MUI.Web/Api/ApiModels.cs b/src/MUI.Web/Api/ApiModels.cs
index 1abfe5e..35629d2 100644
--- a/src/MUI.Web/Api/ApiModels.cs
+++ b/src/MUI.Web/Api/ApiModels.cs
@@ -195,7 +195,8 @@ public sealed record FilterView(
string? Family,
string? Genre,
string? Language,
- string? CodebaseFamily)
+ string? CodebaseFamily,
+ GameSort Sort)
{
public static FilterView Of(GameFilter filter)
{
@@ -213,7 +214,8 @@ public static FilterView Of(GameFilter filter)
filter.Family?.Token,
filter.Genre?.Token,
filter.Language?.Token,
- filter.CodebaseFamily?.Token);
+ filter.CodebaseFamily?.Token,
+ filter.Sort);
}
}
diff --git a/src/MUI.Web/Api/GameFilterBinding.cs b/src/MUI.Web/Api/GameFilterBinding.cs
index 4803263..69f9b6e 100644
--- a/src/MUI.Web/Api/GameFilterBinding.cs
+++ b/src/MUI.Web/Api/GameFilterBinding.cs
@@ -63,7 +63,9 @@ private static bool TryRead(
{
result = null!;
- if (!TryBand(read, out var band, out error) || !TryLastSeen(read, out var seen, out error))
+ if (!TryBand(read, out var band, out error)
+ || !TryLastSeen(read, out var seen, out error)
+ || !TrySort(read, out var sort, out error))
{
return false;
}
@@ -92,6 +94,7 @@ private static bool TryRead(
CodebaseFamily = string.IsNullOrWhiteSpace(codebaseFamily)
? null
: FacetChoice.Parse(codebaseFamily.Trim()),
+ Sort = sort,
};
result = new GameQuery(
@@ -175,6 +178,32 @@ private static bool TryLastSeen(Func read, out LastSeenBan
return true;
}
+ ///
+ /// The listing's order. Refused rather than ignored, like every other unreadable facet: a
+ /// consumer who asked for ?sort=busiest and silently got the alphabet would read the first
+ /// name on the page as the busiest game on the site.
+ ///
+ private static bool TrySort(Func read, out GameSort sort, out string? error)
+ {
+ sort = GameSort.Name;
+ error = null;
+ var text = read(FacetKeys.Sort).ToString();
+
+ if (string.IsNullOrWhiteSpace(text))
+ {
+ return true;
+ }
+
+ if (!FacetTokens.TrySort(text, out var parsed))
+ {
+ error = $"'{text}' is not a sort order. Accepted: {string.Join(", ", FacetTokens.Sorts)}.";
+ return false;
+ }
+
+ sort = parsed;
+ return true;
+ }
+
private static int Bounded(string? value, int fallback, int min, int max)
{
if (!int.TryParse(value, out var parsed))
diff --git a/src/MUI.Web/Components/ActiveFilters.cs b/src/MUI.Web/Components/ActiveFilters.cs
new file mode 100644
index 0000000..15b0d0f
--- /dev/null
+++ b/src/MUI.Web/Components/ActiveFilters.cs
@@ -0,0 +1,134 @@
+using MUI.Catalog;
+
+namespace MUI.Web.Components;
+
+/// One thing the query is currently asking for, and the URL that stops asking it.
+/// Which question — codebase, search.
+/// What it is asking of that question, polarity included.
+/// The same listing with this one selection dropped and every other kept.
+public sealed record ActiveFilter(string Facet, string Value, string RemoveHref);
+
+///
+/// Everything the current query asks for, read back out of the facets it was applied to.
+///
+///
+///
+/// The panel is not the only place the query is visible: what it is asking for is repeated above the
+/// results as a row of chips, each of which is a link that removes itself. That is what makes the
+/// third state legible — ?codebase=!Evennia renders as codebase · anything but
+/// Evennia, where a <select> scrolled to an option in its second
+/// <optgroup> shows nothing at all until you open it — and it is the only affordance on
+/// the page that can undo one filter without disturbing the rest.
+///
+///
+/// Built from rather than from , deliberately. The
+/// facets already carry each value's , computed by the same pass that
+/// produced the listing, so a chip cannot claim a selection the query did not apply — and a facet
+/// added to the catalogue gets a chip without anything here being told about it.
+///
+///
+public static class ActiveFilters
+{
+ public static IReadOnlyList For(
+ IReadOnlyList facets,
+ GameFilter filter,
+ string? query)
+ {
+ ArgumentNullException.ThrowIfNull(facets);
+ ArgumentNullException.ThrowIfNull(filter);
+
+ var chips = new List();
+
+ if (!string.IsNullOrWhiteSpace(filter.Text))
+ {
+ chips.Add(new ActiveFilter(
+ "search", filter.Text.Trim(), Href(ListingLinks.With(query, FacetKeys.Text, null))));
+ }
+
+ if (filter.CodebaseFamily is { Exclude: false, Value: { } family })
+ {
+ chips.Add(new ActiveFilter(
+ "codebase", family, Href(ListingLinks.With(query, FacetKeys.CodebaseFamily, null))));
+ }
+
+ var drawn = new HashSet(StringComparer.Ordinal);
+
+ foreach (var group in facets)
+ {
+ foreach (var value in group.Values.Where(v => v.State is not FacetState.Unselected))
+ {
+ drawn.Add(group.Key);
+
+ chips.Add(new ActiveFilter(
+ FacetWords.Group(group.Key),
+ value.State is FacetState.Excluded
+ ? FacetWords.Excluded(group.Key, value)
+ : FacetWords.Value(group.Key, value),
+
+ // A choice facet holds one selection, so removing it drops the parameter; a
+ // presence facet holds several in one repeatable, comma-separated parameter, so
+ // removing one has to leave the others behind.
+ Href(group.Kind is FacetKind.Choice
+ ? ListingLinks.With(query, group.Key, null)
+ : ListingLinks.Without(query, group.Key, value.Token))));
+ }
+ }
+
+ // A selection the panel is not offering back.
+ //
+ // An open-ended facet's values come from what is in the results, so a selection that matches
+ // nothing left in them has no value to hang a chip on — ?codebase=!Evennia beside a search
+ // that returns no Evennia game at all is the ordinary case, not a corner. Left there, the one
+ // affordance for undoing a filter would go missing exactly when the filter is doing the most
+ // and the reader can see the least of why.
+ foreach (var (key, choice) in Open(filter))
+ {
+ if (choice is null || !drawn.Add(key))
+ {
+ continue;
+ }
+
+ var stand = new FacetValue(
+ choice.Value ?? FacetChoice.UnknownToken,
+ Count: 0,
+ IsSelected: true,
+ IsUnknown: choice.IsUnknown,
+ IsExcluded: choice.Exclude);
+
+ chips.Add(new ActiveFilter(
+ FacetWords.Group(key),
+ choice.Exclude ? FacetWords.Excluded(key, stand) : FacetWords.Value(key, stand),
+ Href(ListingLinks.With(query, key, null))));
+ }
+
+ // Last, because it widens the answer rather than narrowing it and reads oddly among the
+ // things that narrow it — but present, because it is a thing the URL is asking for and a
+ // reader who cannot see it asked has no way to stop asking.
+ if (filter.IncludeArchived)
+ {
+ chips.Add(new ActiveFilter(
+ "archived", "included", Href(ListingLinks.With(query, FacetKeys.Archived, null))));
+ }
+
+ return chips;
+ }
+
+ ///
+ /// The facets whose vocabulary comes from the data rather than from an enum, which are the ones
+ /// that can be asked for a value the current results do not contain.
+ ///
+ ///
+ /// The two derived facets are not here: their values are a fixed vocabulary and a selected one
+ /// stays in the panel at a count of zero, so it always has a chip already.
+ ///
+ private static IEnumerable<(string Key, FacetChoice? Choice)> Open(GameFilter filter) =>
+ [
+ (FacetKeys.Charset, filter.Charset),
+ (FacetKeys.Codebase, filter.Codebase),
+ (FacetKeys.Family, filter.Family),
+ (FacetKeys.Genre, filter.Genre),
+ (FacetKeys.Language, filter.Language),
+ ];
+
+ private static string Href(string queryString) => "/games" + queryString;
+}
diff --git a/src/MUI.Web/Components/FacetPanel.razor b/src/MUI.Web/Components/FacetPanel.razor
index b9bff4c..7d96801 100644
--- a/src/MUI.Web/Components/FacetPanel.razor
+++ b/src/MUI.Web/Components/FacetPanel.razor
@@ -9,105 +9,192 @@
Every count beside a value came from the same pass that produced the listing below it (see
FacetedSearch), so a choice cannot promise results it will not deliver, and a value nothing
matches is never drawn at all.
+
+ It used to explain itself in a paragraph under the controls, and every facet wore a sentence for
+ a label — "codebase the game says so". The facts in that prose are all still here; none of them
+ is prose any more. The measured/declared split is a two-word chip on each group, the counts speak
+ for themselves, the one reading that has to be prevented at the moment of ticking sits inside the
+ fieldset it applies to, and the rest is one disclosure a reader can open once and never again.
*@
+@*
+ What the query is asking for, repeated where the results are, one removable chip each.
+
+ Outside the form on purpose: these are links rather than controls, and each undoes exactly one
+ selection while leaving every other in place — which no
}
-
- @Listing.Games.Count games · counts measured, never asserted ·
- show me a random one
-
+ @* The order is stated rather than left to be inferred from the first few rows. *@
+ by @FacetWords.Sort(Filter.Sort) · counts measured, never asserted
+ @if (Listing.Games.Count > 0)
+ {
+ @* Only where there is something to be random among. An affordance that cannot do
+ what it offers is worse than one that is not there. *@
+ · show me a random one
+ }
+
+
@foreach (var g in Listing.Games)
{
-
+ @*
+ Where a sort runs out of things it can rank, the list says so before it goes on.
+ Sorted by players, the counted games end and the ones we reached but could not
+ count begin — and without this break the page reads 54, 11, 2, 0, and then a long
+ tail showing no number, which looks exactly like a tail of empty games. It is a
+ real list item so it survives in the accessibility tree and in plain text.
+ *@
+ @if (BreakBefore(g))
+ {
+
+ @g.Name
+ @*
+ Archived and claimed are the two states worth a pill. Unclaimed is
+ the majority state of the catalogue and is drawn as the quiet word
+ it is — pilled on every row it was chrome, and a badge every game
+ wears is a badge that distinguishes none of them. The word itself
+ stays, on every row and in plain text, because "nobody has claimed
+ this" is a fact about the entry.
+ *@
+ @if (g.State is LifecycleState.Archived)
+ {
+ archived
+ }
+ else if (g.IsClaimed)
+ {
+ claimed
+ }
+ else
+ {
+ unclaimed
+ }
+
@if (g.Tagline is { } t)
{
-
@t
+
@t
}
-
+
@if (g.Codebase is { } cb)
{
@cb
@@ -83,24 +121,25 @@ else
}
@foreach (var p in g.MeasuredProtocols)
{
- · @p ●
+ · @p ●
}
-
+
-
+
@*
Three states, spelled rather than implied. A missing count says so in
words — it is never rendered as a zero, and never left blank for a
- reader to guess at.
+ reader to guess at. A measured zero keeps the accent glyph every other
+ count has, because it is every bit as much a measurement.
*@
@if (g.PlayersNow is { } n)
{
- ● @n on
+ ● @n on
}
else
{
- – count unknown
+ – count unknown
}
@*
@@ -108,17 +147,17 @@ else
a reader can check the facet did what it said. Never reached is its own
sentence and is never dated from our own first sighting.
*@
-
+
@if (g.LastReachableAt is { } seen)
{
- last reached @Relative.Format(Now - seen) ago
+ reached @Relative.Ago(Now - seen)
}
else
{
never reached
}
-
}
}
@@ -160,13 +205,27 @@ else
/// link would have quietly dropped it and offered the unfiltered catalogue as "this page, as
/// text" — which is the same page in neither sense.
///
- private string PlainHref => Relink("plain", "1");
+ private string PlainHref => ListingLinks.With(Query, "plain", "1");
///
/// Random, within whatever is on screen (spec §9). The filters ride along, because "surprise me"
/// after narrowing to Evennia games means surprise me among those.
///
- private string RandomHref => "/games/random" + Relink("plain", null);
+ private string RandomHref => "/games/random" + ListingLinks.With(Query, "plain", null);
+
+ ///
+ /// The first game this sort had nothing to rank, which is where the list says so.
+ ///
+ ///
+ /// Held as the game rather than as an index because the loop has the game and not its position,
+ /// and resolved once per render rather than per row. Null when the sort ranks everything it was
+ /// given — including when it ranks nothing, which is the case where the break is the first thing
+ /// on the list and is exactly as worth drawing.
+ ///
+ private GameSummary? FirstUnranked => Listing.Games
+ .FirstOrDefault(g => GameSorting.IsUnranked(g, Filter.Sort));
+
+ private bool BreakBefore(GameSummary game) => ReferenceEquals(game, FirstUnranked);
protected override async Task OnParametersSetAsync()
{
@@ -184,21 +243,4 @@ else
Filter = query.Filter;
Listing = await Queries.SearchAsync(Filter);
}
-
- /// This page's querystring with one parameter set, replaced, or — on null — removed.
- private string Relink(string name, string? value)
- {
- var parts = QueryHelpers.ParseQuery(Query)
- .Where(p => !string.Equals(p.Key, name, StringComparison.OrdinalIgnoreCase))
- .SelectMany(p => p.Value.Select(v =>
- $"{Uri.EscapeDataString(p.Key)}={Uri.EscapeDataString(v ?? string.Empty)}"))
- .ToList();
-
- if (value is not null)
- {
- parts.Add($"{Uri.EscapeDataString(name)}={Uri.EscapeDataString(value)}");
- }
-
- return parts.Count == 0 ? string.Empty : "?" + string.Join('&', parts);
- }
}
diff --git a/src/MUI.Web/Components/PlainText.cs b/src/MUI.Web/Components/PlainText.cs
index ffc40e5..e5f3143 100644
--- a/src/MUI.Web/Components/PlainText.cs
+++ b/src/MUI.Web/Components/PlainText.cs
@@ -229,6 +229,12 @@ public static string RenderListing(GameListing listing, GameFilter filter, DateT
+ (string.IsNullOrWhiteSpace(filter.Text) ? string.Empty : $" matching \"{filter.Text}\"")
+ (filter.IncludeArchived ? ", archived included" : ", archived excluded"));
+ // The order, stated. A sorted list that does not say what it is sorted by is one a reader has
+ // to reverse-engineer from the first few rows — and that is exactly how a tail of games
+ // showing no number gets read as a tail of games with no players.
+ b.AppendLine($"Sorted by {FacetWords.Sort(filter.Sort)}"
+ + $" (?{FacetKeys.Sort}={string.Join('/', FacetTokens.Sorts)})");
+
AppendFacets(b, listing.Facets);
b.AppendLine();
@@ -239,8 +245,20 @@ public static string RenderListing(GameListing listing, GameFilter filter, DateT
return b.ToString();
}
+ var broken = false;
+
foreach (var g in games)
{
+ // The same break the rendered listing draws, in the same place and for the same reason:
+ // where the sort runs out of things it can rank, the list says so rather than letting the
+ // rows that follow read as the bottom of the ranking.
+ if (!broken && GameSorting.IsUnranked(g, filter.Sort))
+ {
+ broken = true;
+ b.AppendLine($"-- from here: {FacetWords.Unranked(filter.Sort)}");
+ b.AppendLine();
+ }
+
var mark = g.State is LifecycleState.Archived ? "[archived]" : g.IsClaimed ? "[claimed]" : "[unclaimed]";
b.AppendLine($"{g.Name} {mark}");
b.AppendLine($" /g/{g.Slug}");
@@ -261,7 +279,7 @@ public static string RenderListing(GameListing listing, GameFilter filter, DateT
// the oldest bucket, because a game we have never got an answer from has no date and
// inventing one from our first sighting would read as its outage.
b.AppendLine(g.LastReachableAt is { } seen
- ? $" Last reached: {Relative.Format(now - seen)} ago"
+ ? $" Last reached: {Relative.Ago(now - seen)}"
: " Last reached: never — we have not once got an answer from it");
if (g.Tagline is { } tagline)
@@ -292,10 +310,16 @@ private static void AppendFacets(StringBuilder b, IReadOnlyList face
}
Heading(b, "FILTERS");
+ Wrap(b, $"Each facet is marked {FacetWords.Evidence(FacetEvidence.Measured)} "
+ + $"({FacetWords.EvidenceMeaning(FacetEvidence.Measured)}) or "
+ + $"{FacetWords.Evidence(FacetEvidence.Declared)} "
+ + $"({FacetWords.EvidenceMeaning(FacetEvidence.Declared)}).");
+ b.AppendLine();
Wrap(b, "Each count is what choosing that value returns, from the same query as the list "
+ "below. A protocol is listed when we saw a game offer it, so a game missing from one "
+ "may simply never have been measured for it and is never a \"no\". Where a facet has "
- + "no value for a game it says so in its own words, and that is not a no either.");
+ + "no value for a game it says so in its own words, and that is not a no either. A "
+ + "measured zero is a count; an unknown count is not a zero and never sorts as one.");
foreach (var group in facets)
{
diff --git a/src/MUI.Web/Components/Relative.cs b/src/MUI.Web/Components/Relative.cs
index c67752d..38f80f3 100644
--- a/src/MUI.Web/Components/Relative.cs
+++ b/src/MUI.Web/Components/Relative.cs
@@ -10,6 +10,17 @@ namespace MUI.Web.Components;
///
public static class Relative
{
+ ///
+ /// The same age, as something that already happened.
+ ///
+ ///
+ /// 's freshest bucket is the word "now", so a caller appending " ago" to it
+ /// wrote "last reached now ago" for the ninety seconds after every probe — which, on a listing
+ /// rendered while a crawl is running, was most of the rows on the page. The suffix belongs to
+ /// whoever knows whether the bucket came back a duration or a word, and that is here.
+ ///
+ public static string Ago(TimeSpan age) => Format(age) is "now" ? "just now" : Format(age) + " ago";
+
public static string Format(TimeSpan age) => age switch
{
{ TotalSeconds: < 90 } => "now",
diff --git a/src/MUI.Web/wwwroot/app.css b/src/MUI.Web/wwwroot/app.css
index dbe7501..bd5aa55 100644
--- a/src/MUI.Web/wwwroot/app.css
+++ b/src/MUI.Web/wwwroot/app.css
@@ -189,13 +189,6 @@ figure.ansi figcaption {
background: var(--surface);
}
-/* ── listing ───────────────────────────────────────────────────────────── */
-
-ul.games { list-style: none; margin: 0; padding: 0; }
-ul.games li { border-bottom: 1px solid var(--line); padding: var(--cpad) 0; }
-ul.games .name { font-size: 16px; font-weight: 600; text-decoration: none; }
-ul.games .meta { font-family: var(--mono); font-size: 12px; color: var(--faint); }
-
/*
Amber means declared, unverified or ageing, and nothing else — so the archived pill is not amber.
Archived is a lifecycle state derived from measurement, and colouring it as staleness would put
@@ -208,9 +201,6 @@ ul.games .meta { font-family: var(--mono); font-size: 12px; color: var(--faint);
}
.badge.archived { color: var(--faint); }
-fieldset.facets { border: 1px solid var(--line); padding: var(--cpad); margin: 0 0 var(--cpad); }
-fieldset.facets legend { font: 10px/1 var(--kick); letter-spacing: 0.16em; text-transform: uppercase; color: var(--faint); }
-
/* ── things a reader cannot see but a reader still gets ────────────────────
Text present for a screen reader and absent from the picture. Never display:none — that removes
it from the accessibility tree as well, which is the opposite of the point. */
@@ -243,7 +233,9 @@ fieldset.facets legend { font: 10px/1 var(--kick); letter-spacing: 0.16em; text-
/* ── chrome ────────────────────────────────────────────────────────────── */
-header.site nav { display: flex; gap: var(--cpad); }
+/* Wrapping, because seven catalogue links do not fit a 390px phone on one line and the alternative
+ is a page that scrolls sideways — which the whole layout is otherwise careful never to do. */
+header.site nav { display: flex; gap: var(--cpad); flex-wrap: wrap; }
header.site nav a { color: var(--dim); text-decoration: none; }
header.site nav a:hover { color: var(--text); text-decoration: underline; text-underline-offset: 3px; }
header.site .kicker { margin-left: auto; }
@@ -467,26 +459,148 @@ pre.plain-screen { background: var(--recessed); padding: var(--cpad); overflow-x
}
.ansi-plate p { margin: 0; }
-/* ── listing and archive rows ──────────────────────────────────────────── */
+/* ══ listing and archive rows ═══════════════════════════════════════════════
+ Two columns and four lines of type. What the game is on the left, what we
+ last measured of it on the right, and the eye runs down one edge or the other
+ rather than across every row.
+
+ Density is deliberate and is the reason the row is a grid rather than a stack:
+ this list is 700 games long and growing, so the name, the count and the age
+ each have a column of their own and land in the same place on every row. The
+ hairline is the only separator — the design's own rule is that separation
+ comes from surface steps, not from rules, and a rule per row at this density
+ would be the loudest thing on the page.
+ ═══════════════════════════════════════════════════════════════════════════ */
+
+ul.games, ul.archive { list-style: none; margin: 0; padding: 0; }
+
+ul.games > li, ul.archive > li {
+ border-bottom: 1px solid var(--line);
+ padding: var(--row-pad) var(--cpad);
+
+ /* Negative margin so the hover plane runs to the reading measure's own edge and the text does
+ not shift when it appears — a row that moves under the pointer is a row you mis-click. */
+ margin: 0 calc(var(--cpad) * -1);
+}
+
+ul.games > li.game-row:hover { background: var(--surface); }
+
+.row-main {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 2px var(--cpad);
+ align-items: baseline;
+}
+
+.row-text { min-width: 0; }
+.row-head { margin: 0; display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; }
+
+ul.games .name, ul.archive .name { font-size: 15px; font-weight: 600; text-decoration: none; }
+ul.games .name:hover { text-decoration: underline; text-underline-offset: 3px; }
+ul.archive .name { color: var(--dim); }
+
+/* The majority state of the catalogue, so it is the quiet word rather than the third pill. A badge
+ every row wears distinguishes no row from any other, and at 700 rows it is chrome. */
+ul.games .unclaimed { font: 10px/1 var(--kick); letter-spacing: 0.14em; text-transform: uppercase; color: var(--faint); }
+
+.row-text .tagline {
+ margin: 2px 0 0;
+ color: var(--dim);
+ font-size: 13px;
+
+ /* One line. A game's own description is a paragraph on its page; here it is a hint at what the
+ game is, and a listing whose rows are different heights cannot be scanned at all. */
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+ul.games .meta, ul.archive .meta { margin: 3px 0 0; font-size: 11.5px; color: var(--faint); }
+ul.games .meta .protocol { white-space: nowrap; }
+
+/* The measured column. Tabular figures so the counts line up down the page without a table. */
+.row-figure {
+ margin: 0;
+ text-align: right;
+ font-size: 12px;
+ white-space: nowrap;
+ font-variant-numeric: tabular-nums;
+ display: grid;
+ gap: 2px;
+ justify-items: end;
+}
+
+.row-figure .players { color: var(--text); }
+.row-figure .players .unit { color: var(--faint); }
+.row-figure .players.unknown { color: var(--faint); }
+.row-figure .seen { color: var(--faint); font-size: 11.5px; }
+
+/* Archived: dimmed one step, and that is the whole treatment. No red, no strikethrough, no "dead" —
+ the entry is a library record for a periodical that ceased publication. */
+ul.games > li.archived { opacity: 0.72; }
+
+/*
+ Where a sort ran out of things it could rank. Not a row and not styled as one: it is a rule with a
+ sentence on it, so the reader can see that the list below it is a different kind of thing from the
+ list above. The words are the carrier; the rule only says where the change happened.
+*/
+li.unranked-break {
+ display: flex;
+ gap: 10px;
+ align-items: baseline;
+ flex-wrap: wrap;
+ border-bottom: 1px solid var(--line);
+ border-top: 1px solid var(--line);
+ background: var(--surface);
+ color: var(--dim);
+ font-size: 12px;
+ margin-top: var(--cpad);
+}
-.row-main { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--cpad); align-items: start; }
-ul.games .count { font-size: 12px; white-space: nowrap; }
+li.unranked-break:hover { background: var(--surface); }
-ul.archive { list-style: none; margin: 0; padding: 0; }
-ul.archive li { border-bottom: 1px solid var(--line); padding: var(--cpad) 0; }
-ul.archive .name { font-size: 16px; font-weight: 600; text-decoration: none; color: var(--dim); }
-ul.archive .meta { font-size: 12px; color: var(--faint); margin-top: 4px; }
dl.archive-facts { margin: 0; font-size: 12px; min-width: 220px; }
dl.archive-facts dd { margin: 0 0 6px; color: var(--dim); }
-fieldset.facets { display: flex; gap: var(--cpad); align-items: center; flex-wrap: wrap; }
-fieldset.facets .check { display: inline-flex; gap: 6px; align-items: center; font-size: 13px; color: var(--dim); }
+/* ── the listing's own header ────────────────────────────────────────────── */
+
+.listing-head { margin-bottom: var(--cpad); }
+.listing-head p { margin: 0; max-width: 62ch; }
+
+.listing-bar {
+ display: flex;
+ align-items: baseline;
+ gap: var(--cpad);
+ flex-wrap: wrap;
+ margin: calc(var(--cpad) * 1.5) 0 4px;
+}
+
+.listing-bar .result-count { margin: 0; font-size: 13px; }
+.listing-bar .result-count strong { font-size: 19px; font-weight: 600; }
+.listing-bar .listing-order { margin: 0; }
+.listing-bar .listing-order a { color: var(--faint); }
+
+/*
+ Nothing matched. A plate rather than a paragraph, because an empty listing is a state and not an
+ aside — and it says the same thing the archive section says, since a name that once worked here
+ still resolves.
+*/
+.empty-state {
+ border: 1px dashed var(--line);
+ border-radius: 8px;
+ background: var(--recessed);
+ padding: calc(var(--cpad) * 1.5);
+ margin: var(--cpad) 0;
+ max-width: 60ch;
+}
+
+.empty-state .empty-head { font-size: 15px; font-weight: 600; margin: 0 0 4px; }
+.empty-state .kicker { margin: 8px 0 0; }
/* ── one column below 900px; no horizontal scroll except inside the frame ── */
@media (max-width: 900px) {
.two-up, .feeds { grid-template-columns: minmax(0, 1fr); }
- .row-main { grid-template-columns: minmax(0, 1fr); }
.game-head { flex-direction: column; }
header.site { flex-wrap: wrap; }
header.site .kicker { margin-left: 0; }
@@ -596,50 +710,236 @@ table.ranking tbody th { font-weight: 400; }
@media (max-width: 900px) {
/* The label stops competing with the bar for a width neither of them has. */
ul.shares li { grid-template-columns: minmax(0, 1fr); gap: 6px; }
+}
+
/* ══ faceted search ═════════════════════════════════════════════════════════
The panel on /games. It is a plain GET form with no script, so everything
here dresses controls the browser already knows how to operate — no rule
- below is load-bearing for the filter working.
+ below is load-bearing for the filter working, and every one of them survives
+ being switched off.
+
+ It reads as one card with three registers stacked in it: the bar a reader
+ touches on every visit, the facets they touch on some, and a key they read
+ once. That shape is the point. The panel used to be a column of full-width
+ label-and-select rows with a paragraph of prose under it — a database form,
+ and one that explained itself at more length than it took to operate.
- Two things are deliberate rather than decorative. The evidence chip beside
- each facet's name takes the site's own accent/amber split for measured
+ Two things here are deliberate rather than decorative. The evidence chip
+ beside each facet's name takes the site's own accent/amber split for measured
versus declared, because a reader has to be able to see which half of the
- panel is evidence — and it says the words too, since colour is not a fact.
- And a count is never hidden at a narrow width: a facet whose numbers
- disappear has stopped saying what a click will produce, which is the only
- reason they are there.
+ panel is evidence — and it says the word too, since colour is not a fact and
+ the glyph is aria-hidden. And a count is never hidden at any width: a facet
+ whose numbers disappear has stopped saying what a click will produce, which
+ is the only reason they are there.
═══════════════════════════════════════════════════════════════════════════ */
-form.facet-form { margin-bottom: var(--gap); }
+form.facet-form {
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: var(--surface);
+ padding: var(--cpad);
+ margin: 0 0 var(--cpad);
+}
+
+/* ── the bar ─────────────────────────────────────────────────────────────── */
+/*
+ The archive's own search box is the same control with a legend on it, so it is styled here rather
+ than growing a second look for one question asked on two pages.
+*/
+.filter-bar, fieldset.facets {
+ display: flex;
+ gap: 10px;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+fieldset.facets {
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: var(--surface);
+ padding: var(--cpad);
+ margin: 0 0 var(--cpad);
+}
+
+fieldset.facets legend {
+ font: 10px/1 var(--kick);
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+ color: var(--faint);
+}
+
+.filter-bar input[type="search"], fieldset.facets input[type="search"] { flex: 1 1 16rem; min-width: 0; }
+
+.bar-field { display: inline-flex; align-items: center; gap: 6px; }
+.bar-label { font: 10px/1 var(--kick); letter-spacing: 0.16em; text-transform: uppercase; color: var(--faint); }
+
+.filter-bar button { flex: none; }
+
+/* ── one facet ───────────────────────────────────────────────────────────── */
+
+/*
+ auto-fit at a narrow floor, so eight facets pack four to a row on a laptop, two on a tablet and one
+ on a phone without a breakpoint being written for any of them.
+*/
.facet-grid {
display: grid;
- grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
- gap: var(--cpad);
+ grid-template-columns: repeat(auto-fit, minmax(178px, 1fr));
+ gap: 10px var(--cpad);
margin-top: var(--cpad);
}
-.facet { min-width: 0; }
-.facet > label, .facet > legend { display: block; font-size: 12px; color: var(--dim); margin-bottom: 4px; }
-.facet select { width: 100%; max-width: 100%; padding: 4px 6px; }
+/* The control sits at the bottom of its cell, so a label that wraps grows upward and every select in
+ the row stays on one line. Left to itself the grid aligns the tops, and one long facet name puts
+ one control half a row below its neighbours. */
+.facet {
+ min-width: 0;
+ display: grid;
+ grid-template-rows: 1fr auto;
+ align-content: end;
+}
-fieldset.facet.presence {
+.facet > label, .facet > legend {
+ display: flex;
+ gap: 6px;
+ align-items: baseline;
+ flex-wrap: wrap;
+ margin-bottom: 3px;
+ padding: 0;
+}
+
+.facet .facet-name {
+ font: 10px/1.4 var(--kick);
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--dim);
+}
+
+select {
+ background: var(--recessed);
border: 1px solid var(--line);
- border-radius: 4px;
- padding: 6px 8px 8px;
+ color: var(--text);
+ font: 13px var(--sans);
+ padding: 5px 7px;
+ border-radius: 6px;
+}
+
+.facet select { width: 100%; max-width: 100%; }
+
+/* A facet that is doing something says so at rest, and not only in colour — the chip row above the
+ results names it in words, and the select's own text is the value it is set to. */
+.facet select.chosen { border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); }
+
+/* ── the presence facet ──────────────────────────────────────────────────── */
+
+/*
+ Real checkboxes in pill-shaped labels. The box stays visible rather than being replaced by a tinted
+ chip: the tick is what a screen reader reads and what the keyboard operates, and a chip that
+ carried the state in a background alone would carry it in colour alone.
+*/
+fieldset.facet.presence {
+ border: 0;
+ padding: 0;
+ margin: var(--cpad) 0 0;
min-width: 0;
}
-fieldset.facet.presence .check { display: flex; gap: 6px; align-items: baseline; font-size: 13px; }
-fieldset.facet.presence .check .count { margin-left: auto; color: var(--dim); font-variant-numeric: tabular-nums; }
+fieldset.facet.presence .checks { display: flex; flex-wrap: wrap; gap: 6px; }
+
+fieldset.facet.presence .check {
+ display: inline-flex;
+ gap: 6px;
+ align-items: center;
+ font-size: 12.5px;
+ border: 1px solid var(--line);
+ border-radius: 99px;
+ padding: 3px 10px 3px 7px;
+ background: var(--recessed);
+ cursor: pointer;
+}
+
+fieldset.facet.presence .check:hover { border-color: var(--dim); }
+fieldset.facet.presence .check:has(:checked) { border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); }
+fieldset.facet.presence .check .count { color: var(--faint); font-variant-numeric: tabular-nums; }
+
+/* Where the unticked-is-not-a-no sentence lives: inside the fieldset it is about, at the moment of
+ ticking, rather than in a key at the bottom of the panel. */
+fieldset.facet.presence .hint { margin: 6px 0 0; font-size: 11.5px; color: var(--faint); max-width: 62ch; }
/* Measured and declared, in the site's own two registers. Never the only carrier of the
difference — the chip spells it out in words as well, because colour is not a fact. */
-.evidence { font-size: 10px; text-transform: uppercase; letter-spacing: 0.04em; margin-left: 6px; }
+.evidence {
+ font: 10px/1.4 var(--kick);
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ white-space: nowrap;
+}
+
.evidence.measured { color: var(--accent); }
.evidence.declared { color: var(--amber); }
-.facet-note { margin: var(--cpad) 0 0; font-size: 12px; color: var(--dim); max-width: 68ch; }
+/* ── the key ─────────────────────────────────────────────────────────────── */
+
+/*
+ What the panel used to say in a paragraph. Three short statements on one line, and one disclosure
+ holding the readings a reader would otherwise have to guess at. Deliberately not a tooltip and not
+ a title attribute: this is the difference the whole site exists to publish, and it stays in the
+ document whether or not a pointer ever hovers anything.
+*/
+.facet-key {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px 18px;
+ align-items: baseline;
+ margin-top: var(--cpad);
+ padding-top: 10px;
+ border-top: 1px solid var(--line);
+ font-size: 11.5px;
+ color: var(--faint);
+}
+
+.facet-key .key-item { display: inline-flex; gap: 5px; align-items: baseline; }
+.facet-key .facet-more { margin-left: auto; }
+.facet-key summary { cursor: pointer; color: var(--dim); }
+
+.facet-key .facet-more[open] { flex: 1 0 100%; margin-left: 0; margin-top: 6px; }
+.facet-key .facet-more ul { margin: 6px 0 0; padding-left: 1.2em; max-width: 72ch; }
+.facet-key .facet-more li { margin-bottom: 5px; color: var(--dim); }
+
+/* ── what the query is currently asking for ──────────────────────────────── */
+
+/*
+ One chip per selection, each a link that removes itself. This is the only place the third state is
+ legible at rest: a sitting on an option inside its "anything but" optgroup looks like any
+ other select until it is opened, and "codebase · anything but Evennia" does not.
+*/
+.active-filters {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px 8px;
+ align-items: center;
+ margin: 0 0 var(--cpad);
+}
+
+.filter-chip {
+ display: inline-flex;
+ align-items: baseline;
+ gap: 6px;
+ border: 1px solid color-mix(in srgb, var(--accent) 34%, var(--line));
+ border-radius: 99px;
+ padding: 3px 9px;
+ font-size: 12px;
+ text-decoration: none;
+ background: var(--recessed);
+}
+
+.filter-chip:hover { border-color: var(--accent); background: var(--raised); }
+.filter-chip .chip-facet { font: 10px/1.4 var(--kick); letter-spacing: 0.12em; text-transform: uppercase; color: var(--faint); }
+.filter-chip .chip-value { color: var(--text); }
+.filter-chip .chip-x { color: var(--faint); font-size: 10px; }
+.filter-chip:hover .chip-x { color: var(--text); }
+
+.active-filters .clear-all { font-size: 12px; color: var(--dim); }
/* A filter we could not read is refused out loud rather than dropped, so it needs somewhere loud
to be said. Amber, like every other "we are not showing you a measurement" state here. */
@@ -648,6 +948,19 @@ fieldset.facet.presence .check .count { margin-left: auto; color: var(--dim); fo
border-left: 3px solid var(--amber);
background: color-mix(in srgb, var(--amber) 10%, transparent);
}
+
+/* ── the panel and the rows, narrow ──────────────────────────────────────── */
+
+@media (max-width: 620px) {
+ /* The bar stacks: a search box sharing a row with a sort and a toggle has room for none of them. */
+ .filter-bar input[type="search"] { flex: 1 0 100%; }
+ .filter-bar button { margin-left: auto; }
+
+ /* The measured column goes under the name rather than fighting it for a width neither has. */
+ .row-main { grid-template-columns: minmax(0, 1fr); }
+ .row-figure { text-align: left; justify-items: start; margin-top: 4px; }
+ .row-figure { display: flex; gap: 12px; flex-wrap: wrap; }
+}
/* ── reference and orientation pages ───────────────────────────────────────
The hand-written section (spec §9). Two things it has to communicate that no
other page does.
diff --git a/tests/MUI.Web.Tests/FacetSurfaceTests.cs b/tests/MUI.Web.Tests/FacetSurfaceTests.cs
index 5643ae2..da5c70f 100644
--- a/tests/MUI.Web.Tests/FacetSurfaceTests.cs
+++ b/tests/MUI.Web.Tests/FacetSurfaceTests.cs
@@ -39,6 +39,7 @@ .. typeof(FacetKeys)
FacetKeys.Band => "quiet",
FacetKeys.LastSeen => "week",
FacetKeys.Protocol => "GMCP",
+ FacetKeys.Sort => "players",
_ => "something",
};
@@ -173,11 +174,31 @@ public async Task ThePanelSaysInWordsThatAnUnknownIsNotANo()
{
// Said in the markup, not only in a comment. A reader ticking boxes is exactly the person who
// would otherwise read an unticked box as the game declining a protocol.
+ //
+ // The prose these used to sit in is gone — the panel explained itself at more length than it
+ // took to operate — so each of them now has a place of its own: the tick-box reading is a
+ // line inside the fieldset it is about, and the rest is one disclosure. What may not change
+ // is that they are all still in the document, which is what this asserts.
var words = Render.Words(await PanelAsync(new GameFilter()));
+ await Assert.That(words).Contains("Unticked is never a no");
await Assert.That(words).Contains("leaving one unticked never means a game lacks it");
await Assert.That(words).Contains("none of those is a no");
await Assert.That(words).Contains("computed from the same query as the list below");
+ await Assert.That(words).Contains("An unknown count is not a zero");
+ }
+
+ [Test]
+ public async Task TheDisclosureIsAnAffordanceRatherThanAPlaceToHideThings()
+ {
+ // is closed by default and its contents are still in the document, in the
+ // accessibility tree and in the page a text browser gets — which is what makes it a fair
+ // place to put the long form of a rule. It stops being fair the moment the summary stops
+ // saying what is inside it, so the summary is asserted too.
+ var html = await PanelAsync(new GameFilter());
+
+ await Assert.That(html).Contains("");
+ await Assert.That(Render.Words(html)).Contains("what a blank means");
}
[Test]
@@ -202,12 +223,57 @@ public async Task EachFacetSaysWhetherItIsAMeasurementOrTheGamesOwnClaim()
{
// The distinction is the product, and it is carried in words as well as colour — a legend a
// reader has to learn is a difference they will not read.
+ //
+ // It is now one word per facet with the sentence said once, rather than the sentence on
+ // every facet. Both halves are asserted: the word has to be on the group (or the panel has
+ // stopped saying which half of itself is evidence) and the meaning has to be on the page (or
+ // the word is a legend nobody was given).
var words = Render.Words(await PanelAsync(new GameFilter()));
- await Assert.That(words).Contains("we measured this");
- await Assert.That(words).Contains("the game says so");
+ await Assert.That(words).Contains("measured");
+ await Assert.That(words).Contains("declared");
+ await Assert.That(words).Contains(FacetWords.EvidenceMeaning(FacetEvidence.Measured));
+ await Assert.That(words).Contains(FacetWords.EvidenceMeaning(FacetEvidence.Declared));
+
await Assert.That(FacetWords.Evidence(FacetEvidence.Measured))
.IsNotEqualTo(FacetWords.Evidence(FacetEvidence.Declared));
+ await Assert.That(FacetWords.EvidenceMeaning(FacetEvidence.Measured))
+ .IsNotEqualTo(FacetWords.EvidenceMeaning(FacetEvidence.Declared));
+ }
+
+ [Test]
+ public async Task AGroupsEvidenceIsSaidOnTheGroupAndNotOnlyInTheKey()
+ {
+ // A word in a key at the bottom of the panel is not the same as a word on the control. The
+ // point of the compression was to stop the panel repeating a sentence per facet, not to move
+ // the fact off the facets.
+ var html = await PanelAsync(new GameFilter());
+ var listing = await Queries.SearchAsync(new GameFilter());
+
+ foreach (var group in listing.Facets)
+ {
+ // Read off the label element itself rather than off an offset into the page: the facet
+ // names are ordinary English words and several of them occur in the option text of other
+ // facets, so anything positional here measures the wrong thing.
+ var marker = $"{FacetWords.Group(group.Key)}";
+ var at = html.IndexOf(marker, StringComparison.Ordinal);
+
+ await Assert.That(at).IsGreaterThanOrEqualTo(0).Because($"{group.Key} has no name of its own");
+
+ var rest = html[(at + marker.Length)..];
+ var label = rest[..new[]
+ {
+ rest.IndexOf("", StringComparison.Ordinal),
+ rest.IndexOf("", StringComparison.Ordinal),
+ }.Where(i => i >= 0).Min()];
+
+ await Assert.That(label).Contains($"evidence {Word(group.Evidence)}")
+ .Because($"{group.Key} has no evidence chip beside its own name");
+ await Assert.That(label).Contains(FacetWords.Evidence(group.Evidence));
+ }
+
+ static string Word(FacetEvidence evidence) =>
+ evidence is FacetEvidence.Measured ? "measured" : "declared";
}
[Test]
@@ -258,6 +324,72 @@ public async Task NoPlainLineTheFacetsAddIsWiderThanEightyColumns()
}
}
+ [Test]
+ public async Task WhatTheQueryIsAskingForIsRepeatedAsChipsThatRemoveThemselves()
+ {
+ // The panel is not the only place the query is visible. A sitting on an option in
+ // its "anything but" group looks like every other select until it is opened, so without
+ // these the third state is invisible at rest — and a reader who wants to drop one filter has
+ // to find the control that set it and remember what "any" was called.
+ const string Url = "?codebase=!Evennia&protocol=GMCP&protocol=MSSP&q=sun";
+
+ await Assert.That(GameFilterBinding.TryRead(Url, out var query, out _)).IsTrue();
+ var listing = await Queries.SearchAsync(query.Filter);
+ var chips = ActiveFilters.For(listing.Facets, query.Filter, Url);
+
+ await Assert.That(chips.Select(c => c.Value)).Contains("not Evennia");
+ await Assert.That(chips.Select(c => c.Value)).Contains("sun");
+
+ // Removing one value of a repeatable facet leaves the other behind. Dropping the whole
+ // parameter would take MSSP off the query too, and the chip said nothing about MSSP.
+ var gmcp = chips.Single(c => c.Value is "GMCP");
+ await Assert.That(gmcp.RemoveHref).Contains("MSSP");
+ await Assert.That(gmcp.RemoveHref).DoesNotContain("GMCP");
+ }
+
+ [Test]
+ public async Task NothingSelectedIsNoChipsAtAll()
+ {
+ var listing = await Queries.SearchAsync(new GameFilter());
+
+ await Assert.That(ActiveFilters.For(listing.Facets, new GameFilter(), string.Empty)).IsEmpty();
+ }
+
+ [Test]
+ public async Task AnUnreadableSortIsRefusedRatherThanQuietlyIgnored()
+ {
+ // The same rule as band and seen. A consumer who asked for ?sort=busiest and silently got
+ // the alphabet would read the first name on the page as the busiest game on the site.
+ await Assert.That(GameFilterBinding.TryRead("?sort=busiest", out _, out var error)).IsFalse();
+ await Assert.That(error).Contains("players");
+ }
+
+ [Test]
+ public async Task TheSortIsAControlOnThePanelAndAParameterInTheUrl()
+ {
+ var html = await PanelAsync(new GameFilter { Sort = GameSort.Players });
+
+ await Assert.That(html).Contains($"name=\"{FacetKeys.Sort}\"");
+
+ foreach (var sort in Enum.GetValues())
+ {
+ await Assert.That(Render.Words(html)).Contains(FacetWords.Sort(sort));
+ }
+ }
+
+ [Test]
+ public async Task NoSortCallsItselfBusiestBecauseTheRankingsPageAlreadyMeansSomethingByThat()
+ {
+ // /rankings means a median over ninety days with a sample floor under it. A sort over one
+ // instantaneous count is a cruder question and must not borrow the word — two measurements
+ // answering to one name on one site is how a reader ends up comparing them.
+ foreach (var sort in Enum.GetValues())
+ {
+ await Assert.That(FacetWords.Sort(sort)).DoesNotContain("busiest");
+ await Assert.That(FacetWords.Sort(sort)).DoesNotContain("popular");
+ }
+ }
+
private static async Task PanelAsync(GameFilter filter)
{
var listing = await Queries.SearchAsync(filter);
@@ -290,5 +422,6 @@ private static string Describe(GameFilter f) => string.Join(
f.Genre?.Token,
f.Language?.Token,
f.CodebaseFamily,
+ f.Sort,
string.Join(',', f.MeasuredProtocols));
}
diff --git a/tests/MUI.Web.Tests/Render.cs b/tests/MUI.Web.Tests/Render.cs
index 65b0c9f..62e3949 100644
--- a/tests/MUI.Web.Tests/Render.cs
+++ b/tests/MUI.Web.Tests/Render.cs
@@ -30,6 +30,22 @@ public static class Render
/// reader of the demo site sees.
///
public static Task PageAsync(Dictionary parameters)
+ where TComponent : IComponent =>
+ PageAsync(parameters, string.Empty);
+
+ ///
+ /// The same page, rendered at a URL.
+ ///
+ ///
+ /// The listing reads its own querystring rather than binding parameter by parameter, because one
+ /// parser answers both it and the read API — so rendering it at all means giving it somewhere to
+ /// read that from. Nothing here did until there was a sort whose survival of the URL had to be
+ /// proved, which is also how "last reached now ago" reached a real page: no test had ever looked
+ /// at a rendered listing row.
+ ///
+ public static Task PageAsync(
+ Dictionary parameters,
+ string query)
where TComponent : IComponent =>
ComponentAsync(parameters, services =>
{
@@ -39,8 +55,20 @@ public static Task PageAsync(Dictionary par
services.AddSingleton(fixture);
services.AddSingleton(TimeProvider.System);
services.AddSingleton(new CatalogueSource(IsMeasured: false));
+ services.AddSingleton(new StubNavigation(query));
});
+ /// A navigation manager with nothing to do but answer "which URL am I on".
+ private sealed class StubNavigation : NavigationManager
+ {
+ public StubNavigation(string query) =>
+ Initialize("http://localhost/", "http://localhost/games" + query);
+
+ protected override void NavigateToCore(string uri, bool forceLoad)
+ {
+ }
+ }
+
public static async Task ComponentAsync(
Dictionary parameters,
Action? configure = null)
diff --git a/tests/MUI.Web.Tests/SortingTests.cs b/tests/MUI.Web.Tests/SortingTests.cs
new file mode 100644
index 0000000..cef26ae
--- /dev/null
+++ b/tests/MUI.Web.Tests/SortingTests.cs
@@ -0,0 +1,149 @@
+using MUI.Catalog;
+using MUI.Web.Api;
+using MUI.Web.Components;
+using MUI.Web.Components.Pages;
+using MUI.Web.Fixtures;
+
+namespace MUI.Web.Tests;
+
+///
+/// The listing's order, and the one thing it may not do with the games it cannot rank.
+///
+///
+/// A large share of this catalogue answers with nothing we can count — we got in and the
+/// WHO was past our parser, or the game published no PLAYERS. Sorted as zeroes those
+/// games pile up at the bottom of "players on now" indistinguishable from the games we measured and
+/// found empty, which is this project's central claim made backwards on the page most likely to be
+/// read off. Every test here is about that one sentence.
+///
+public class SortingTests
+{
+ private static readonly FixtureGameQueries Queries = new();
+
+ [Test]
+ public async Task AGameWeCouldNotCountSortsAfterEveryGameWeCould()
+ {
+ var listing = await Queries.SearchAsync(new GameFilter { Sort = GameSort.Players });
+ var counted = listing.Games.Select(g => g.PlayersNow is not null).ToList();
+
+ // Every counted game before every uncounted one: no true may follow a false.
+ await Assert.That(counted.SkipWhile(c => c).Any(c => c)).IsFalse();
+ await Assert.That(counted).Contains(true);
+ await Assert.That(counted).Contains(false);
+ }
+
+ [Test]
+ public async Task AMeasuredZeroSortsAmongTheCountsAndNotAmongTheUnknowns()
+ {
+ // The whole distinction, in one row. We got in and nobody was there, which is a measurement;
+ // the games below the break are ones we could not count at all. Ranking the zero with them
+ // would throw away the difference the sort exists to preserve.
+ var listing = await Queries.SearchAsync(new GameFilter { Sort = GameSort.Players });
+ var zero = listing.Games.Single(g => g.PlayersNow is 0);
+ var firstUnknown = listing.Games.First(g => g.PlayersNow is null);
+
+ await Assert.That(listing.Games.ToList().IndexOf(zero))
+ .IsLessThan(listing.Games.ToList().IndexOf(firstUnknown));
+
+ await Assert.That(GameSorting.IsUnranked(zero, GameSort.Players)).IsFalse();
+ }
+
+ [Test]
+ public async Task AGameWeHaveNeverReachedIsNotAGameWeReachedLongAgo()
+ {
+ // The same rule on the other sort. Null has no date, so it cannot be the oldest date — and
+ // ordering it as one would date our own ignorance as somebody's outage.
+ var never = Summary("never", players: null, reached: null);
+ var ancient = Summary("ancient", players: null, reached: FixtureGameQueries.Now.AddYears(-4));
+
+ var order = GameSorting.Apply([never, ancient], GameSort.Reached);
+
+ await Assert.That(order[0].Name).IsEqualTo("ancient");
+ await Assert.That(GameSorting.IsUnranked(never, GameSort.Reached)).IsTrue();
+ await Assert.That(GameSorting.IsUnranked(ancient, GameSort.Reached)).IsFalse();
+ }
+
+ [Test]
+ public async Task TheDefaultOrderRanksNobody()
+ {
+ // A listing that arrives pre-ranked makes an editorial claim the reader never asked for.
+ // Sorting is a thing a reader chooses, so the default is the one order that ranks nothing.
+ await Assert.That(new GameFilter().Sort).IsEqualTo(GameSort.Name);
+
+ GameFilterBinding.TryRead(string.Empty, out var unasked, out _);
+ await Assert.That(unasked.Filter.Sort).IsEqualTo(GameSort.Name);
+
+ var listing = await Queries.SearchAsync(new GameFilter());
+ await Assert.That(listing.Games.Select(g => g.Name))
+ .IsEquivalentTo([.. listing.Games.Select(g => g.Name).OrderBy(n => n, StringComparer.OrdinalIgnoreCase)]);
+ }
+
+ [Test]
+ public async Task TheListingSaysWhereTheSortRanOutOfThingsToRank()
+ {
+ // Without this the page reads 219, 71, 15, 9, 0, and then a long tail of rows showing no
+ // number — a list that looks exactly like the lie. The break is a real list item, so it is in
+ // the accessibility tree rather than being a border a sighted reader might notice.
+ // Unsorted, there is nothing the order failed to rank and so nothing to announce.
+ await Assert.That(await Render.PageAsync([])).DoesNotContain("unranked-break");
+
+ var sorted = await Render.PageAsync([], "?sort=players");
+
+ await Assert.That(sorted).Contains("unranked-break");
+ await Assert.That(Render.Words(sorted)).Contains("from here");
+ await Assert.That(Render.Words(sorted)).Contains(FacetWords.Unranked(GameSort.Players));
+ await Assert.That(FacetWords.Unranked(GameSort.Players)).Contains("not zero");
+ }
+
+ [Test]
+ public async Task ARowNeverPrintsAnAgeAsNowAgo()
+ {
+ // Relative.Format's freshest bucket is the word "now", and every caller appending " ago" to
+ // it wrote "last reached now ago" for the ninety seconds after each probe — which, on a
+ // listing rendered while a crawl is running, was most of the rows on the page.
+ var html = Render.Words(await Render.PageAsync([]));
+
+ await Assert.That(html).DoesNotContain("now ago");
+ await Assert.That(Relative.Ago(TimeSpan.FromSeconds(10))).IsEqualTo("just now");
+ await Assert.That(Relative.Ago(TimeSpan.FromMinutes(20))).IsEqualTo("20m ago");
+ }
+
+ [Test]
+ public async Task ThePlainSurfaceSaysWhatOrderItIsInAndWhereTheBreakFell()
+ {
+ // A sorted list that does not say what it is sorted by is one a reader has to
+ // reverse-engineer from the first few rows, which is how the tail gets misread. If a fact
+ // only survives graphically, its graphic was decoration.
+ await Assert.That(GameFilterBinding.TryRead("?sort=players", out var query, out _)).IsTrue();
+
+ var text = PlainText.RenderListing(
+ await Queries.SearchAsync(query.Filter), query.Filter, FixtureGameQueries.Now);
+
+ await Assert.That(Render.Words(text)).Contains($"Sorted by {FacetWords.Sort(GameSort.Players)}");
+ await Assert.That(Render.Words(text)).Contains(FacetWords.Unranked(GameSort.Players));
+
+ // And the parameter that changes it, because a text browser cannot operate a .
+ await Assert.That(text).Contains($"?{FacetKeys.Sort}=");
+ }
+
+ [Test]
+ public async Task SortingMovesNoFacetCount()
+ {
+ // Counts are taken over a set and a set has no order, so this is true by construction — and
+ // asserted anyway, because the day it stops being true the panel starts promising one number
+ // and delivering another depending on how the reader happened to be reading.
+ var unsorted = await Queries.SearchAsync(new GameFilter());
+ var sorted = await Queries.SearchAsync(new GameFilter { Sort = GameSort.Players });
+
+ await Assert.That(Counts(sorted)).IsEquivalentTo(Counts(unsorted));
+ await Assert.That(sorted.Games.Count).IsEqualTo(unsorted.Games.Count);
+
+ static List Counts(GameListing listing) =>
+ [
+ .. listing.Facets.SelectMany(g => g.Values.Select(v => $"{g.Key}/{v.Token}={v.Count}")).Order(),
+ ];
+ }
+
+ private static GameSummary Summary(string name, int? players, DateTimeOffset? reached) => new(
+ Guid.NewGuid(), name, name, null, LifecycleState.Active, false, players, null, [], reached);
+}