Skip to content

feat: New UI - #67

Open
disconsented wants to merge 45 commits into
masterfrom
feat/updated_ui
Open

disconsented wants to merge 45 commits into
masterfrom
feat/updated_ui

Conversation

@disconsented

Copy link
Copy Markdown
Owner

I've been faffing with Claude design because I've found the UI hideous and I can't design things to save my life.

disconsented and others added 30 commits September 4, 2026 11:45
`cargo package` removes `[patch]` from the manifest that it writes into
the tarball. A verification build of the packaged crate then gets
surrealdb from crates.io, and the build fails.

Cargo also reads `[patch]` from `.cargo/config.toml`, and the config
lookup goes up from the directory of the command. The patch thus stays
in effect for that build.
`.cargo/config.toml` links with clang and mold, and the git-only mode of
release-plz does a verification build of the packaged crate. The runner
must thus have both tools. The cache keeps that build short.
`git_only` is resolved for each package. For each git-only package,
release-plz checks the last release tag out into a worktree and runs
`cargo package --workspace` in it. At the workspace level, that is one
such pass for each member: six full builds of the workspace for one
answer.
The schema declares `'system' | record<users>`, but the generated
`SurrealValue` code sent an enum-shaped object. The database refused the
value.
The similarity query compared only the value, so two properties with the
same value in different classes looked like a conflict. It now compares
the full `class:value` pair.

The existing-link query returns the property ids directly, which removes
a deserialization step. A repeat link is now refused before the write.
The unique index would otherwise refuse it, but the server reports that
as an internal error and not an already-exists error.

`take_errors` gives the errors for each statement. The keys are
statement indexes and a failed statement makes gaps, thus the code goes
through the map itself.

Adds tests for the four link paths: a new property, a known property on
a different item, a property from a user, and a repeat link.
A conflict is a usual result when the classifier gives a property that
the item already has. It is not an error. The log lines also give the
workshop item now.
Item updates now send each item's tags to a new tags actor. The actor
keeps a per-app tag cache and writes only the tags it has not seen.

The app update concatenates instead of replacing, because the actor
sends new tags only and a replace would drop the earlier ones.
The panel holds the app, title, language, order and tag controls in one
card. The controls are hardcoded and the handlers do nothing. The old
SearchPanel stays on the page next to it until the mockup works.
Each page publishes its own trail from `load`. A page can return a
promise when the crumb label needs a fetch. The navbar then renders the
crumbs when they land, and does not hold up the page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The toolbar now holds a segmented view control, a page size select and
one pagination bar, in place of the two footers. The filter panel shows
the tags of the app, more languages and a date range. An Advanced button
hides the panel.
The table showed a large description column and duplicate detail links.
It now shows tags, an author profile link, the item languages and two
link buttons. The thumbnail switch moved into the table caption.
Nothing reads the setting, and the commented derive on Class is a leftover
from the SurrealDB 3 work.
The actor builds the union of all item tags, which is much larger than the
list on the workshop page. Steam gives no API for the declared list, so the
note keeps the scrape and the item count alternative together.
The table markup could not hold a column to its share of the width, so long
titles and descriptions pushed the other columns out. A subgrid keeps the
column widths and lets the description clamp. The table is now the default
view, and the search panel moved to the search component.
The file had no Node section, so node_modules, the SvelteKit output and the
local database dumps all showed as untracked. The dump patterns are anchored
to the root, which keeps the migrations directory tracked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The toggle in the filter panel kept the default gap and padding, so it did
not match the view toggle in the toolbar.
The page reads each filter from the URL, so a filtered list becomes a
link that other people can open.

The date filter takes an updated_before and an updated_after bound in
place of one last_updated cutoff.

The language field holds one value, so the query uses CONTAIN in place
of CONTAINSANY.
The upsert relied on the `distinct()` clause in the `apps.tags` field
definition to remove duplicates. The query now removes them itself.
The title filter used CONTAINS, which does a substring comparison on the
raw title. It now uses @@ against a full-text index with a punctuation
and blank tokenizer.
Test runs against an in-memory SurrealDB spend most of their time in
dependency code.
The tag filter listed obsolete tags and one-off tags. The actor asks
Steam for a total per tag, then the query hides the tags with fewer
than 1000 items.
disconsented and others added 10 commits September 11, 2026 17:41
The onTagsChange function had no caller, and the query builder kept a
commented copy of the member count test that now runs in the query
text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lw1BsaF6svux3dhReiiQr1
A threshold of 1000 members hid almost every tag on the smaller apps.
Darkest Dungeon showed only 3 of them.
loadParams only wrote a filter when the URL supplied it. A removed
parameter kept the previous value in the form.
An absent parameter set both filters to an empty value, which kept them
out of the list request.
The item page becomes a sidebar and a tabbed body. Body tabs page and
filter the dependencies and the dependants on their own.

The workshopwalker theme replaces hamlindigo. It uses Atkinson
Hyperlegible Next for the text and IBM Plex Mono for the headings.

Properties.svelte and lang.ts hold the code that the item card and the
item page both use.
The pagination read the full item count. It showed pages that the tag
and language filter had already emptied.
The item page rebuild left unused snippets, commented markup, and an
unused style block behind.
Name the tag and the language filter groups. Set the sidebar headings
to upper case. The card background used a colour custom property that
does not exist.
The chart shows sample data. The label tells the user that the values
are a placeholder.
@disconsented
disconsented marked this pull request as ready for review September 14, 2026 05:14
@disconsented

Copy link
Copy Markdown
Owner Author
image

@disconsented

Copy link
Copy Markdown
Owner Author
image

@disconsented

Copy link
Copy Markdown
Owner Author
image

Comment thread src/db/apps_repository.rs
.db
.query("SELECT *, tags.*, default_tags.* FROM apps WHERE available = true;")
.query(
"SELECT *, tags.filter(|$tag|$tag.known_members > 100).*, default_tags.* FROM \

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High — every app's tag list goes empty on deploy, and long-tail tags never come back.

migrations/1789088642905_hide_tags.surql sets known_members = 0 on every existing tag, and all three queries here now drop anything at or below 100. Immediately after the migration /api/app/:id returns tags: [], so the tag chips in search.svelte disappear and +page.ts can no longer resolve default_tags (they are matched against app.v.tags).

Counts only return when TagsActor sees a tag it does not already have cached, which only happens when ItemUpdateActor upserts an item carrying it — and steam_download_actor only pulls items updated in the last 12 h. A declared-but-quiet tag (Scenario, Translation, an older version tag) may hold thousands of items and still sit at known_members = 0 indefinitely, permanently invisible.

A backfill pass at startup (enumerate tags where known_members = 0 and queue UpdateCount) would close the gap. The threshold is also hard-coded in three places.

Comment thread src/db/tags_actor.rs
}
.into_request(&state.client, &state.steam_token)
.whatever_context("into_request")?;
let response = state

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — a failed Steam call leaves known_members at 0 forever.

The response status is never checked. A 429, a 403 on a bad key, or a 5xx returns an HTML or JSON error body, .json::<SteamRoot<GetTagCountResponse>>() fails, the error is logged and the message is dropped. There is no retry, and the tag is not re-queued until the 24 h Clear happens to coincide with an item that carries it.

Combined with apps_repository's known_members > 100 filter, one transient rate-limit during a crawl burst hides that tag from the UI for at least a day. Add .error_for_status() and requeue UpdateCount (with backoff) on failure.

Comment thread src/db/tags_actor.rs
}
acc
});
if let Err(error) = state.service.update_tags(appid.clone(), new_tags).await {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — this runs a write transaction for every item, which is what the doc comment says it avoids.

The type doc claims a deliberate trade-off to "sometimes upsert redundantly instead of adding an extra query for every item", but update_tags is called unconditionally, including when new_tags is empty — which is the common case once the cache is warm. upsert_tags then opens BEGIN/UPDATE apps:<id> SET tags = tags.union([])/COMMIT, so a full crawl issues one transaction per item, all serialised on the same apps record.

Suggested change
if let Err(error) = state.service.update_tags(appid.clone(), new_tags).await {
if !new_tags.is_empty()
&& let Err(error) = state.service.update_tags(appid.clone(), new_tags).await
{
error!(?error, ?appid, "Failed to update tags");
}

Separately, each new tag also blocks this actor on a synchronous Steam round trip via UpdateCount, so AddTagToApp messages queue behind HTTP latency during ingestion.

Comment thread src/steam/model.rs
("key", access_token),
("cursor", "*"),
(
"query_type",

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — query_type is fed a value from the wrong enum.

query_type takes an EPublishedFileQueryType; EPublishedFileInfoMatchingFileType is what the separate filetype parameter takes. MatchingFileType_Items is 0, which lands on EPublishedFileQueryType::RankedByVote — and TagsActor's own doc comment says to use query_type=1. RankedByVote is the one query type Steam windows by days, so the total you read back can be a subset rather than the tag's real item count, which then feeds the known_members > 100 cut in apps_repository.

Suggested change
"query_type",
"query_type",
&(EPublishedFileQueryType::RankedByPublicationDate as i64).to_string(),

Comment thread src/web/query.rs
conditions.push(Expr::Binary {
left: Box::new(Expr::Idiom(Idiom::field("title".to_string()))),
op: BinaryOperator::Contain,
op: BinaryOperator::Matches(MatchesOperator {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — swapping CONTAINS for @@ drops partial-word title search, and fails silently without the index.

Verified against surreal 3.2 with the analyzer and index from the two new migrations: title @@ 'Van' returns nothing for 'Vanilla Expanded Framework'@@ matches whole analyzer tokens, so the search box no longer does prefix or substring matching. Typing into it now returns nothing until a complete word is entered. It also became OR-across-terms rather than a single substring.

Worse, on a database where 1789087854843_title_full_text_index.surql has not run, @@ does not error — it just returns zero rows for every query, so title search looks like "no results" rather than a broken deployment.

If prefix matching is wanted, an ngram/edgengram filter on title_analyzer restores it.

<span>
by
<a
href="https://steamcommunity.com/id/{item.author.id}"

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High — unguarded item.author crashes the whole item page, and /id/ is the wrong profile path.

FullWorkshopItem.author is Option<ExternalUsername> with no skip_serializing_if, so the API sends "author": null whenever the usernames record has not been resolved yet. item.author.id then throws and, because this is rendered inside the {:then} of +page.svelte, the entire item page renders blank. The page this replaced guarded it: {#if item.author}.

Second issue on the same line: UsernameID is an i64 SteamID64 (define_id!("usernames", IUsernameID, UsernameID, i64)), and steamcommunity.com/id/<steamid64> is the vanity URL path — it 404s. The removed titleCard used /profiles/.

Wrap the author block in {#if item.author} and use /profiles/.

<div class="flex w-full min-w-0 flex-col">
<span class="overflow-hidden text-nowrap text-ellipsis">{item.title}</span>
<span class="overflow-hidden text-sm text-nowrap text-ellipsis opacity-50"
>{item.author.name}</span

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — same null-author crash on dependency cards.

Dependencies and dependants are ExternalFullWorkshopItem, whose author is Option<ExternalUsername> serialised as null. One dependency with an unresolved author throws here and takes the whole tab down.

Suggested change
>{item.author.name}</span
>{item.author?.name ?? 'Unknown'}</span

</div>
<div role="cell" class="flex place-items-center">
<a
href="https://steamcommunity.com/id/{item.author.id}"

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — wrong Steam profile path.

item.author.id is a SteamID64 (define_id!("usernames", IUsernameID, UsernameID, i64)). steamcommunity.com/id/<n> looks up a vanity URL and 404s for a numeric id; the numeric form needs /profiles/. Every author link in table view is dead.

Suggested change
href="https://steamcommunity.com/id/{item.author.id}"
href="https://steamcommunity.com/profiles/{item.author.id}"

(itemCard.svelte:59 carries the same pre-existing bug.)

.toSorted((b, a) => (a.last_updated || 0) - (b.last_updated || 0))
);

let page = $state(1);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — the page reset on filter change was lost in the split.

The old +page.svelte had $effect(() => { selectedTags; selectedLangs; page = 1; }); it did not survive the move into this component. Now: open an item with 40 dependants, page to 3, then tick a tag in the filter. filtered drops to 8 entries, page is still 3, and filtered.slice(30, 45) renders an empty grid with no obvious way back other than clicking page 1.

Suggested change
let page = $state(1);
let page = $state(1);
$effect(() => {
selectedTags;
selectedLangs;
page = 1;
});

let page = $state(1);
let size = $state(15);
const slicedSource = $derived((s) => s.slice((page - 1) * size, page * size));
let pageSize = $state(15);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — page is never reset after a search.

search.svelte navigates with goto('?' + params), which reruns load but keeps this component instance, so page survives. Page to 6 of a 200-result set, then add a tag that narrows it to 12: count becomes 12 but page stays 6, slicedSource returns value.slice(75, 90), and the grid renders empty while the header reads "12 results".

Reset it when the result set changes, e.g. $effect(() => { data.searchRequest; page = 1; }), or clamp page against Math.ceil(value.length / pageSize).

firstRun = !!app.v;
app.v = await res.json();
if (firstRun) {
if (firstRun && tags.v.length == 0) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — the URL does not round-trip an empty tag selection.

loadParams sets tags.v = [] when the URL carries no tags, and then this branch puts the app's default_tags back. Within one session firstRun is already false so it does not bite — but deselect every tag, copy the resulting URL (which correctly has no tags param), and open it in a new tab: firstRun is true again, the defaults are silently reapplied, and the chips in search.svelte show as selected while the URL says otherwise. Same on a plain refresh.

Distinguish "no tags param" from "tags explicitly empty" — e.g. have search.svelte emit a sentinel, or only apply defaults when the URL has no search params at all.

<Portal>
<Tooltip.Positioner>
<Tooltip.Content class="card preset-filled-surface-950-50 p-2">
<span>"{tag}"</span>

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — renders "[object Object]".

Here tag is { id, display_name } (from all_tags), not a string. The equivalent block in search.svelte interpolates a plain string tag, which is where this was copied from. Every tag tooltip in the filter shows "[object Object]".

Suggested change
<span>"{tag}"</span>
<span>"{tag.display_name}"</span>

</script>

{#if first_props}
{@debug first_props}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — {@debug} ships a debugger statement to production.

Svelte compiles {@debug x} to console.log(x); debugger; unconditionally — it is not dev-gated (svelte/src/compiler/phases/3-transform/client/visitors/DebugTag.js). This component renders once per item card, so with devtools open the browser breaks on every card in the grid and on the item page.

PropertyPrompt.svelte:59 has the same {@debug value}; Body.svelte has a leftover $inspect, and +page.svelte (both routes) console.log(data).

Suggested change
{@debug first_props}

</div>
<hr class="hr border-b-surface-200-800" />
<div class="flex flex-col">
<span class="uppercase opacity-50">Popularity · 12 months · (Placeholder)</span>

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — the placeholder chart ships ~5,200 lines of AAPL prices to every item page.

PopularityChart.svelte is a static 2007 Apple stock series in module scope, and its tooltip formats the values with format(data.value, 'currency') — so the "Popularity" panel shows dollar amounts from 2007 on every item, and the whole array is bundled into the item route chunk.

"(Placeholder)" in the label helps, but it is still fabricated data presented as a chart. Worth gating behind a flag, or dropping the series and rendering an empty state until the real data exists.

{#each [5, 10, 15, 30] as v}
<option value={v}>Items {v}</option>
{/each}
<option value={value.length}>Show All</option>

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — "Show All" freezes pageSize at the count it had when clicked.

pageSize is set to value.length at selection time, not tracked. Pick "Show All" on a 12-result search, then broaden the filter to 300 results: the select still reads "Show All" but only the first 12 render. When value.length is 0 it also sets pageSize = 0, and when it happens to equal 5/10/15/30 you get a duplicate <option> value.

Use a sentinel (value={-1}) and resolve it at slice time instead.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant