ui : merge download options and terminal commands into one panel

The quant and draft sidecar buttons become a multiple toggle group
(selecting which files to download, not download triggers), and below
the panel the terminal llama serve command updates live to reflect the
selection, with a download CTA that fires the downloads for the
selected entries (main + optional draft sidecar).

Adds the shadcn-svelte toggle-group component.

Assisted-by: pi
This commit is contained in:
Aleksander Grygier
2026-09-04 20:07:36 +02:00
parent 6be54d7bd6
commit 5ef15ee2ce
12 changed files with 407 additions and 265 deletions
+8 -5
View File
@@ -71,7 +71,7 @@
"svelte-check": "4.6.0",
"svelte-sonner": "1.1.1",
"tailwind-merge": "3.6.0",
"tailwind-variants": "3.2.2",
"tailwind-variants": "^3.3.1",
"tailwindcss": "4.3.0",
"tw-animate-css": "1.4.0",
"typescript": "5.9.3",
@@ -16048,13 +16048,13 @@
}
},
"node_modules/tailwind-variants": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.2.2.tgz",
"integrity": "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==",
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.3.1.tgz",
"integrity": "sha512-4pAvwUtM4HKBiRZftncAbpn6V9Hhwoa5Fl7O2u5zbp7Z5Cvu+/o/6+176WY3WCEES209543quG8zFIcXCsc5Jw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16.x",
"node": ">=16.9.x",
"pnpm": ">=7.x"
},
"peerDependencies": {
@@ -16064,6 +16064,9 @@
"peerDependenciesMeta": {
"tailwind-merge": {
"optional": true
},
"tailwindcss": {
"optional": true
}
}
},
+1 -1
View File
@@ -91,7 +91,7 @@
"svelte-check": "4.6.0",
"svelte-sonner": "1.1.1",
"tailwind-merge": "3.6.0",
"tailwind-variants": "3.2.2",
"tailwind-variants": "^3.3.1",
"tailwindcss": "4.3.0",
"tw-animate-css": "1.4.0",
"typescript": "5.9.3",
@@ -1,13 +1,12 @@
<script lang="ts">
import ModelsDiscoverModelDetailsCommands from './ModelsDiscoverModelDetailsCommands.svelte';
import ModelsDiscoverModelDetailsDownloadOptions from './ModelsDiscoverModelDetailsDownloadOptions.svelte';
import ModelsDiscoverDetailsHeader from './ModelsDiscoverModelDetailsHeader.svelte';
import ModelsDiscoverDetailsReadme from './ModelsDiscoverModelDetailsReadme.svelte';
import { isAuxSidecar, type ModelSidecar } from '$lib/constants';
import ModelsDiscoverModelDetailsHeader from './ModelsDiscoverModelDetailsHeader.svelte';
import ModelsDiscoverModelDetailsReadme from './ModelsDiscoverModelDetailsReadme.svelte';
import { isAuxSidecar } from '$lib/constants';
import { HuggingFaceService } from '$lib/services';
import type { HfModelDetailInfo, HfModelSibling } from '$lib/types/huggingface';
import { detectThinkingSupport, detectToolUseSupport } from '$lib/utils';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { SvelteMap } from 'svelte/reactivity';
interface Props {
/** Full HuggingFace model id, e.g. `ggml-org/gemma-3-4b-it-GGUF`. */
@@ -49,29 +48,8 @@
// Draft sidecars (mtp, dflash, dspark, eagle3) present in the repo, e.g.
// speculative-decoding drafts. mmproj is excluded: it is vision.
let draftSidecars = $derived.by<ModelSidecar[]>(() => {
const set = new SvelteSet<ModelSidecar>();
for (const file of files) {
const sidecar = HuggingFaceService.extractQuantMeta(file.path)?.sidecar;
if (sidecar && !isAuxSidecar(sidecar)) set.add(sidecar);
}
return [...set];
});
type BitDepthRow = { bitDepth: number; files: HfModelSibling[] };
let quants = $derived(
Array.from(
new Set(
files
.map((f) => HuggingFaceService.extractQuantMeta(f.path)?.quant)
.filter((q): q is string => Boolean(q))
.map((q) => q.toUpperCase())
)
)
);
let bitDepthRows = $derived.by<BitDepthRow[]>(() => {
const rows = new SvelteMap<number, HfModelSibling[]>();
@@ -105,7 +83,7 @@
</div>
{:else if details}
<div class="space-y-6 p-6">
<ModelsDiscoverDetailsHeader
<ModelsDiscoverModelDetailsHeader
{baseModels}
{details}
{gguf}
@@ -118,8 +96,6 @@
<ModelsDiscoverModelDetailsDownloadOptions {bitDepthRows} {modelId} />
<ModelsDiscoverModelDetailsCommands {modelId} {quants} sidecars={draftSidecars} />
<ModelsDiscoverDetailsReadme {readme} />
<ModelsDiscoverModelDetailsReadme {readme} />
</div>
{/if}
@@ -1,124 +0,0 @@
<script lang="ts">
import { Check, Copy, Server, SquareTerminal } from '@lucide/svelte';
import { isAuxSidecar, type ModelSidecar } from '$lib/constants';
import { ModelAuxSidecar, ModelDraftSidecar } from '$lib/enums';
import { copyToClipboard } from '$lib/utils';
interface Props {
/** Full HuggingFace repo id, e.g. `ggml-org/gemma-3-4b-it-GGUF`. */
modelId: string;
/** Available quantization tags, e.g. `Q4_K_M`, `Q8_0`. */
quants: string[];
/** Draft sidecars present in the repo (mtp, dflash, dspark, eagle3). */
sidecars?: ModelSidecar[];
}
let { modelId, quants, sidecars = [] }: Props = $props();
let pickedQuant = $state<string | null>(null);
let selectedQuant = $derived(pickedQuant ?? quants[0] ?? null);
let selectedSidecar = $state<ModelSidecar | null>(null);
// llama.cpp --spec-type value for each draft sidecar.
const SPEC_TYPE: Record<ModelSidecar, string> = {
[ModelAuxSidecar.MMPROJ]: '',
[ModelDraftSidecar.DFLASH]: 'draft-dflash',
[ModelDraftSidecar.DSPARK]: 'draft-dspark',
[ModelDraftSidecar.EAGLE3]: 'eagle3',
[ModelDraftSidecar.MTP]: 'draft-mtp'
};
let copiedIndex = $state<number | null>(null);
async function handleCopy(index: number, text: string) {
await copyToClipboard(text);
copiedIndex = index;
setTimeout(() => (copiedIndex = null), 1500);
}
// One box per binary (serve / cli). The command embeds inline selectors for
// the quant and the draft sidecar type.
let boxes = $derived.by(() => {
const quant = selectedQuant ?? quants[0];
if (!quant) return [];
const draft = selectedSidecar && !isAuxSidecar(selectedSidecar) ? selectedSidecar : null;
const specType = draft ? `--spec-type ${SPEC_TYPE[draft]}` : '';
const build = (bin: string) => {
const parts = ['llama', bin, '-hfd', modelId];
if (specType) parts.push(specType);
return parts.join(' ');
};
return [
{ command: build('serve'), icon: Server, title: 'Serve' },
{ command: build('cli'), icon: SquareTerminal, title: 'CLI' }
];
});
</script>
<div class="space-y-3">
<div class="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
<span>Quant</span>
<select
bind:value={selectedQuant}
class="rounded border bg-background px-1.5 py-0.5 font-mono text-xs"
>
{#each quants as quant (quant)}
<option value={quant}>{quant}</option>
{/each}
</select>
{#if sidecars.length}
<span>Draft</span>
<select
bind:value={selectedSidecar}
class="rounded border bg-background px-1.5 py-0.5 font-mono text-xs"
>
<option value={null}>none</option>
{#each sidecars as sidecar (sidecar)}
<option value={sidecar}>{sidecar}</option>
{/each}
</select>
{/if}
</div>
{#each boxes as box, i (box.title)}
<div
class="overflow-hidden rounded-md"
style="background: var(--code-background); border: 1px solid color-mix(in oklch, var(--border) 30%, transparent);"
>
<div
class="flex items-center gap-2 px-3 py-2"
style="border-bottom: 1px solid color-mix(in oklch, var(--border) 30%, transparent);"
>
<box.icon class="h-3.5 w-3.5 text-muted-foreground/60" />
<span class="text-xs font-medium text-foreground/80">{box.title}</span>
</div>
<div class="flex items-center justify-between gap-2 p-2">
<span class="truncate font-mono text-xs text-foreground/90">{box.command}</span>
<button
aria-label="Copy command"
class="shrink-0 text-muted-foreground/60 transition-colors hover:text-foreground"
onclick={() => handleCopy(i, box.command)}
type="button"
>
{#if copiedIndex === i}
<Check class="h-3.5 w-3.5 text-green-500" />
{:else}
<Copy class="h-3.5 w-3.5" />
{/if}
</button>
</div>
</div>
{/each}
</div>
@@ -1,14 +1,14 @@
<script lang="ts">
import DialogModelDownload from './DialogModelDownload.svelte';
import DownloadProgressBar from './DownloadProgressBar.svelte';
import { Download } from '@lucide/svelte';
import { Check, Copy, Download } from '@lucide/svelte';
import { ToggleGroup, ToggleGroupItem } from '$lib/components/ui/toggle-group';
import * as Tooltip from '$lib/components/ui/tooltip';
import { isAuxSidecar, type ModelSidecar } from '$lib/constants';
import { ModelAuxSidecar, ModelDraftSidecar } from '$lib/enums';
import { HuggingFaceService, ModelsService } from '$lib/services';
import { modelsStore } from '$lib/stores';
import type { ModelDownloadProgress } from '$lib/types';
import type { HfModelSibling } from '$lib/types/huggingface';
import { estimateModelMemoryBytes } from '$lib/utils';
import { copyToClipboard, estimateModelMemoryBytes } from '$lib/utils';
/** Download state of a single repo entry, injected by the integration layer. */
export interface DownloadEntryState {
@@ -20,9 +20,8 @@
type BitDepthRow = { bitDepth: number; files: HfModelSibling[] };
interface PendingDownload {
interface SelectedDownload {
filePath: string;
sizeBytes: number | null;
quant: string | null;
sidecar: ModelSidecar | null;
}
@@ -42,7 +41,39 @@
let { bitDepthRows, getDownloadState, modelId }: Props = $props();
let pendingDownload: PendingDownload | null = $state(null);
let selectedPaths = $state<string[]>([]);
/** Kind of a file path: the main weights, a draft sidecar, or an aux sidecar (mmproj). */
function classify(path: string): 'main' | 'draft' | 'aux' {
const sidecar = HuggingFaceService.extractQuantMeta(path)?.sidecar;
if (!sidecar) return 'main';
return isAuxSidecar(sidecar) ? 'aux' : 'draft';
}
/**
* Constrained selection: at most one base model and one draft sidecar.
* Aux sidecars (mmproj) are unconstrained.
*/
function handleSelection(next: string[]) {
const added = next.find((p) => !selectedPaths.includes(p));
if (!added) {
selectedPaths = next;
return;
}
const kind = classify(added);
const base = kind === 'aux' ? selectedPaths : selectedPaths.filter((p) => classify(p) !== kind);
selectedPaths = [...base, added];
}
let copied = $state(false);
/** All repo files in one lookup by path. */
let fileByPath = $derived(new Map(bitDepthRows.flatMap((r) => r.files).map((f) => [f.path, f])));
function stateFor(repoWithTag: string, filePath: string, isSidecar: boolean): DownloadEntryState {
if (getDownloadState) return getDownloadState(repoWithTag, filePath, isSidecar);
@@ -57,6 +88,47 @@
};
}
/** Selection ordered main-quant-first, so the command reads naturally. */
let selected: SelectedDownload[] = $derived.by(() => {
const mains: SelectedDownload[] = [];
const sidecars: SelectedDownload[] = [];
for (const path of selectedPaths) {
const file = fileByPath.get(path);
if (!file) continue;
const meta = HuggingFaceService.extractQuantMeta(file.path);
const entry = {
filePath: file.path,
quant: meta?.quant ?? null,
sidecar: meta?.sidecar ?? null
};
if (entry.sidecar && !isAuxSidecar(entry.sidecar)) sidecars.push(entry);
else mains.push(entry);
}
return [...mains, ...sidecars];
});
/** First selected main quant, drives the `-hf <repo>:<quant>` tag. */
let primaryQuant = $derived(selected.find((s) => !s.sidecar)?.quant ?? null);
/** First selected draft sidecar, drives the `--spec-type` flag. */
let draft = $derived(
selected.find((s) => s.sidecar && !isAuxSidecar(s.sidecar))?.sidecar ?? null
);
// llama.cpp --spec-type value for each draft sidecar.
const SPEC_TYPE: Record<ModelSidecar, string> = {
[ModelAuxSidecar.MMPROJ]: '',
[ModelDraftSidecar.DFLASH]: 'draft-dflash',
[ModelDraftSidecar.DSPARK]: 'draft-dspark',
[ModelDraftSidecar.EAGLE3]: 'eagle3',
[ModelDraftSidecar.MTP]: 'draft-mtp'
};
function buttonClass(parts: { isDownloaded: boolean; isFailed: boolean }): string {
const classes = [
'relative inline-flex items-center gap-1 overflow-hidden rounded-md border bg-muted px-2 py-1 text-left font-mono text-xs transition-colors'
@@ -70,20 +142,62 @@
return classes.join(' ');
}
async function copyCommand() {
await copyToClipboard(serveCommand);
copied = true;
setTimeout(() => (copied = false), 1500);
}
/** Fire downloads for every selected entry. */
function downloadSelected() {
for (const sel of selected) {
const tag = ModelsService.buildDownloadTag(modelId, sel.quant, sel.sidecar);
if (modelsStore.status.hasFailedDownload(tag)) {
void modelsStore.status
.cancelDownload(tag)
.then(() => modelsStore.status.downloadModel(tag, sel.filePath));
} else {
void modelsStore.status.downloadModel(tag, sel.filePath);
}
}
selectedPaths = [];
}
/** The llama serve command for the current selection. */
let serveCommand = $derived.by(() => {
const quantTag = primaryQuant ? `${modelId}:${primaryQuant}` : modelId;
const parts = ['llama', 'serve', '-hf', quantTag];
if (draft) parts.push('-hfd', modelId, '--spec-type', SPEC_TYPE[draft]);
return parts.join(' ');
});
</script>
{#if bitDepthRows.length}
<section class="rounded-xl border">
<div class="flex flex-wrap items-center justify-between gap-2 px-4 pt-3 pb-1">
<section class="space-y-3 rounded-xl border p-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="flex items-center gap-1.5 text-sm font-medium text-muted-foreground">
<Download class="h-4 w-4" />
Downloadable options
</h2>
{#if selectedPaths.length}
<span class="text-xs text-muted-foreground">{selected.length} selected</span>
{/if}
</div>
<div class="divide-y px-4 pb-1">
<ToggleGroup
class="flex flex-col"
onValueChange={handleSelection}
type="multiple"
value={selectedPaths}
>
{#each bitDepthRows as row (row.bitDepth)}
<div class="grid grid-cols-[5rem_1fr] items-start gap-3 py-3">
<div class="grid grid-cols-[5rem_1fr] items-start gap-3 py-2">
<div class="pt-1 text-sm tabular-nums text-muted-foreground">
{#if row.bitDepth === 99}
Other
@@ -113,58 +227,55 @@
: isDownloaded
? `Already downloaded: ${file.path}`
: isFailed
? `Last attempt failed: ${file.path}. Click to retry.`
? `Last attempt failed: ${file.path}`
: `Download ${file.path} (requires ~${memoryGb} GB of memory)`}
<Tooltip.Root>
<Tooltip.Trigger
class={buttonClass({ isDownloaded, isFailed })}
onclick={() => {
pendingDownload = {
filePath: file.path,
quant: meta?.quant ?? null,
sidecar: meta?.sidecar ?? null,
sizeBytes: file.size ?? null
};
}}
type="button"
>
{#if isFailed && !isDownloading && !isDownloaded}
<span
class="rounded bg-destructive px-1 py-0.5 text-[10px] font-semibold tracking-wide text-destructive-foreground uppercase"
>
Failed
</span>
{/if}
{#if meta?.sidecar && !isAuxSidecar(meta.sidecar)}
<span
class="rounded bg-primary px-1 py-0.5 text-[10px] font-semibold tracking-wide text-primary-foreground uppercase"
>
{meta.sidecar}
</span>
{/if}
<span class="font-medium {isDownloaded ? '' : 'text-muted-foreground/80'}"
>{label}</span
<Tooltip.Trigger>
<ToggleGroupItem
aria-label={tooltipText}
class="relative inline-flex h-auto items-center gap-1 overflow-hidden rounded-md border bg-muted px-2 py-1 text-left font-mono text-xs transition-colors data-[state=on]:border-primary data-[state=on]:bg-primary/10 {buttonClass(
{ isDownloaded, isFailed }
)}"
value={file.path}
>
<span class="-my-1 w-px self-stretch bg-border"></span>
<span class={isDownloaded ? '' : 'text-muted-foreground/80'}>
{#if isDownloading && progress && progress.totalBytes > 0}
{Math.round((progress.downloadedBytes / progress.totalBytes) * 100)}%
{:else}
{HuggingFaceService.formatFileSize(file.size ?? 0)}
{#if isFailed && !isDownloading && !isDownloaded}
<span
class="rounded bg-destructive px-1 py-0.5 text-[10px] font-semibold tracking-wide text-destructive-foreground uppercase"
>
Failed
</span>
{/if}
</span>
{#if isDownloading && progress}
<DownloadProgressBar
downloadedBytes={progress.downloadedBytes}
overlay
totalBytes={progress.totalBytes}
/>
{/if}
{#if meta?.sidecar && !isAuxSidecar(meta.sidecar)}
<span
class="rounded bg-primary px-1 py-0.5 text-[10px] font-semibold tracking-wide text-primary-foreground uppercase"
>
{meta.sidecar}
</span>
{/if}
<span class="font-medium {isDownloaded ? '' : 'text-muted-foreground/80'}"
>{label}</span
>
<span class="-my-1 w-px self-stretch bg-border"></span>
<span class={isDownloaded ? '' : 'text-muted-foreground/80'}>
{#if isDownloading && progress && progress.totalBytes > 0}
{Math.round((progress.downloadedBytes / progress.totalBytes) * 100)}%
{:else}
{HuggingFaceService.formatFileSize(file.size ?? 0)}
{/if}
</span>
{#if isDownloading && progress}
<DownloadProgressBar
downloadedBytes={progress.downloadedBytes}
overlay
totalBytes={progress.totalBytes}
/>
{/if}
</ToggleGroupItem>
</Tooltip.Trigger>
<Tooltip.Content>
@@ -175,25 +286,44 @@
</div>
</div>
{/each}
</ToggleGroup>
<!-- Terminal command + download CTA for the current selection -->
<div class="space-y-2">
<div
class="flex items-center justify-between gap-2 rounded-md px-3 py-2"
style="background: var(--code-background); border: 1px solid color-mix(in oklch, var(--border) 30%, transparent);"
>
<span class="truncate font-mono text-xs text-foreground/90">{serveCommand}</span>
<button
aria-label="Copy command"
class="shrink-0 text-muted-foreground/60 transition-colors hover:text-foreground"
onclick={copyCommand}
type="button"
>
{#if copied}
<Check class="h-3.5 w-3.5 text-green-500" />
{:else}
<Copy class="h-3.5 w-3.5" />
{/if}
</button>
</div>
<button
class="inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
disabled={selected.length === 0}
onclick={downloadSelected}
type="button"
>
<Download class="h-4 w-4" />
{#if primaryQuant && draft}
Download {primaryQuant} + {draft}
{:else}
Download
{/if}
</button>
</div>
</section>
{/if}
{#if pendingDownload}
<DialogModelDownload
bind:open={
() => pendingDownload !== null,
(v) => {
if (!v) pendingDownload = null;
}
}
filePath={pendingDownload.filePath}
formattedSize={pendingDownload.sizeBytes != null
? HuggingFaceService.formatFileSize(pendingDownload.sizeBytes)
: undefined}
onClose={() => (pendingDownload = null)}
quant={pendingDownload.quant}
repoId={modelId}
sidecar={pendingDownload.sidecar}
/>
{/if}
@@ -84,13 +84,6 @@ export { default as ModelsDiscoverChatTemplateDialog } from './ModelsDiscoverCha
*/
export { default as ModelsDiscoverModelDetailsReadme } from './ModelsDiscoverModelDetailsReadme.svelte';
/**
* **TerminalCommands** - Terminal command block
*
* Shows the `llama serve` / `llama cli` commands for a model with copy buttons.
*/
export { default as ModelsDiscoverModelDetailsCommands } from './ModelsDiscoverModelDetailsCommands.svelte';
/**
* **DialogModelDownload** - Download confirmation / progress dialog
*
@@ -0,0 +1,10 @@
import Root from './toggle-group.svelte';
import Item from './toggle-group-item.svelte';
export {
Root,
Item,
//
Root as ToggleGroup,
Item as ToggleGroupItem
};
@@ -0,0 +1,35 @@
<script lang="ts">
import { getToggleGroupCtx } from './toggle-group.svelte';
import { type ToggleVariants, toggleVariants } from '$lib/components/ui/toggle/index.js';
import { cn } from '$lib/components/ui/utils.js';
import { ToggleGroup as ToggleGroupPrimitive } from 'bits-ui';
let {
class: className,
ref = $bindable(null),
size,
value = $bindable(),
variant,
...restProps
}: ToggleGroupPrimitive.ItemProps & ToggleVariants = $props();
const ctx = getToggleGroupCtx();
</script>
<ToggleGroupPrimitive.Item
bind:ref
class={cn(
'group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg shrink-0 focus:z-10 focus-visible:z-10 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t',
toggleVariants({
size: ctx.size || size,
variant: ctx.variant || variant
}),
className
)}
data-size={ctx.size || size}
data-slot="toggle-group-item"
data-spacing={ctx.spacing}
data-variant={ctx.variant || variant}
{value}
{...restProps}
/>
@@ -0,0 +1,75 @@
<script lang="ts" module>
import { toggleVariants } from '$lib/components/ui/toggle/index.js';
import { getContext, setContext } from 'svelte';
import type { VariantProps } from 'tailwind-variants';
type ToggleVariants = VariantProps<typeof toggleVariants>;
interface ToggleGroupContext extends ToggleVariants {
spacing?: number;
orientation?: 'horizontal' | 'vertical';
}
export function setToggleGroupCtx(props: ToggleGroupContext) {
setContext('toggleGroup', props);
}
export function getToggleGroupCtx() {
return getContext<Required<ToggleGroupContext>>('toggleGroup');
}
</script>
<script lang="ts">
import { cn } from '$lib/components/ui/utils.js';
import { ToggleGroup as ToggleGroupPrimitive } from 'bits-ui';
let {
class: className,
orientation = 'horizontal',
ref = $bindable(null),
size = 'default',
spacing = 0,
value = $bindable(),
variant = 'default',
...restProps
}: ToggleGroupPrimitive.RootProps &
ToggleVariants & {
spacing?: number;
orientation?: 'horizontal' | 'vertical';
} = $props();
setToggleGroupCtx({
get orientation() {
return orientation;
},
get size() {
return size;
},
get spacing() {
return spacing;
},
get variant() {
return variant;
}
});
</script>
<!--
Discriminated Unions + Destructing (required for bindable) do not
get along, so we shut typescript up by casting `value` to `never`.
-->
<ToggleGroupPrimitive.Root
bind:ref
bind:value={value as never}
class={cn(
'rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] data-vertical:flex-col data-vertical:items-stretch',
className
)}
data-size={size}
data-slot="toggle-group"
data-spacing={spacing}
data-variant={variant}
{orientation}
style={`--gap: ${spacing}`}
{...restProps}
/>
@@ -0,0 +1,13 @@
import Root from './toggle.svelte';
export {
toggleVariants,
type ToggleSize,
type ToggleVariant,
type ToggleVariants
} from './toggle.svelte';
export {
Root,
//
Root as Toggle
};
@@ -0,0 +1,52 @@
<script lang="ts" module>
import { tv, type VariantProps } from 'tailwind-variants';
export const toggleVariants = tv({
base: "gap-1 rounded-lg text-sm font-medium transition-all hover:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 group/toggle inline-flex items-center justify-center whitespace-nowrap outline-none hover:bg-muted focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
defaultVariants: {
size: 'default',
variant: 'default'
},
variants: {
size: {
default:
'h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
lg: 'h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5"
},
variant: {
default: 'bg-transparent',
outline: 'border border-input bg-transparent hover:bg-muted'
}
}
});
export type ToggleVariant = VariantProps<typeof toggleVariants>['variant'];
export type ToggleSize = VariantProps<typeof toggleVariants>['size'];
export type ToggleVariants = VariantProps<typeof toggleVariants>;
</script>
<script lang="ts">
import { cn } from '$lib/components/ui/utils.js';
import { Toggle as TogglePrimitive } from 'bits-ui';
let {
class: className,
pressed = $bindable(false),
ref = $bindable(null),
size = 'default',
variant = 'default',
...restProps
}: TogglePrimitive.RootProps & {
variant?: ToggleVariant;
size?: ToggleSize;
} = $props();
</script>
<TogglePrimitive.Root
bind:pressed
bind:ref
class={cn(toggleVariants({ size, variant }), className)}
data-slot="toggle"
{...restProps}
/>
@@ -3,11 +3,9 @@
import { defineMeta } from '@storybook/addon-svelte-csf';
import ModelsDiscoverChatTemplateDialog from '$lib/components/app/models/discover/ModelsDiscoverChatTemplateDialog.svelte';
import ModelsDiscoverModelDetails from '$lib/components/app/models/discover/ModelsDiscoverModelDetails.svelte';
import ModelsDiscoverModelDetailsCommands from '$lib/components/app/models/discover/ModelsDiscoverModelDetailsCommands.svelte';
import ModelsDiscoverModelDetailsDownloadOptions from '$lib/components/app/models/discover/ModelsDiscoverModelDetailsDownloadOptions.svelte';
import ModelsDiscoverModelDetailsHeader from '$lib/components/app/models/discover/ModelsDiscoverModelDetailsHeader.svelte';
import ModelsDiscoverModelDetailsReadme from '$lib/components/app/models/discover/ModelsDiscoverModelDetailsReadme.svelte';
import { ModelDraftSidecar } from '$lib/enums';
import type { HfModelSibling } from '$lib/types';
const { Story } = defineMeta({
@@ -72,25 +70,6 @@
</div>
</Story>
<Story name="Terminal commands (no sidecars)">
<div class="w-200 p-4">
<ModelsDiscoverModelDetailsCommands
modelId="ggml-org/Qwen3.8-27B-GGUF"
quants={['Q4_K_M', 'Q8_0']}
/>
</div>
</Story>
<Story name="Terminal commands (MTP sidecar)">
<div class="w-200 p-4">
<ModelsDiscoverModelDetailsCommands
modelId="ggml-org/gemma-4-12b-it-GGUF"
quants={['Q4_K_M', 'Q8_0']}
sidecars={[ModelDraftSidecar.MTP]}
/>
</div>
</Story>
<Story name="Readme">
<div class="w-160 p-4">
<ModelsDiscoverModelDetailsReadme {readme} />