Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions OPEN-CORE.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,22 @@ Promise<{liked, likeCount} | null>` puts a heart on every card (`[data-card-hear
A play link's frame may carry `&src=play|embed&b=v<n>` (read once with `embed=1`;
`playMode.embedSource` / `embedBuild`) — the badge inside counts them.

#### 76 FW3: search and sorts on the Community tab — additive, no bump

The Templates modal has ONE search field (`#templates-search`) on every tab: core filters the
cards client-side — every word must appear in the title, description, author or tags
(`communitySearch.matchesQuery`) — so the OSS GitHub gallery is searchable with no server. Two
optional provider members widen it on Community:

| member | contract |
|---|---|
| `search: true` | `list()` also receives `q` (lower-case, single-spaced, ≤ 60 chars, debounced 300 ms) when the field is not empty; search the same fields server-side so a returned row also passes core's filter. An older query's late answer is dropped |
| `sorts: [{id, label}]` | ≤ 5 chips beside the field (`#community-sorts [data-sort]`); the FIRST is the default and is never sent — another chip's `id` arrives as `list({sort})` |

Absent = absent: a provider that declares neither is called `list({force})` exactly as before
and is never re-listed for a query. While a re-list runs the grid stays up and
`#community-searching` says "Searching…"; an empty answer to a query reads "No scenes match".

#### 37 R20: "Report a problem" — `setProblemReporter(reporter | null)` — additive, no bump

Core draws the whole report — the screenshot taken at the press, boxes drawn round what is
Expand Down
108 changes: 105 additions & 3 deletions src/components/menu/TemplatesModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import Tabs from '../ui/Tabs.svelte';
import EmptyState from '../ui/EmptyState.svelte';
import Icon from '../ui/Icon.svelte';
import SearchField from '../ui/SearchField.svelte';
import { templatesModalOpen, templatesModalTab, hidePanels, restorePanels } from '../../stores/appStore.js';
import {
templates,
Expand All @@ -19,6 +20,9 @@
communityEntries,
communityState,
communityNotice,
communityQuery,
communitySearching,
setCommunityQuery,
loadingSlug,
loadTemplatesIndex,
loadCommunityGallery,
Expand All @@ -33,6 +37,7 @@
// 28-A6: a cloud plugin's Community provider — null in the OSS build. Read for two
// things only: whether the GitHub copy applies, and the provider's own submit control.
import { communityProvider } from '$lib/cloudHooks';
import { matchesQuery, normQuery } from '$lib/communitySearch.js';
import { licenseLabel } from '$lib/packs';
import { classifyRequirements } from '$lib/moduleRequirements';
import HeartButton from '../ui/HeartButton.svelte';
Expand All @@ -59,8 +64,45 @@
// (`provider.mine`) and marks the viewer's own entries; a facet of its own, AND-ed with tags
let mineOnly = $state(false);
const mineChip = $derived(tab === 'community' && $communityProvider?.mine === true);
// 76 FW3: the search field — one query for every tab (it survives a tab switch, unlike the
// chips: a word means the same thing in each tab). Client-side over title, description,
// author and tags everywhere; a provider that declared `search: true` also gets it
// server-side (debounced), so Community finds scenes beyond the first page.
let query = $state('');
const q = $derived(normQuery(query));
// 76 FW3: the provider's sorts ({id, label}[], the first is its default) — Community only
const sorts = $derived(
tab === 'community' && Array.isArray($communityProvider?.sorts)
? $communityProvider.sorts
.filter((/** @type {any} */ s) => s && typeof s.id === 'string' && typeof s.label === 'string')
.slice(0, 5)
: []
);
const sortId = $derived(
sorts.some((/** @type {any} */ s) => s.id === $communityQuery.sort) ? $communityQuery.sort : (sorts[0]?.id ?? '')
);
/** @param {string} id */
function pickSort(id) {
setCommunityQuery({ q: $communityQuery.q, sort: id === sorts[0]?.id ? '' : id });
}
// the server half is debounced; the client filter above is immediate
$effect(() => {
const next = q;
const t = setTimeout(() => untrack(() => setCommunityQuery({ q: next, sort: $communityQuery.sort })), 300);
return () => clearTimeout(t);
});
/** @param {KeyboardEvent} e Escape clears a non-empty field before it closes the modal */
function searchKey(e) {
if (e.key === 'Escape' && query) {
e.preventDefault();
e.stopPropagation();
query = '';
}
}
const shown = $derived(
tabEntries.filter((/** @type {any} */ e) => matchesTags(e, activeTags) && (!mineOnly || !mineChip || e.mine === true))
tabEntries.filter(
(/** @type {any} */ e) => matchesTags(e, activeTags) && (!mineOnly || !mineChip || e.mine === true) && matchesQuery(e, q)
)
);
// 36-community (C6): a provider that can write a like puts a heart on its cards
const canLike = $derived(tab === 'community' && typeof $communityProvider?.toggleLike === 'function');
Expand Down Expand Up @@ -141,8 +183,10 @@
}
});
});
// the Community manifest fetches lazily, first time the tab is opened
// the Community manifest fetches lazily, first time the tab is opened — and again when the
// query/sort it answers changes (76 FW3; loadCommunityGallery memoizes per query)
$effect(() => {
void $communityQuery;
const wants = $templatesModalOpen && tab === 'community';
untrack(() => {
if (wants) loadCommunityGallery();
Expand Down Expand Up @@ -329,6 +373,35 @@
{/snippet}
<div class="tpl-body">

<!-- 76 FW3: one search field for every tab, and the provider's sorts on Community -->
<div class="tpl-search-row">
<SearchField
id="templates-search"
clearId="templates-search-clear"
bind:value={query}
placeholder={tab === 'community' ? 'Search scenes, makers, tags…' : 'Search titles, tags…'}
label="Search templates"
size="sm"
onkeydown={searchKey}
/>
{#if sorts.length}
<div id="community-sorts" class="tpl-sorts" role="group" aria-label="Sort community scenes">
{#each sorts as s (s.id)}
<button
class="tpl-chip"
class:active={sortId === s.id}
data-sort={s.id}
aria-pressed={sortId === s.id}
onclick={() => pickSort(s.id)}>{s.label}</button
>
{/each}
</div>
{/if}
</div>
{#if tab === 'community' && $communitySearching}
<p id="community-searching" class="mb-2 text-xs italic text-text-faint" aria-live="polite">Searching…</p>
{/if}

<!-- A7: tag chips, shared by all four tabs and derived from the ACTIVE tab's own
tags. OR within the facet (see matchesTags) — an AND would empty the grid on
the second click, which reads as a broken filter. VR is just a chip. -->
Expand Down Expand Up @@ -480,6 +553,8 @@
</div>
{#if !shown.length && mineOnly}
<p id="community-mine-empty" class="mt-3 text-xs italic text-text-faint">Nothing published yet — publish a scene and it shows here.</p>
{:else if !shown.length && q && !$communitySearching}
<p id="community-search-empty" class="mt-3 text-xs italic text-text-faint">No scenes match “{q}”.</p>
{/if}
<p class="mt-3 text-xs text-text-faint">
{#if !provided}
Expand All @@ -492,7 +567,9 @@
<p class="text-sm text-text-muted">
{$communityState === 'error'
? "Couldn't reach the community gallery — check your connection."
: 'No community scenes yet — be the first!'}
: $communityQuery.q
? `No scenes match “${$communityQuery.q}”.`
: 'No community scenes yet — be the first!'}
</p>
{#if !provided}
<p class="text-xs text-text-faint">
Expand Down Expand Up @@ -531,6 +608,31 @@
gap: 6px;
margin-bottom: var(--space-4);
}
/* 76 FW3: the search field + the provider's sort chips share one row, wrapping on a phone */
.tpl-search-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.tpl-search-row > :global(.sf) {
flex: 1 1 220px;
}
.tpl-sorts {
display: flex;
gap: 6px;
}
/* a touch screen wider than the phone breakpoint (Find N6 unfolded, a tablet) still needs
44 px targets — SearchField's own 44 px rule is width-based */
@media (pointer: coarse) {
.tpl-search-row > :global(.sf) {
height: 44px;
}
.tpl-search-row .tpl-chip {
height: 44px;
}
}
.tpl-chip {
height: var(--control-h-sm);
padding: 0 12px;
Expand Down
36 changes: 36 additions & 0 deletions src/lib/communitySearch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// 76 FW3: the Templates modal's search field — pure, so the unit suite and the modal agree.
// A query is up to 60 characters of words; a card matches when EVERY word appears somewhere
// in its title, description, author or tags (case-insensitive substring), so "space race"
// finds a scene titled "Race" tagged #space. The cloud provider searches the same four
// fields server-side (title, summary, tags, the maker's @handle), so a card the server
// returned also passes this filter.

/** @param {unknown} raw @returns {string} trimmed, single-spaced, lower-case, ≤ 60 chars */
export function normQuery(raw) {
return String(raw ?? '')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase()
.slice(0, 60)
.trim();
}

/** @param {unknown} q @returns {string[]} the distinct words, at most 6 (a leading # or @ is dropped) */
export function queryWords(q) {
const words = normQuery(q)
.split(' ')
.map((w) => w.replace(/^[#@]+/, ''))
.filter(Boolean);
return [...new Set(words)].slice(0, 6);
}

/** @param {any} entry a normalized card entry @param {unknown} q @returns {boolean} */
export function matchesQuery(entry, q) {
const words = queryWords(q);
if (!words.length) return true;
const hay = [entry?.title, entry?.description, entry?.author, ...(Array.isArray(entry?.tags) ? entry.tags : [])]
.filter((v) => typeof v === 'string' && v)
.join('\n')
.toLowerCase();
return words.every((w) => hay.includes(w));
}
56 changes: 49 additions & 7 deletions src/lib/sceneTemplates.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { claimLoad, isLive, endLoad, updateLoad } from './sceneLoader';
// 28-A6: the Community source a cloud plugin may install (store-only, no cycle — the
// objectPermissions import above already reaches cloudHooks' family)
import { communityProvider, notifyTemplateOpen } from './cloudHooks';
import { normQuery } from './communitySearch.js';
import { primeLoadOrigin } from './gameIdentity.js';
// 33 (L3): the Clear scene modal's setup check and full clear (see confirmClearScene)
import * as sceneSwitch from './sceneSwitch';
Expand Down Expand Up @@ -81,6 +82,23 @@ export const loadingSlug = writable(null);
* the OSS build, so the modal renders no row.
* @type {import('svelte/store').Writable<{text: string, href?: string} | null>} */
export const communityNotice = writable(null);
/** 76 FW3: what the Community tab asks its provider for — `q` (normQuery'd text, '' = none)
* and `sort` (an id from `provider.sorts`, '' = the provider's default). Written by the modal;
* a provider that declared `search: true` / `sorts` receives both in `list()`.
* @type {import('svelte/store').Writable<{q: string, sort: string}>} */
export const communityQuery = writable({ q: '', sort: '' });
/** 76 FW3: true while a query/sort re-list is in flight — the grid stays up (no skeleton
* flash on every keystroke) and the modal shows a quiet "Searching…" instead.
* @type {import('svelte/store').Writable<boolean>} */
export const communitySearching = writable(false);
/** 76 FW3: the modal's one write path for the above (normalized here, so a store reader never
* sees raw input) @param {{q?: unknown, sort?: unknown}} next */
export function setCommunityQuery(next) {
const q = normQuery(next?.q);
const sort = typeof next?.sort === 'string' ? next.sort.slice(0, 40) : '';
const cur = get(communityQuery);
if (cur.q !== q || cur.sort !== sort) communityQuery.set({ q, sort });
}

/** Resolve an index path against a CDN base: absolute http(s) URLs pass through
* (big .tpscene files >~20MB point at raw.githubusercontent — the jsDelivr file
Expand Down Expand Up @@ -147,6 +165,10 @@ export function matchesTags(entry, active) {
* the modal in a tab that outlived a deploy re-fetches instead of showing the old list. */
let templatesStale = false;
let communityStale = false;
/** 76 FW3: the newest Community list request — an older answer that lands late is dropped —
* and the query it asked (`q\nsort`), so a changed query re-lists instead of hitting the memo */
let communitySeq = 0;
let askedKey = '\n';
onContentStale(() => {
templatesStale = true;
communityStale = true;
Expand Down Expand Up @@ -231,23 +253,40 @@ function normalizeProviderEntry(e) {
* way, so the modal needs no state work. */
export async function loadCommunityGallery(force = false) {
const state = get(communityState);
if (!force && !communityStale && (state === 'ready' || state === 'empty' || state === 'loading')) return;
communityStale = false;
communityState.set('loading');
const provider = get(communityProvider);
// 76 FW3: the part of the query this source answers — absent = absent, so a provider that
// declared neither `search` nor `sorts` (and the GitHub path) sees the 36-era call
const { q, sort } = get(communityQuery);
/** @type {any} */
const ask = { force: !!force };
if (provider?.search === true && q) ask.q = q;
if (Array.isArray(provider?.sorts) && sort) ask.sort = sort;
const key = `${ask.q ?? ''}\n${ask.sort ?? ''}`;
const requery = key !== askedKey;
if (!force && !requery && !communityStale && (state === 'ready' || state === 'empty' || state === 'loading')) return;
communityStale = false;
askedKey = key;
// a query/sort re-list keeps the grid that is showing; only a first load skeletons
const keepGrid = requery && (state === 'ready' || state === 'empty');
if (keepGrid) communitySearching.set(true);
else communityState.set('loading');
const seq = ++communitySeq;
if (provider) {
try {
const raw = await provider.list({ force: !!force });
const raw = await provider.list(ask);
// the provider may have been swapped/cleared while its list was in flight — a
// late answer must not overwrite what the new source (or the GitHub path) shows
if (get(communityProvider) !== provider) return;
// late answer must not overwrite what the new source (or the GitHub path) shows;
// 76 FW3: nor may an older query's answer overwrite a newer one's
if (get(communityProvider) !== provider || seq !== communitySeq) return;
const list = (Array.isArray(raw) ? raw : []).map(normalizeProviderEntry);
communityEntries.set(list);
communityState.set(list.length ? 'ready' : 'empty');
} catch {
if (get(communityProvider) !== provider) return;
if (get(communityProvider) !== provider || seq !== communitySeq) return;
communityEntries.set([]);
communityState.set('error');
} finally {
if (seq === communitySeq) communitySearching.set(false);
}
let notice = null;
try {
Expand All @@ -259,6 +298,8 @@ export async function loadCommunityGallery(force = false) {
return;
}
communityNotice.set(null);
// the GitHub gallery is one small file: its search and sort are the modal's client-side filter
communitySearching.set(false);
try {
const res = await fetchIndex(GALLERY_JSON_URL);
if (res.ok) {
Expand Down Expand Up @@ -573,5 +614,6 @@ communityProvider.subscribe((provider) => {
lastProvider = provider;
communityEntries.set([]);
communityNotice.set(null);
communitySearching.set(false);
communityState.set('idle');
});
1 change: 1 addition & 0 deletions tests/e2e/battery.json
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,7 @@
"collider-viz": 20,
"color-picker-15c": 38,
"command-palette": 158,
"community-search": 160,
"community-seams": 210,
"connect-decision": 812,
"connect-overlay": 15,
Expand Down
Loading
Loading