feat: New UI - #67
feat: New UI#67disconsented wants to merge 45 commits into
Conversation
`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.
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.
de49a09 to
58e5aaf
Compare
| .db | ||
| .query("SELECT *, tags.*, default_tags.* FROM apps WHERE available = true;") | ||
| .query( | ||
| "SELECT *, tags.filter(|$tag|$tag.known_members > 100).*, default_tags.* FROM \ |
There was a problem hiding this comment.
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.
| } | ||
| .into_request(&state.client, &state.steam_token) | ||
| .whatever_context("into_request")?; | ||
| let response = state |
There was a problem hiding this comment.
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.
| } | ||
| acc | ||
| }); | ||
| if let Err(error) = state.service.update_tags(appid.clone(), new_tags).await { |
There was a problem hiding this comment.
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.
| 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.
| ("key", access_token), | ||
| ("cursor", "*"), | ||
| ( | ||
| "query_type", |
There was a problem hiding this comment.
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.
| "query_type", | |
| "query_type", | |
| &(EPublishedFileQueryType::RankedByPublicationDate as i64).to_string(), |
| conditions.push(Expr::Binary { | ||
| left: Box::new(Expr::Idiom(Idiom::field("title".to_string()))), | ||
| op: BinaryOperator::Contain, | ||
| op: BinaryOperator::Matches(MatchesOperator { |
There was a problem hiding this comment.
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}" |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
| >{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}" |
There was a problem hiding this comment.
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.
| 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); |
There was a problem hiding this comment.
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.
| 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); |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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> |
There was a problem hiding this comment.
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]".
| <span>"{tag}"</span> | |
| <span>"{tag.display_name}"</span> |
| </script> | ||
|
|
||
| {#if first_props} | ||
| {@debug first_props} |
There was a problem hiding this comment.
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).
| {@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> |
There was a problem hiding this comment.
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> |
There was a problem hiding this comment.
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.



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