Skip to content

Commit d9b2e41

Browse files
committed
fix(configurator): track viewport changes live + dedupe count helpers
Findings from a 7-angle static review of the IA-restructure diff plus a behavioral Playwright sweep (console-clean across every route in both modes at 1600/1000/480px; hostile-input sanitisation; corrupt-storage recovery; import round-trip; generator edge inputs; theme gallery): - App: the narrow-viewport check is now a matchMedia change listener — shrinking the window dismisses the preview overlay instead of dropping a scrim over the app, and widening it back restores the desktop pane (previously the preview stayed closed forever after one resize) - model.js: new modifiedCountsByDomain() shared by Sidebar badges and the Home checklist, so the two counts can never drift - store: openOutputDrawer() helper deduplicates the header pill and Home shortcut logic - Home: 'start here' pointer hoisted to a derived (was an O(n^2) findIndex re-run inside the each template) - domains.js: docs links base URL extracted to DOCS_BASE_URL - tests: border presets now pin the framework default radii (4/8/12px) so a framework retune redesigns the presets instead of silently skewing their semantics https://claude.ai/code/session_01DCCWK2EPSRdhBDZ7f25NxT
1 parent 244ac55 commit d9b2e41

9 files changed

Lines changed: 75 additions & 45 deletions

File tree

configurator/src/App.svelte

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,17 @@
5757
5858
// On narrow viewports the preview is a slide-over overlay, so it must start
5959
// closed — opening a full-width scrim on first paint would bury the panel.
60+
// The media query is tracked live: shrinking the window dismisses the
61+
// overlay instead of dropping a scrim onto the app, and widening it back
62+
// restores the desktop pane (its default-open state).
6063
$effect(() => {
61-
if (window.matchMedia('(max-width: 1100px)').matches) ui.previewOpen = false;
64+
const mql = window.matchMedia('(max-width: 1100px)');
65+
if (mql.matches) ui.previewOpen = false;
66+
const onChange = (e) => {
67+
ui.previewOpen = !e.matches;
68+
};
69+
mql.addEventListener('change', onChange);
70+
return () => mql.removeEventListener('change', onChange);
6271
});
6372
6473
// Keyboard shortcuts: '/' focuses the search box; 'b'/'a' switch mode;

configurator/src/components/DomainPanel.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* so each domain is just curation — no bespoke per-field code.
1515
*/
1616
import { allTokens, groupTokens, matchesQuery, tokenByName } from '../lib/model.js';
17-
import { domainOf, KNOBS_BY_DOMAIN } from '../lib/domains.js';
17+
import { domainOf, KNOBS_BY_DOMAIN, DOCS_BASE_URL } from '../lib/domains.js';
1818
import { BASIC_BY_DOMAIN } from '../lib/basics.js';
1919
import { BRAND_COLOR_KEYS } from '../lib/brandColors.js';
2020
import { ui, overrides, patchOverrides } from '../lib/store.svelte.js';
@@ -319,7 +319,7 @@
319319
{#if domain.docsPath}
320320
<p class="panel__docs">
321321
<a
322-
href="https://github.com/codeslash-dev/SLASHED/blob/main/{domain.docsPath}"
322+
href="{DOCS_BASE_URL}{domain.docsPath}"
323323
target="_blank"
324324
rel="noreferrer"
325325
>Learn more about {domain.label.toLowerCase()} in the framework docs →</a>

configurator/src/components/Header.svelte

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* reachable. Keyboard shortcut: `/` focuses the search box.
1515
*/
1616
import { sync, allTokens } from '../lib/model.js';
17-
import { ui, overrides, overrideCount, history, undo, redo } from '../lib/store.svelte.js';
17+
import { ui, overrides, overrideCount, history, undo, redo, openOutputDrawer } from '../lib/store.svelte.js';
1818
1919
const totalTokens = allTokens.length;
2020
const modCount = $derived(Object.keys(overrides).length);
@@ -41,12 +41,7 @@
4141
{#if modCount > 0}
4242
<button
4343
class="hdr__pill hdr__pill--mod hdr__pill--btn"
44-
onclick={() => {
45-
ui.outputOpen = true;
46-
requestAnimationFrame(() => {
47-
document.querySelector('.out')?.scrollIntoView({ block: 'end', behavior: 'smooth' });
48-
});
49-
}}
44+
onclick={openOutputDrawer}
5045
title="{modCount} active override{modCount === 1 ? '' : 's'} — open the export drawer"
5146
>
5247
{modCount} customised · Export CSS

configurator/src/components/Home.svelte

Lines changed: 10 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,33 +8,19 @@
88
* customised, so the screen doubles as orientation ("what do I change per
99
* project?") and progress tracking ("what have I already touched?").
1010
*/
11-
import { DOMAINS, BASIC_DOMAIN_IDS, domainOf } from '../lib/domains.js';
12-
import { tokenByName } from '../lib/model.js';
13-
import { ui, overrides } from '../lib/store.svelte.js';
11+
import { DOMAINS, BASIC_DOMAIN_IDS } from '../lib/domains.js';
12+
import { modifiedCountsByDomain } from '../lib/model.js';
13+
import { ui, overrides, openOutputDrawer } from '../lib/store.svelte.js';
1414
1515
const rows = $derived(DOMAINS.filter((d) => BASIC_DOMAIN_IDS.includes(d.id)));
1616
17-
// Domain id → number of currently-overridden tokens (same logic as Sidebar).
18-
const mods = $derived.by(() => {
19-
const c = {};
20-
for (const name of Object.keys(overrides)) {
21-
const t = tokenByName.get(name);
22-
if (!t) continue;
23-
const id = domainOf(t);
24-
c[id] = (c[id] || 0) + 1;
25-
}
26-
return c;
27-
});
17+
// Domain id → number of currently-overridden tokens (same map as Sidebar).
18+
const mods = $derived.by(() => modifiedCountsByDomain(overrides));
2819
2920
const totalMods = $derived(Object.keys(overrides).length);
3021
31-
function openExport() {
32-
ui.outputOpen = true;
33-
// The drawer lives at the bottom of the shell — bring it into view.
34-
requestAnimationFrame(() => {
35-
document.querySelector('.out')?.scrollIntoView({ block: 'end', behavior: 'smooth' });
36-
});
37-
}
22+
// The first untouched non-tool domain gets the "start here" pointer.
23+
const startId = $derived(rows.find((r) => !r.tool && !mods[r.id])?.id ?? null);
3824
</script>
3925
4026
<section class="home">
@@ -47,14 +33,14 @@
4733
(<kbd class="cfg-kbd">A</kbd>).
4834
</p>
4935
{#if totalMods > 0}
50-
<button class="home__export" onclick={openExport} title="Open the output drawer with your override CSS">
36+
<button class="home__export" onclick={openOutputDrawer} title="Open the output drawer with your override CSS">
5137
{totalMods} token{totalMods === 1 ? '' : 's'} customised — Export CSS
5238
</button>
5339
{/if}
5440
</header>
5541
5642
<ul class="home__list">
57-
{#each rows as d, i (d.id)}
43+
{#each rows as d (d.id)}
5844
<li>
5945
<button class="home__row" onclick={() => (ui.domain = d.id)}>
6046
<span class="home__icon" aria-hidden="true">{d.icon}</span>
@@ -67,7 +53,7 @@
6753
<span class="home__count">presets</span>
6854
{:else if mods[d.id]}
6955
<span class="home__count home__count--mod">{mods[d.id]} customised</span>
70-
{:else if !d.tool && i === rows.findIndex((r) => !r.tool && !mods[r.id])}
56+
{:else if d.id === startId}
7157
<span class="home__count home__count--start">start here →</span>
7258
{:else}
7359
<span class="home__count">defaults</span>

configurator/src/components/Sidebar.svelte

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
* icon font — keeps the configurator a single self-contained bundle.
1414
*/
1515
import { DOMAINS, BASIC_DOMAIN_IDS, domainOf } from '../lib/domains.js';
16-
import { allTokens, tokenByName } from '../lib/model.js';
16+
import { allTokens, modifiedCountsByDomain } from '../lib/model.js';
1717
import { ui, overrides } from '../lib/store.svelte.js';
1818
1919
// Basic mode shows the per-project checklist only (6 domains + Themes),
@@ -36,16 +36,7 @@
3636
});
3737
3838
// Domain id → number of currently-overridden tokens (the "modified" badge).
39-
const mods = $derived.by(() => {
40-
const c = {};
41-
for (const name of Object.keys(overrides)) {
42-
const t = tokenByName.get(name);
43-
if (!t) continue;
44-
const id = domainOf(t);
45-
c[id] = (c[id] || 0) + 1;
46-
}
47-
return c;
48-
});
39+
const mods = $derived.by(() => modifiedCountsByDomain(overrides));
4940
</script>
5041

5142
<aside class="side" class:side--narrow={!ui.sidebarOpen} aria-label="Categories">

configurator/src/lib/domains.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@
2121
*/
2222
import { basicControlTokens } from './basics.js';
2323

24+
/**
25+
* Base URL for the per-domain "Learn more" docs links (joined with each
26+
* domain's `docsPath`). Single definition so a repo move is a one-line fix.
27+
*/
28+
export const DOCS_BASE_URL = 'https://github.com/codeslash-dev/SLASHED/blob/main/';
29+
2430
/**
2531
* Domains surfaced in BASIC mode (plus the synthetic Home screen).
2632
*

configurator/src/lib/model.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
* rune-free means it is trivially unit-testable and re-usable.
88
*/
99
import data from '../data/api-index.generated.json' with { type: 'json' };
10+
import { domainOf } from './domains.js';
1011

1112
/** Sync metadata (framework version, generated timestamp, counts). */
1213
export const sync = data._sync ?? {};
@@ -22,6 +23,25 @@ export const defaultsByName = new Map(
2223
allTokens.map((t) => [t.name, t.value ?? ''])
2324
);
2425

26+
/**
27+
* Domain id -> number of currently-overridden tokens it owns. Shared by the
28+
* Sidebar badges and the Home checklist so the two counts can never drift.
29+
* Unknown override names (stale storage) are skipped, matching loadOverrides.
30+
*
31+
* @param {Record<string, string>} overrides token name -> value
32+
* @returns {Record<string, number>}
33+
*/
34+
export function modifiedCountsByDomain(overrides) {
35+
const c = {};
36+
for (const name of Object.keys(overrides)) {
37+
const t = tokenByName.get(name);
38+
if (!t) continue;
39+
const id = domainOf(t);
40+
c[id] = (c[id] || 0) + 1;
41+
}
42+
return c;
43+
}
44+
2545
/**
2646
* Compute the dependents-count map for an arbitrary token list.
2747
*

configurator/src/lib/store.svelte.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,19 @@ export function deleteSavedTheme(id) {
249249

250250
// ───────────────────────────── helpers ────────────────────────────────────
251251

252+
/**
253+
* Expand the output drawer and bring it into view. Shared by the header
254+
* "N customised — Export CSS" pill and the Home checklist shortcut.
255+
*/
256+
export function openOutputDrawer() {
257+
ui.outputOpen = true;
258+
if (typeof document === 'undefined') return;
259+
// The drawer lives at the bottom of the shell — scroll after it expands.
260+
requestAnimationFrame(() => {
261+
document.querySelector('.out')?.scrollIntoView({ block: 'end', behavior: 'smooth' });
262+
});
263+
}
264+
252265
/** Count of active overrides (non-reactive helper for one-off reads). */
253266
export function overrideCount() {
254267
return Object.keys(overrides).length;

configurator/tests/style-presets.test.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,16 @@ describe('preset list invariants', () => {
7070
});
7171
}
7272

73+
test('border presets stay anchored to the framework default radii', () => {
74+
// The preset semantics encode knowledge of the defaults: Subtle is half
75+
// of 4/8/12px, Rounded IS 4/8/12px, Pill scales beyond them. If the
76+
// framework retunes its radius steps this must fail so the presets get
77+
// redesigned alongside (same parity idea as the fluid-engine defaults).
78+
assert.equal(tokenByName.get('--sf-radius-s').value, 'calc(4px * var(--sf-radius-scale))');
79+
assert.equal(tokenByName.get('--sf-radius-m').value, 'calc(8px * var(--sf-radius-scale))');
80+
assert.equal(tokenByName.get('--sf-radius-l').value, 'calc(12px * var(--sf-radius-scale))');
81+
});
82+
7383
test('STYLE_PRESETS_BY_DOMAIN keys are borders + shadows with titles', () => {
7484
assert.deepEqual(Object.keys(STYLE_PRESETS_BY_DOMAIN).sort(), ['borders', 'shadows']);
7585
for (const def of Object.values(STYLE_PRESETS_BY_DOMAIN)) {

0 commit comments

Comments
 (0)