Add a viewer-first mobile shell that swaps in under 768px without restyling desktop Bench.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-09-19 08:19:36 -05:00
co-authored by Cursor
parent dccfafa08b
commit d733e80881
5 changed files with 730 additions and 1 deletions
+543
View File
@@ -0,0 +1,543 @@
<template>
<div class="m-shell" data-mobile="1">
<header class="m-header">
<span class="m-brand">{{ ctx.instanceName }}</span>
<label class="m-crumb">
<select v-model="ctx.form.folderId" aria-label="Folder">
<option v-for="f in ctx.library.folders" :key="f.id" :value="f.id">{{ f.name }}</option>
</select>
</label>
<span class="m-grow" />
<span class="m-gpu" :data-tone="ctx.gpu.tone" :title="ctx.gpu.detail">● {{ ctx.gpuChipLabel }}</span>
<button type="button" class="m-chip" @click="toggleSheet('queue')">Queue {{ ctx.queueCount }}</button>
<details class="m-more">
<summary aria-label="More">⋯</summary>
<div class="m-more-menu">
<NuxtLink to="/library" @click="closeMenus">Library</NuxtLink>
<NuxtLink to="/queue" @click="closeMenus">Queue</NuxtLink>
<NuxtLink to="/settings" @click="closeMenus">Settings</NuxtLink>
</div>
</details>
</header>
<div class="m-chips" role="tablist" aria-label="Tasks">
<button
v-for="task in ctx.tasks"
:key="task"
type="button"
class="m-chip capitalize"
:class="{ on: ctx.form.mode===task }"
:aria-pressed="ctx.form.mode===task"
@click="ctx.chooseTask(task)"
>{{ task }}</button>
</div>
<div v-if="ctx.form.mode!=='music'" class="m-chips" role="group" aria-label="Model">
<button
v-for="opt in ctx.engineOptions"
:key="opt.value"
type="button"
class="m-chip"
:class="{ on: ctx.form.engine===opt.value }"
:disabled="opt.disabled"
:title="opt.hint || undefined"
:aria-pressed="ctx.form.engine===opt.value"
@click="!opt.disabled && (ctx.form.engine=opt.value)"
>{{ opt.label }}</button>
</div>
<div v-else class="m-chips"><span class="m-chip on">YuE2</span></div>
<div class="m-stage">
<button
v-if="ctx.selected && ctx.xaigen"
type="button"
class="m-eye"
:aria-label="ctx.isHidden(ctx.selected) ? 'Show preview' : 'Hide preview'"
@click.stop="ctx.toggleReveal(ctx.selected.id)"
>
<img :src="ctx.isHidden(ctx.selected) ? '/icons/display/eye-off.svg' : '/icons/display/eye.svg'" alt="" width="16" height="16">
</button>
<template v-if="ctx.selected && ctx.isHidden(ctx.selected)">
<p class="m-muted">{{ ctx.selected.kind==='track' ? 'Track loaded · preview hidden' : 'Preview hidden' }}</p>
</template>
<template v-else-if="ctx.selected?.kind==='track'">
<div class="m-audio">
<AudioPlayer :src="`/api/library/tracks/${ctx.selected.id}/audio`" :download-name="`${ctx.selected.name || 'track'}.${ctx.selected.ext || 'wav'}`" :label="ctx.selected.name || 'Track'" />
</div>
</template>
<video
v-else-if="ctx.selected?.kind==='clip'"
:key="ctx.selected.id"
:src="`/api/library/clips/${ctx.selected.id}/video`"
controls
playsinline
/>
<img v-else-if="ctx.selected" :src="ctx.stillUrl(ctx.selected.id)" alt="Selected still">
<p v-else class="m-muted">Select a still or clip</p>
</div>
<div class="m-strip" aria-label="Family outputs">
<div
v-for="o in ctx.family"
:key="o.id"
class="m-tile"
:class="{ on: ctx.selected?.id===o.id }"
>
<button type="button" class="m-tile-pick" @click="ctx.selectOutput(o)">
<span v-if="ctx.isHidden(o)" class="m-tile-hid">Hidden</span>
<span v-else-if="o.kind==='track'" class="m-tile-hid">Audio</span>
<img v-else :src="o.kind==='clip' ? `/api/library/clips/${o.id}/thumb` : ctx.stillUrl(o.id)" alt="">
</button>
<button
v-if="ctx.xaigen"
type="button"
class="m-tile-eye"
:aria-label="ctx.isHidden(o) ? 'Show tile' : 'Hide tile'"
@click.stop="ctx.toggleReveal(o.id)"
>
<img :src="ctx.isHidden(o) ? '/icons/display/eye-off.svg' : '/icons/display/eye.svg'" alt="" width="12" height="12">
</button>
<button
v-if="ctx.selected?.id===o.id"
type="button"
class="m-tile-trash"
aria-label="Delete selected"
@click.stop="ctx.deleteOutput"
>🗑</button>
</div>
</div>
<div class="m-dock">
<button type="button" :class="{ on: sheet==='slots' }" @click="toggleSheet('slots')">Slots</button>
<button type="button" :class="{ on: sheet==='prompt' }" @click="toggleSheet('prompt')">Prompt</button>
<button type="button" :class="{ on: sheet==='look' }" @click="toggleSheet('look')">Look</button>
<button type="button" :class="{ on: sheet==='gen' }" @click="toggleSheet('gen')">Gen</button>
<button type="button" class="m-cta" :disabled="ctx.submitting" @click="ctx.queue">{{ ctx.submitting ? '…' : 'Generate' }}</button>
</div>
<!-- Sheets -->
<div v-if="sheet" class="m-sheet-backdrop" @click="sheet=''" />
<aside v-if="sheet==='slots'" class="m-sheet" role="dialog" aria-label="Slots">
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
<div class="m-sheet-head"><strong>Slots</strong><button type="button" @click="sheet=''">Done</button></div>
<div class="m-sheet-body">
<template v-if="ctx.form.mode==='music'">
<p class="m-muted">Music uses tags + lyrics in Prompt / Gen — no hero slot.</p>
</template>
<template v-else>
<Studio2AssetSlot size="hero" label="Hero still" :asset="ctx.stills.find((a:any)=>a.id===ctx.form.identityStillId)" :hidden="ctx.slotHidden(ctx.form.identityStillId)" upload :disabled="ctx.uploading" @pick="ctx.setPicker('identityStillId')" @upload="ctx.upload($event,'hero')" />
<Studio2AssetSlot :label="ctx.form.mode==='extend'?'Previous clip':'Start still'" :asset="ctx.sourceSlot" :hidden="ctx.slotHidden(ctx.form.mode==='extend'?ctx.form.startClipId:ctx.form.imageAId)" upload :disabled="ctx.uploading" @pick="ctx.setPicker(ctx.form.mode==='extend'?'startClipId':'imageAId')" @upload="ctx.upload($event,'source')" />
<Studio2AssetSlot v-if="ctx.form.mode==='compose'" label="Second still" :asset="ctx.stills.find((a:any)=>a.id===ctx.form.imageBId)" :hidden="ctx.slotHidden(ctx.form.imageBId)" @pick="ctx.setPicker('imageBId')" />
<Studio2AssetSlot v-if="ctx.form.mode==='refine'" label="Mask" :asset="ctx.stills.find((a:any)=>a.id===ctx.form.maskId)" :hidden="ctx.slotHidden(ctx.form.maskId)" @pick="ctx.setPicker('maskId')" />
<Studio2AssetSlot v-if="ctx.video" label="End still" :asset="ctx.stills.find((a:any)=>a.id===ctx.form.endStillId)" :hidden="ctx.slotHidden(ctx.form.endStillId)" @pick="ctx.setPicker('endStillId')" />
<label class="m-check"><input v-model="ctx.form.lockFace" type="checkbox"> Lock face</label>
<label class="m-check"><input v-model="ctx.form.lockOutfit" type="checkbox"> Lock outfit</label>
<p class="m-hint">hero encode + lock text — not a MiniMax face/clothes split on stills</p>
<p v-if="ctx.uploading || ctx.uploadMessage" class="m-amber">{{ ctx.uploading?'Uploading…':ctx.uploadMessage }}</p>
</template>
</div>
</aside>
<aside v-if="sheet==='prompt'" class="m-sheet" role="dialog" aria-label="Prompt">
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
<div class="m-sheet-head"><strong>Prompt</strong><button type="button" @click="sheet=''">Done</button></div>
<div class="m-sheet-body">
<template v-if="ctx.form.mode==='music'">
<label class="m-label">Genre / style tags<textarea v-model="ctx.music.tags" rows="3" class="m-area" /></label>
<label class="m-label">Lyrics<textarea v-model="ctx.music.lyrics" rows="8" class="m-area" /></label>
</template>
<template v-else>
<label class="m-label">Prompt<textarea v-model="ctx.form.promptSections.action" rows="5" class="m-area" placeholder="Describe the image or the shot." /></label>
<div>
<span class="m-label">Negative prompt</span>
<div class="m-neg">
<span v-for="chip in ctx.negativeChips" :key="chip" class="m-neg-chip">
{{ chip }}
<button type="button" :aria-label="'Remove '+chip" @click="ctx.removeNegative(chip)">×</button>
</span>
</div>
</div>
<Studio2StyleChips v-if="!ctx.video" v-model="ctx.form.imageStyles" />
</template>
<p v-if="ctx.error" class="m-err">{{ ctx.error }}</p>
</div>
</aside>
<aside v-if="sheet==='look'" class="m-sheet" role="dialog" aria-label="Look">
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
<div class="m-sheet-head"><strong>Look</strong><button type="button" @click="sheet=''">Done</button></div>
<div class="m-sheet-body">
<template v-if="ctx.form.mode==='music'">
<label class="m-label">Seed<input v-model="ctx.music.seed" class="m-input" placeholder="random"></label>
<label class="m-label">Track name<input v-model="ctx.music.name" class="m-input" placeholder="Optional"></label>
</template>
<template v-else>
<div>
<span class="m-label">LoRA</span>
<LoraSelect v-model="ctx.form.settings.loraStack" compact :names="ctx.pickerLoras" :use="ctx.currentLoraUse" :clip-strength="!ctx.video" filename-subtitle :aliases="ctx.loraAliases" :triggers="ctx.loraTriggers" missing-label="None" @insert-trigger="ctx.form.promptSections.action += ' '+$event" />
</div>
<label class="m-label">Seed
<div class="m-seed">
<input
:value="ctx.form.settings.seed ?? ''"
type="number"
min="0"
max="2147483647"
class="m-input"
@input="ctx.form.settings.seed=Number(($event.target as HTMLInputElement).value);ctx.form.settings.seedMode='locked'"
>
<button type="button" class="m-dice" aria-label="Random seed" @click="ctx.randomizeSeed">🎲</button>
</div>
</label>
<label class="m-label">Aspect
<select v-model="ctx.form.settings.aspect" class="m-input">
<option v-for="opt in ctx.aspectOptions" :key="opt.id" :value="opt.id">{{ opt.label }}</option>
</select>
</label>
<label class="m-label">Steps<input v-model.number="ctx.form.settings.steps" type="number" class="m-input"></label>
<label class="m-label">CFG<input v-model.number="ctx.form.settings.cfg" type="number" class="m-input"></label>
</template>
</div>
</aside>
<aside v-if="sheet==='gen'" class="m-sheet" role="dialog" aria-label="Gen extras">
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
<div class="m-sheet-head"><strong>Gen</strong><button type="button" @click="sheet=''">Done</button></div>
<div class="m-sheet-body">
<template v-if="ctx.form.mode==='music'">
<label class="m-label">Target length (sec)<input v-model.number="ctx.music.duration" type="number" min="15" max="150" class="m-input"></label>
<p class="m-hint">Caps semantic tokens (~25/s). Actual length follows the model end token.</p>
</template>
<template v-else-if="ctx.video">
<label class="m-label">Duration (sec)<input v-model.number="ctx.form.settings.duration" type="number" class="m-input"></label>
<label class="m-label">FPS<input v-model.number="ctx.form.settings.fps" type="number" class="m-input"></label>
<label class="m-check"><input v-model="ctx.form.settings.turbo" type="checkbox"> Turbo</label>
<Studio2PromptControls v-model="ctx.form.promptSections" />
<details class="m-details">
<summary>Queue more shots</summary>
<textarea :value="ctx.batchText" rows="4" class="m-area" placeholder="First additional prompt&#10;&#10;Next" @input="ctx.setBatchText(($event.target as HTMLTextAreaElement).value)" />
</details>
</template>
<template v-else>
<p class="m-muted">Image extras live in Look (steps / CFG / seed / aspect).</p>
<details v-if="ctx.form.mode==='edit'" class="m-details">
<summary>Queue extra edit passes</summary>
<textarea :value="ctx.batchText" rows="4" class="m-area" @input="ctx.setBatchText(($event.target as HTMLTextAreaElement).value)" />
</details>
</template>
</div>
</aside>
<aside v-if="sheet==='queue'" class="m-sheet" role="dialog" aria-label="Queue">
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
<div class="m-sheet-head"><strong>Queue</strong><button type="button" @click="sheet=''">Done</button></div>
<div class="m-sheet-body">
<div
v-for="card in ctx.queueCards"
:key="card.id"
class="m-job"
:class="{ on: ctx.focusedJobId===card.id }"
@click="ctx.focusQueueCard(card)"
>
<div class="m-job-id">{{ card.code }}</div>
<div class="m-job-title">{{ card.title }}</div>
<div class="m-muted">{{ card.meta }}</div>
<div class="m-job-status">
<span>● {{ card.statusLabel }}</span>
<button v-if="card.cancelable" type="button" @click.stop="ctx.cancel(card.job)">Cancel</button>
</div>
</div>
<p v-if="!ctx.queueCards.length" class="m-muted">No active jobs</p>
<button v-if="ctx.canClearFinished" type="button" class="m-link" @click="ctx.clearFinished">Clear finished</button>
</div>
</aside>
</div>
</template>
<script setup lang="ts">
export type Studio2MobileCtx = Record<string, any>
const props = defineProps<{ ctx: Studio2MobileCtx }>()
const sheet = ref('')
function toggleSheet(id: string) {
sheet.value = sheet.value === id ? '' : id
}
function closeMenus() {
sheet.value = ''
}
watch(() => props.ctx.form.mode, () => { sheet.value = '' })
</script>
<style scoped>
.m-shell {
--bg: #0b0b0d;
--panel: #141416;
--line: #2a2a2e;
--amber: #f5a524;
--amber-on: #1a1204;
--text: #f3f1ea;
--muted: #9a9588;
position: relative;
display: flex;
height: 100dvh;
max-height: 100dvh;
flex-direction: column;
overflow: hidden;
background: var(--bg);
color: var(--text);
font: 14px ui-sans-serif, system-ui, sans-serif;
}
.m-header {
display: flex;
height: 48px;
flex-shrink: 0;
align-items: center;
gap: 8px;
border-bottom: 1px solid var(--line);
padding: 0 12px;
}
.m-brand { font-weight: 700; }
.m-crumb select {
max-width: 7rem;
border: 0;
background: transparent;
color: var(--muted);
font-size: 12px;
}
.m-grow { flex: 1; }
.m-gpu {
border: 1px solid #2d4a2a;
border-radius: 999px;
padding: 3px 8px;
color: #b8e0a8;
font-size: 11px;
}
.m-gpu[data-tone='busy'] { border-color: color-mix(in srgb, var(--amber) 40%, transparent); color: var(--amber); }
.m-chips {
display: flex;
flex-shrink: 0;
gap: 6px;
overflow-x: auto;
padding: 8px 12px;
}
.m-chip {
flex: 0 0 auto;
border: 1px solid var(--line);
border-radius: 8px;
background: #1a1a1d;
padding: 6px 10px;
color: var(--muted);
font: inherit;
}
.m-chip.on, .m-dock button.on {
border-color: var(--amber);
background: var(--amber);
color: var(--amber-on);
font-weight: 700;
}
.m-chip:disabled { opacity: 0.45; }
.m-more { position: relative; }
.m-more summary { list-style: none; cursor: pointer; padding: 4px 8px; color: var(--muted); }
.m-more-menu {
position: absolute;
right: 0;
z-index: 20;
display: flex;
min-width: 8rem;
flex-direction: column;
gap: 4px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
padding: 8px;
}
.m-more-menu a { color: var(--text); text-decoration: none; padding: 6px 8px; }
.m-stage {
position: relative;
display: flex;
flex: 1;
min-height: 0;
min-width: 0;
align-items: center;
justify-content: center;
overflow: hidden;
background: #080808;
}
.m-stage > img,
.m-stage > video {
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.m-audio { width: 100%; max-width: 100%; padding: 12px; }
.m-eye {
position: absolute;
top: 8px;
right: 8px;
z-index: 5;
display: grid;
width: 28px;
height: 28px;
place-items: center;
border: 1px solid var(--line);
border-radius: 6px;
background: #000a;
}
.m-strip {
display: flex;
height: 64px;
flex-shrink: 0;
gap: 6px;
overflow-x: auto;
border-top: 1px solid var(--line);
padding: 8px 12px;
}
.m-tile {
position: relative;
width: 72px;
height: 48px;
flex: 0 0 auto;
overflow: hidden;
border: 2px solid transparent;
border-radius: 6px;
background: #222;
}
.m-tile.on { border-color: var(--amber); }
.m-tile-pick { display: block; width: 100%; height: 100%; border: 0; padding: 0; background: transparent; }
.m-tile img { width: 100%; height: 100%; object-fit: cover; }
.m-tile-hid { display: grid; height: 100%; place-items: center; color: var(--muted); font-size: 10px; }
.m-tile-eye, .m-tile-trash {
position: absolute;
top: 2px;
right: 2px;
display: grid;
width: 16px;
height: 16px;
place-items: center;
border: 0;
border-radius: 3px;
background: #000a;
font-size: 10px;
}
.m-tile-trash { right: 20px; }
.m-dock {
display: grid;
grid-template-columns: repeat(4, 1fr) 120px;
flex-shrink: 0;
gap: 6px;
border-top: 1px solid var(--line);
background: var(--panel);
padding: 8px 8px calc(8px + env(safe-area-inset-bottom));
}
.m-dock button {
height: 48px;
border: 1px solid var(--line);
border-radius: 8px;
background: #1a1a1d;
color: var(--text);
font: inherit;
}
.m-cta {
background: var(--amber) !important;
color: var(--amber-on) !important;
font-weight: 700 !important;
}
.m-cta:disabled { opacity: 0.5; }
.m-sheet-backdrop {
position: absolute;
inset: 0;
z-index: 30;
background: #0008;
}
.m-sheet {
position: absolute;
right: 0;
bottom: 0;
left: 0;
z-index: 31;
display: flex;
max-height: min(85vh, 100%);
flex-direction: column;
border-top: 1px solid var(--line);
border-radius: 16px 16px 0 0;
background: var(--panel);
padding: 10px 14px 20px;
}
.m-handle {
display: block;
width: 40px;
height: 4px;
margin: 4px auto 12px;
border: 0;
border-radius: 4px;
background: #444;
}
.m-sheet-head {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.m-sheet-head button { border: 0; background: transparent; color: var(--amber); }
.m-sheet-body {
flex: 1;
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
gap: 10px;
}
.m-label { display: block; font-size: 11px; color: var(--muted); }
.m-area, .m-input {
width: 100%;
margin-top: 4px;
border: 1px solid var(--line);
border-radius: 8px;
background: #1a1a1d;
padding: 8px;
color: var(--text);
font: inherit;
}
.m-check { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text); }
.m-hint, .m-muted { color: var(--muted); font-size: 12px; }
.m-amber { color: var(--amber); font-size: 12px; }
.m-err { color: #fca5a5; font-size: 12px; }
.m-neg { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
.m-neg-chip {
display: inline-flex;
align-items: center;
gap: 4px;
border: 1px solid #5a4a28;
border-radius: 999px;
background: #2a2418;
padding: 4px 10px;
color: #e8c37a;
font-size: 12px;
}
.m-neg-chip button { border: 0; background: transparent; color: inherit; }
.m-seed { display: flex; gap: 8px; margin-top: 4px; }
.m-seed .m-input { margin-top: 0; flex: 1; }
.m-dice {
width: 40px;
height: 40px;
border: 1px solid var(--line);
border-radius: 8px;
background: #1a1a1d;
}
.m-details { border: 1px solid var(--line); border-radius: 8px; padding: 8px; }
.m-job {
border: 1px solid var(--line);
border-radius: 8px;
padding: 10px;
cursor: pointer;
}
.m-job.on { border-color: var(--amber); border-width: 2px; }
.m-job-id { color: var(--muted); font-size: 12px; }
.m-job-title { font-weight: 600; }
.m-job-status { display: flex; justify-content: space-between; margin-top: 6px; font-size: 12px; }
.m-link { border: 0; background: transparent; color: var(--amber); text-align: left; }
</style>