Skip to content
Merged
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
29 changes: 28 additions & 1 deletion configurator/src/components/Preview.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

/** @type {HTMLButtonElement | undefined} */
let closeBtn;
/** @type {HTMLElement | undefined} */
let previewEl;
let isFullscreen = $state(false);

$effect(() => {
if (!window.matchMedia('(max-width: 1100px)').matches) return;
Expand All @@ -13,6 +16,25 @@
};
});

$effect(() => {
const handler = () => { isFullscreen = document.fullscreenElement === previewEl; };
document.addEventListener('fullscreenchange', handler);
return () => document.removeEventListener('fullscreenchange', handler);
});

async function toggleFullscreen() {
if (!previewEl) return;
try {
if (document.fullscreenElement === previewEl) {
await document.exitFullscreen?.();
} else {
await previewEl.requestFullscreen?.();
}
} catch {
// keep UI stable if fullscreen is denied or unavailable
}
}

const VIEWPORTS = [
{ id: 'mobile', emoji: '📱', label: 'Mobile', width: 360, hint: '360 px' },
{ id: 'tablet', emoji: '📱', label: 'Tablet', width: 768, hint: '768 px' },
Expand Down Expand Up @@ -84,7 +106,7 @@
const preventDemoNav = (e) => e.preventDefault();
</script>

<section class="preview">
<section class="preview" bind:this={previewEl}>
<header class="preview__bar">
<div class="preview__title">
<span class="preview__dot" aria-hidden="true"></span>
Expand All @@ -107,6 +129,7 @@
<button class="cfg-seg__btn" class:cfg-seg__btn--on={ui.previewMotion === 'reduced'} onclick={() => (ui.previewMotion = ui.previewMotion === 'reduced' ? 'normal' : 'reduced')} aria-pressed={ui.previewMotion === 'reduced'} aria-label={ui.previewMotion === 'reduced' ? 'Set preview motion to normal' : 'Set preview motion to reduced'} title="Reduced motion in preview only">🐢</button>
</div>
<span class="preview__hint">{ui.previewTheme}{ui.previewMotion === 'reduced' ? ' · reduced motion' : ''}{activeViewport.width ? ` · ${activeViewport.width} px` : ''}</span>
<button class="preview__fullscreen" onclick={toggleFullscreen} title={isFullscreen ? 'Exit full screen' : 'Full screen preview'} aria-label={isFullscreen ? 'Exit full screen' : 'Full screen preview'} aria-pressed={isFullscreen}>{isFullscreen ? '⤡' : '⤢'}</button>
<button bind:this={closeBtn} class="preview__close" onclick={() => (ui.previewOpen = false)} title="Close the preview overlay" aria-label="Close preview">✕</button>
</header>

Expand Down Expand Up @@ -1028,6 +1051,10 @@
.preview__dot { width: 8px; height: 8px; border-radius: 50%; background: var(--cfg-ok); box-shadow: 0 0 0 3px rgba(90,210,122,.18); }
.preview__hint { font-size: 11px; color: var(--cfg-text-faint); text-transform: capitalize; }
.preview__modes :global(.cfg-seg__btn) { padding: 4px 9px; font-size: 12px; }
.preview__fullscreen { width: 26px; height: 26px; padding: 0; font-size: 14px; line-height: 1; color: var(--cfg-text-muted); background: var(--cfg-surface-2); border: 1px solid var(--cfg-border-strong); border-radius: var(--cfg-radius-s); cursor: pointer; flex-shrink: 0; display: inline-grid; place-items: center; }
.preview__fullscreen:hover { color: var(--cfg-text); border-color: var(--cfg-accent-strong); }
.preview:fullscreen { background: var(--cfg-bg); }
.preview:fullscreen .preview__close { display: none; }
.preview__close { display: none; margin-left: auto; width: 26px; height: 26px; padding: 0; font-size: 13px; line-height: 1; color: var(--cfg-text-muted); background: var(--cfg-surface-2); border: 1px solid var(--cfg-border-strong); border-radius: var(--cfg-radius-s); cursor: pointer; flex-shrink: 0; }
.preview__close:hover { color: var(--cfg-text); border-color: var(--cfg-accent-strong); }
@media (max-width: 1100px) {
Expand Down
42 changes: 39 additions & 3 deletions configurator/src/components/ScaleGenerator.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,8 @@
const applicable = $derived(computed.filter((s) => tokenByName.has(s.token)));
const missing = $derived(computed.length - applicable.length);

let isOpen = $state(false);

let applied = $state(false);
function apply() {
if (engineLive) {
Expand Down Expand Up @@ -167,6 +169,15 @@
</script>

<section class="card">
<button type="button" class="gen__toggle" onclick={() => (isOpen = !isOpen)} aria-expanded={isOpen}>
<span class="gen__caret" class:gen__caret--open={isOpen} aria-hidden="true">▶</span>
<span class="gen__title">Scale generator</span>
<span class="gen__kind">{KIND_LABELS[kind] ?? kind} ramp</span>
{#if applied}<span class="gen__applied">Applied ✓</span>{/if}
</button>

{#if isOpen}
<div class="gen__body">
{#if kinds.length > 1}
<div class="seg" role="group" aria-label="Scale kind">
{#each kinds as k (k)}
Expand Down Expand Up @@ -228,16 +239,41 @@
<span class="scales__note">{missing} step(s) not in this catalogue — skipped.</span>
{/if}
</div>
</div>
{/if}
</section>

<style>
.card { background: var(--cfg-surface); border: 1px solid var(--cfg-border); border-radius: var(--cfg-radius); padding: 16px; }
.card { background: var(--cfg-surface); border: 1px solid var(--cfg-border); border-radius: var(--cfg-radius); padding: 0; }

.gen__toggle {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
background: transparent;
border: none;
color: var(--cfg-text);
font: inherit;
padding: 14px 16px;
border-radius: var(--cfg-radius);
cursor: pointer;
text-align: left;
transition: background 0.12s;
}
.gen__toggle:hover { background: var(--cfg-surface-2); }
.gen__caret { font-size: 9px; color: var(--cfg-text-faint); transition: transform 0.12s ease; flex-shrink: 0; }
.gen__caret--open { transform: rotate(90deg); }
.gen__title { font-size: 13px; font-weight: 600; }
.gen__kind { font-size: 12px; color: var(--cfg-text-muted); background: var(--cfg-surface-2); border: 1px solid var(--cfg-border-strong); border-radius: 999px; padding: 1px 9px; }
.gen__applied { font-size: 11.5px; color: var(--cfg-ok); margin-left: auto; }
.gen__body { padding: 0 16px 16px; border-top: 1px solid var(--cfg-border); }

.seg { display: inline-flex; border: 1px solid var(--cfg-border-strong); border-radius: var(--cfg-radius-s); overflow: hidden; margin-bottom: 16px; }
.seg { display: inline-flex; border: 1px solid var(--cfg-border-strong); border-radius: var(--cfg-radius-s); overflow: hidden; margin-top: 16px; margin-bottom: 16px; }
.seg__btn { background: var(--cfg-surface-2); color: var(--cfg-text-muted); border: none; padding: 6px 16px; font-size: 13px; }
.seg__btn--on { background: var(--cfg-accent-strong); color: #fff; }

.controls { display: flex; flex-wrap: wrap; gap: 14px; margin-bottom: 16px; }
.controls { display: flex; flex-wrap: wrap; gap: 14px; margin-top: 16px; margin-bottom: 16px; }
.ctl { display: flex; flex-direction: column; gap: 5px; font-size: 12px; color: var(--cfg-text-muted); }
.ctl__in { display: flex; align-items: center; gap: 5px; }
.ctl__in i { font-style: normal; font-size: 11px; color: var(--cfg-text-faint); }
Expand Down
106 changes: 95 additions & 11 deletions configurator/src/components/ThemeGallery.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,19 @@
savedThemes,
applyTheme,
saveCurrentTheme,
saveImportedTheme,
deleteSavedTheme,
} from '../lib/store.svelte.js';
import { frameworkVersion } from '../lib/model.js';

let saveName = $state('');
let lastApplied = $state(null);
let lastSaved = $state(null);
let saveError = $state('');
let importError = $state('');
let importSuccess = $state('');
/** @type {HTMLInputElement | undefined} */
let fileInput;

/** Number of overrides this theme would set (after sanitisation). */
function size(theme) {
Expand All @@ -33,7 +39,7 @@
function apply(theme) {
const { applied, skipped } = applyTheme(theme);
lastApplied = { id: theme.id, name: theme.name, applied, skipped: skipped.length };
setTimeout(() => (lastApplied = null), 2200);
setTimeout(() => (lastApplied = null), skipped.length ? 4000 : 2200);
}

function save() {
Expand All @@ -56,8 +62,69 @@
function remove(theme) {
deleteSavedTheme(theme.id);
}

function exportTheme(theme) {
const payload = {
name: theme.name,
icon: theme.icon ?? '⭐',
blurb: theme.blurb ?? '',
frameworkVersion: frameworkVersion || null,
overrides: theme.overrides ?? {},
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `slashed-theme-${(theme.name || 'theme').toLowerCase().replace(/\s+/g, '-')}.json`;
a.click();
URL.revokeObjectURL(url);
}

function triggerImport() {
importError = '';
importSuccess = '';
fileInput?.click();
}

function handleImportFile(e) {
const file = e.target?.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onerror = () => { importError = 'Failed to read the file.'; };
reader.onload = (ev) => {
try {
const data = JSON.parse(ev.target?.result ?? '');
if (!data || typeof data !== 'object' || typeof data.overrides !== 'object' || data.overrides === null || Array.isArray(data.overrides)) {
importError = 'Invalid theme file — missing overrides map.';
return;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const total = Object.keys(data.overrides).length;
saveImportedTheme(data);
const versionNote =
data.frameworkVersion && frameworkVersion && data.frameworkVersion !== frameworkVersion
? ` · exported from v${data.frameworkVersion}, current v${frameworkVersion} — apply to see which tokens were skipped.`
: '';
importSuccess = `Imported "${data.name || 'Imported theme'}" — ${total} override${total === 1 ? '' : 's'}.${versionNote}`;
importError = '';
setTimeout(() => (importSuccess = ''), versionNote ? 6000 : 3000);
} catch {
importError = 'Could not parse the file — make sure it is a valid SLASHED theme JSON.';
}
};
reader.readAsText(file);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
e.target.value = '';
}
</script>

<input
bind:this={fileInput}
type="file"
accept=".json,application/json"
style="display:none"
onchange={handleImportFile}
aria-hidden="true"
/>

<section class="themes">
<header class="themes__head">
<div class="themes__title-wrap">
Expand All @@ -70,9 +137,17 @@
</p>
</div>
</div>
<button class="cfg-btn cfg-btn--sm themes__import-btn" onclick={triggerImport} title="Import a theme from a JSON file">Import theme…</button>
</header>

<div class="themes__body">
{#if importSuccess}
<p class="themes__msg themes__msg--ok">{importSuccess}</p>
{/if}
{#if importError}
<p class="themes__msg themes__msg--err">{importError}</p>
{/if}

<section>
<h3 class="themes__group">Built-in presets</h3>
<div class="themes__grid">
Expand All @@ -88,9 +163,14 @@
{/if}
</header>
<p class="theme__blurb">{p.blurb}</p>
<button class="cfg-btn cfg-btn--primary cfg-btn--sm theme__apply" onclick={() => apply(p)}>
{lastApplied?.id === p.id ? `Applied ${lastApplied.applied} ✓` : 'Apply'}
</button>
<div class="theme__actions">
<button class="cfg-btn cfg-btn--primary cfg-btn--sm" onclick={() => apply(p)}>
{lastApplied?.id === p.id ? `Applied ${lastApplied.applied}${lastApplied.skipped ? ` · ${lastApplied.skipped} skipped` : ''} ✓` : 'Apply'}
</button>
{#if size(p) > 0}
<button class="cfg-btn cfg-btn--sm" onclick={() => exportTheme(p)} title="Download this preset as a JSON file">Export</button>
{/if}
</div>
</article>
{/each}
</div>
Expand Down Expand Up @@ -151,9 +231,8 @@
<button class="cfg-btn cfg-btn--primary cfg-btn--sm" onclick={() => apply(s)}>
{lastApplied?.id === s.id ? `Applied ${lastApplied.applied} ✓` : 'Apply'}
</button>
<button class="cfg-btn cfg-btn--sm cfg-btn--danger" onclick={() => remove(s)} title="Delete this saved theme">
Delete
</button>
<button class="cfg-btn cfg-btn--sm" onclick={() => exportTheme(s)} title="Download this theme as a JSON file">Export</button>
<button class="cfg-btn cfg-btn--sm cfg-btn--danger" onclick={() => remove(s)} title="Delete this saved theme">Delete</button>
</div>
</article>
{/each}
Expand All @@ -169,8 +248,13 @@
padding: 18px 20px 14px;
border-bottom: 1px solid var(--cfg-border);
background: var(--cfg-surface);
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.themes__title-wrap { display: flex; align-items: flex-start; gap: 12px; }
.themes__import-btn { flex-shrink: 0; align-self: center; }
.themes__title-wrap { display: flex; align-items: flex-start; gap: 12px; flex: 1; }
.themes__icon {
display: inline-grid;
place-items: center;
Expand Down Expand Up @@ -224,9 +308,8 @@
}
.theme__size--zero { color: var(--cfg-text-faint); background: var(--cfg-surface-2); }
.theme__blurb { margin: 0; color: var(--cfg-text-muted); font-size: 12.5px; line-height: 1.5; flex: 1; }
.theme__apply { align-self: stretch; margin-top: 4px; }
.theme__actions { display: flex; gap: 8px; margin-top: 4px; }
.theme__actions .cfg-btn { flex: 1; }
.theme__actions { display: flex; gap: 8px; margin-top: 4px; flex-wrap: wrap; }
.theme__actions .cfg-btn:first-child { flex: 1; }

.themes__save {
display: flex;
Expand All @@ -240,6 +323,7 @@

.themes__msg { margin: 0 0 12px; font-size: 12.5px; color: var(--cfg-text-muted); }
.themes__msg--err { color: var(--cfg-warn); }
.themes__msg--ok { color: var(--cfg-ok); }
.themes__msg code { color: var(--cfg-text); }

.themes__empty {
Expand Down
8 changes: 4 additions & 4 deletions configurator/src/components/WcagPanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,7 @@
</p>
<div class="usage">
{#each usage as u (u.role)}
<div class="usage__chip" style="background: {cssColor(u.bg)}; color: {cssColor(u.fg)};">
<div class="usage__chip" style="background: {cssColor(u.bg)}; color: var({u.fg}, #fff);">
<span class="usage__aa">Aa</span>
<span class="usage__role">{u.role}</span>
{#if u.ratio !== null}
Expand Down Expand Up @@ -440,8 +440,8 @@

/* Checker */
.checker { display: grid; grid-template-columns: 1fr auto; gap: 16px; align-items: stretch; }
.checker__controls { display: flex; align-items: flex-end; gap: 10px; }
.checker__field { display: flex; flex-direction: column; gap: 5px; flex: 1; min-width: 0; }
.checker__controls { display: flex; flex-direction: column; gap: 6px; }
.checker__field { display: flex; flex-direction: column; gap: 5px; }
.checker__field span { font-size: 12px; color: var(--cfg-text-muted); }
.checker__field select {
width: 100%;
Expand All @@ -453,7 +453,7 @@
font-family: var(--cfg-mono);
font-size: 12px;
}
.checker__swap { font-size: 16px; padding: 6px 9px; align-self: flex-end; }
.checker__swap { font-size: 16px; padding: 4px 9px; align-self: flex-start; }
.checker__result {
min-width: 190px;
border: 1px solid var(--cfg-border-strong);
Expand Down
24 changes: 24 additions & 0 deletions configurator/src/lib/store.svelte.js
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,30 @@ export function saveCurrentTheme(name) {
return { id, persisted };
}

/**
* Save an externally-provided theme object (from a JSON import) as a user
* theme. Does NOT touch the active overrides. Returns { id, persisted }.
*
* @param {{ name: string, icon?: string, blurb?: string, overrides: Record<string,string> }} data
* @returns {{ id: string, persisted: boolean }}
*/
export function saveImportedTheme(data) {
const id = slugify(data.name);
const theme = {
id,
name: String(data.name || '').trim() || 'Imported theme',
icon: data.icon ?? '⭅',
blurb: data.blurb ?? `${Object.keys(data.overrides ?? {}).length} imported tokens`,
overrides: { ...(data.overrides ?? {}) },
};
const next = savedThemes.filter((t) => t.id !== id);
next.push(theme);
savedThemes.length = 0;
for (const t of next) savedThemes.push(t);
const persisted = persistSavedThemes(savedThemes);
return { id, persisted };
}

/** Delete a saved theme by id. */
export function deleteSavedTheme(id) {
const idx = savedThemes.findIndex((t) => t.id === id);
Expand Down
Loading