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
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
import { applyRowCap } from '../../presetBrowser/presetBrowserWorkbenchLayout';
import { AXIS_PB_QUICK_TAGS } from '../../presetBrowser/presetBrowserWorkbenchQuery';
import { axisPbRowAnatomy } from '../../presetBrowser/presetBrowserWorkbenchRowChips';
import { detailStatusItems } from '../../presetBrowser/presetBrowserWorkbenchDetailStatus';
import { tick } from 'svelte';
import {
specsBySlug,
Expand Down Expand Up @@ -843,9 +844,17 @@
</div>

<div class="detail-status">
<span class:on={selectedDetail?.gridLoaded}>Grid</span>
<span class:on={selectedDetail?.paramsLoaded}>Params</span>
<span class:on={(selectedDetail?.versions.length ?? 0) > 0}>Versions</span>
{#each detailStatusItems(selectedDetail) as item}
<span
class:on={item.loaded}
data-status={item.key}
data-loaded={item.loaded}
title={item.title}
aria-label={item.title}
>
<i class="dot"></i>{item.label}
</span>
{/each}
</div>

<dl>
Expand Down Expand Up @@ -880,38 +889,30 @@
{#if data.selectedEntry.converted}
<!-- A saved conversion re-opens in the converter (not a device load). True .syx export is wired in
the separate codec-authoring task. -->
<button type="button" class="load-action" onclick={() => openConverter(data.selectedEntry!.id)}>
<button type="button" class="load-action" data-action="open-converter" onclick={() => openConverter(data.selectedEntry!.id)}>
Open in converter
</button>
<button type="button" class="load-action secondary danger" onclick={() => deleteConverted(data.selectedEntry!.id)}>
<button type="button" class="load-action secondary danger" data-action="delete" onclick={() => deleteConverted(data.selectedEntry!.id)}>
Delete
</button>
{:else}
<button type="button" class="load-action" onclick={() => axisPresetBrowserWorkbenchRuntime.loadEntry(data.selectedEntry!.id)}>
<button type="button" class="load-action" data-action="load" onclick={() => axisPresetBrowserWorkbenchRuntime.loadEntry(data.selectedEntry!.id)}>
{runtimeSnapshot.loadingEntryId === data.selectedEntry.id ? 'Loading...' : 'Load preset'}
</button>
{#if data.selectedEntry.sourceId === 'device' && data.selectedEntry.number != null}
<button type="button" class="load-action audition" onclick={() => auditionEntry(data.selectedEntry!)}>
<button type="button" class="load-action audition" data-action="audition" onclick={() => auditionEntry(data.selectedEntry!)}>
{runtimeSnapshot.auditioningEntryId === data.selectedEntry.id ? 'Auditioning...' : 'Audition'}
</button>
{/if}
<button type="button" class="load-action secondary" onclick={() => axisPresetBrowserWorkbenchRuntime.loadDetail(data.selectedEntry!.id)}>
<button type="button" class="load-action secondary" data-action="refresh" onclick={() => axisPresetBrowserWorkbenchRuntime.loadDetail(data.selectedEntry!.id)}>
{runtimeSnapshot.hydratingEntryId === data.selectedEntry.id ? 'Refreshing...' : 'Refresh detail'}
</button>
<button type="button" class="load-action secondary" title="Port this preset to another Fractal device — best-effort, with a full diff report" onclick={() => crossConvert(data.selectedEntry!.id)}>
<button type="button" class="load-action secondary" data-action="convert" title="Port this preset to another Fractal device — best-effort, with a full diff report" onclick={() => crossConvert(data.selectedEntry!.id)}>
⇄ Convert…
</button>
{/if}
</div>

{#if selectedDetail}
<div class="runtime-detail">
<span>{selectedDetail.paramsLoaded ? 'Params loaded' : 'Summary params only'}</span>
<span>{selectedDetail.gridLoaded ? 'Grid preview ready' : 'No grid preview'}</span>
<span>{selectedDetail.versions.length} version{selectedDetail.versions.length === 1 ? '' : 's'}</span>
</div>
{/if}

<!-- V13f BLOCK PARAMETERS (§"detail" step 4): every param of every non-IO block with its value.
Drag or double-click a block header / param cell to add it to the FILTERS row. Cells matched
by an active block-param condition are highlighted. Reaches the same decoded blocks as the
Expand Down Expand Up @@ -2091,26 +2092,32 @@
color: var(--textdim);
font-size: 12px;
}
/* Passive status readout, not a control — deliberately not styled like the segmented
tab/gate controls elsewhere in this app. Dots inherit currentColor from the pending/on
state instead of the accent-bordered pill look, so it doesn't read as an active tab strip. */
.detail-status {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 7px;
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.detail-status span {
height: 28px;
display: grid;
place-items: center;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg2);
color: var(--textdim);
display: flex;
align-items: center;
gap: 6px;
color: var(--textfaint);
font: 800 10px/1 var(--font-mono);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.detail-status span.on {
border-color: color-mix(in srgb, var(--accent) 48%, var(--border));
color: var(--accent);
color: var(--ok);
}
.detail-status .dot {
width: 7px;
height: 7px;
flex: none;
border-radius: 50%;
background: currentColor;
}
dl {
margin: 0;
Expand Down Expand Up @@ -2154,13 +2161,23 @@
}
.detail-actions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(104px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(124px, 1fr));
gap: 7px;
}
.load-action {
height: 34px;
text-align: center;
text-transform: none;
font: 800 10px/1 var(--font-mono);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.load-action:hover {
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
background: color-mix(in srgb, var(--accent) 4%, var(--bg2));
}
.load-action:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.load-action.audition {
color: var(--accent);
Expand All @@ -2172,12 +2189,6 @@
color: var(--danger, #d6543f);
border-color: color-mix(in srgb, var(--danger, #d6543f) 40%, var(--border));
}
.runtime-detail {
display: grid;
gap: 5px;
color: var(--textdim);
font-size: 11px;
}
.runtime-error {
margin: 0;
color: var(--danger, #d6543f);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Pulled out of AxisPresetBrowserPartPanel.svelte's template so it is unit-testable: Vitest runs
// in the node environment with no DOM, and .svelte files are never unit-mounted here.
import type { AxisPresetBrowserDetailState } from './presetBrowserWorkbenchRuntime';

export interface AxisPresetBrowserDetailStatusItem {
key: 'grid' | 'params' | 'versions';
label: string;
loaded: boolean;
title: string;
}

export function detailStatusItems(
detail: AxisPresetBrowserDetailState | null | undefined
): AxisPresetBrowserDetailStatusItem[] {
const gridLoaded = detail?.gridLoaded ?? false;
const paramsLoaded = detail?.paramsLoaded ?? false;
const versionCount = detail?.versions.length ?? 0;

return [
{
key: 'grid',
label: 'GRID',
loaded: gridLoaded,
title: gridLoaded ? 'Grid preview ready' : 'No grid preview'
},
{
key: 'params',
label: 'PARAMS',
loaded: paramsLoaded,
title: paramsLoaded ? 'Params loaded' : 'Summary params only'
},
{
key: 'versions',
label: versionCount === 0 ? 'VERSIONS' : `VERSIONS ${versionCount}`,
loaded: versionCount > 0,
title:
versionCount === 0
? 'No versions'
: versionCount === 1
? '1 version'
: `${versionCount} versions`
}
];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest';
import { detailStatusItems } from '../presetBrowser/presetBrowserWorkbenchDetailStatus';
import type { AxisPresetBrowserDetailState } from '../presetBrowser/presetBrowserWorkbenchRuntime';

function detail(overrides: Partial<AxisPresetBrowserDetailState> = {}): AxisPresetBrowserDetailState {
return {
entryId: 'dev:10',
paramsLoaded: false,
gridLoaded: false,
versionsLoaded: false,
blockCount: 0,
grid: null,
versions: [],
...overrides
};
}

describe('detailStatusItems', () => {
it('returns all three items unloaded, with no count in the label, for null detail', () => {
const items = detailStatusItems(null);
expect(items.map((item) => item.key)).toEqual(['grid', 'params', 'versions']);
expect(items.every((item) => item.loaded === false)).toBe(true);
expect(items.find((item) => item.key === 'versions')?.label).toBe('VERSIONS');
});

it('returns all three items unloaded, with no count in the label, for undefined detail', () => {
const items = detailStatusItems(undefined);
expect(items.map((item) => item.key)).toEqual(['grid', 'params', 'versions']);
expect(items.every((item) => item.loaded === false)).toBe(true);
expect(items.find((item) => item.key === 'versions')?.label).toBe('VERSIONS');
});

it('omits the count when there are zero versions', () => {
const items = detailStatusItems(detail({ versions: [] }));
const versions = items.find((item) => item.key === 'versions')!;
expect(versions.label).toBe('VERSIONS');
expect(versions.loaded).toBe(false);
expect(versions.title).toBe('No versions');
});

it('uses a singular title at exactly one version', () => {
const items = detailStatusItems(detail({ versions: [{ id: 'v1' }] }));
const versions = items.find((item) => item.key === 'versions')!;
expect(versions.label).toBe('VERSIONS 1');
expect(versions.loaded).toBe(true);
expect(versions.title).toBe('1 version');
});

it('uses a plural title and count label at three versions', () => {
const items = detailStatusItems(
detail({ versions: [{ id: 'v1' }, { id: 'v2' }, { id: 'v3' }] })
);
const versions = items.find((item) => item.key === 'versions')!;
expect(versions.label).toBe('VERSIONS 3');
expect(versions.loaded).toBe(true);
expect(versions.title).toBe('3 versions');
});

it('derives the version count from versions.length, not versionsLoaded', () => {
const items = detailStatusItems(
detail({ versionsLoaded: false, versions: [{ id: 'v1' }] })
);
const versions = items.find((item) => item.key === 'versions')!;
expect(versions.loaded).toBe(true);
expect(versions.label).toBe('VERSIONS 1');
});

it('reflects grid loaded state in both directions', () => {
const loaded = detailStatusItems(detail({ gridLoaded: true }));
const unloaded = detailStatusItems(detail({ gridLoaded: false }));
expect(loaded.find((item) => item.key === 'grid')?.title).toBe('Grid preview ready');
expect(loaded.find((item) => item.key === 'grid')?.loaded).toBe(true);
expect(unloaded.find((item) => item.key === 'grid')?.title).toBe('No grid preview');
expect(unloaded.find((item) => item.key === 'grid')?.loaded).toBe(false);
});

it('reflects params loaded state in both directions', () => {
const loaded = detailStatusItems(detail({ paramsLoaded: true }));
const unloaded = detailStatusItems(detail({ paramsLoaded: false }));
expect(loaded.find((item) => item.key === 'params')?.title).toBe('Params loaded');
expect(loaded.find((item) => item.key === 'params')?.loaded).toBe(true);
expect(unloaded.find((item) => item.key === 'params')?.title).toBe('Summary params only');
expect(unloaded.find((item) => item.key === 'params')?.loaded).toBe(false);
});

it('keeps a stable key order across calls', () => {
expect(detailStatusItems(detail()).map((item) => item.key)).toEqual([
'grid',
'params',
'versions'
]);
});
});