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
+97
View File
@@ -0,0 +1,97 @@
# Cursor prompt — unique mobile portrait shell (new file)
Do not edit `STUDIO2-DISPLAY-CURSOR-PROMPT.md`, `STUDIO2-DISPLAY-LAYOUT-SPEC.md`, `STUDIO2-LORA-DIALS-PROMPT.md`, or `STUDIO2-VIEWER-FIT-PROMPT.md`.
Branch: **`studio-2`**. Push `origin/studio-2` only. Chrome only. Same `form`, queue, generate, locks, LoRAs, library. No graph/runner/extend/YuE2 changes.
Desktop 3-column bench is **done**. Do not restyle desktop to make mobile work. Do not stack the three columns in a tall scroll and call it mobile.
Breakpoint: `(max-width: 767px)` **and** coarse pointer. Use a **separate mobile shell** (`Studio2Mobile.vue` or `Bench` template `v-if="isMobile"`) that binds the **same** state as desktop Bench.
Reference mock: `studio2-display-ux/mobile-mock.html` (open on a 390×844 viewport).
---
## Why unique
Portrait cannot show 260 | stage | 280. If you shrink that grid you lose Generate, LoRAs, and the still.
Mobile is a **viewer-first phone app** with sheets for the inspector and the queue. Every desktop control still exists. It is just not on screen at once.
---
## Chrome map (portrait)
```
┌ status / 48px header ─────────────────────────────────────┐
│ AIGen Inbox ▾ GPU ☰ queue(n) │
├ task chips (scroll-x) Generate Edit … Music ─────────────┤
├ model chips Klein | Krea or H3 | LTX ───────┤
├───────────────────────────────────────────────────────────┤
│ │
│ STAGE (contain, full remaining height) │
│ │
├ filmstrip scroll-x, tiles 72px ───────────────────────────┤
├ [Slots] [Prompt] [Look] [Gen] Generate (amber, 48px) ─┤
└───────────────────────────────────────────────────────────┘
```
Bottom **dock** is 4 tabs that open sheets, plus Generate always visible:
| Tab | Sheet contents (all desktop fields) |
|---|---|
| **Slots** | Hero, Start, End, Guides, Upload, Lock face, Lock outfit + Klein caption |
| **Prompt** | Prompt textarea, negative chips, style/render/quality/lighting chips |
| **Look** | LoRA stack (filename + Model/CLIP sliders + numbers), Seed + dice, Aspect, extra generation settings (steps/CFG only if that engine uses them) |
| **Gen** | Engine-specific video/music extras: duration/target length, fps, turbo, camera beats, YuE2 hints. Not a second Generate button. |
**Generate** is the rightmost dock control — 48×48 or a 120px amber pill. Never labeled Queue.
Header **☰** or badge **Queue (n)** opens a **right/bottom sheet** of job cards (same cards as desktop right column).
Library and Settings stay header links (or a `…` menu). Do not drop them.
---
## Stage
- Same contain rule as desktop: `max-width/height 100%`, `object-fit: contain`, no inner max-width cap.
- Still / video / audio switch on `selected.kind`. No waveform on a still.
- Safe area: `padding-bottom: env(safe-area-inset-bottom)` on the dock.
- Filmstrip under stage, 72×48 tiles, amber ring on selected, horizontal scroll. Trash on selected tile only.
---
## Sheets
- One sheet at a time. Height `min(85vh, content)`. Drag handle + Done.
- Fields keep desktop order and payload keys.
- LoRA card = filename + two (or one) sliders + numeric `0.00–2.00`. No hashed meters.
- Krea disabled-on-Edit with the same one-line reason.
- LTX disabled on AIGen with `xAIGen only`.
- xAIGen hide defaults: eye on stage + each tile. No global hide checkbox required on mobile.
---
## What you must not do
- No desktop 3-column at `<768`.
- No “rotate device” dead end.
- No missing task (Iterate if desktop has it — add the chip).
- No second `form` object.
- Do not change Classic/Studio desktop tokens in this pass.
`isMobile` from `window.matchMedia('(max-width: 767px)')` + resize listener. SSR: default desktop to avoid hydration flip, then switch on mount.
---
## Done when
390×844 portrait:
- Stage fills the phone; dock + chips visible without page-zoom.
- Slots, Prompt, Look, Gen sheets each reachable; Generate queues a job.
- Queue sheet lists jobs.
- Rotate to ≥768: desktop 3-column returns, same job state.
One or two present-tense commits. Screenshot 390 and 1440.
+4
View File
@@ -62,6 +62,10 @@
color: var(--text);
}
.bench-root[data-mobile="1"] {
position: relative;
}
.bench-header {
display: grid;
grid-template-columns: 1fr auto 1fr;
+57 -1
View File
@@ -1,5 +1,8 @@
<template>
<div class="bench-root" :data-display="displayMode">
<div class="bench-root" :data-display="displayMode" :data-mobile="isMobile ? '1' : undefined">
<Studio2Mobile v-if="isMobile" :ctx="mobileCtx" />
<template v-else>
<header class="bench-header">
<div class="bench-brand">{{ instanceName }}</div>
<div class="bench-crumb" aria-label="Folder">
@@ -262,6 +265,7 @@
</div>
</aside>
</div>
</template>
<div v-if="settingsOpen || detailsOpen" class="fixed inset-0 z-50 bg-black/70" @click.self="settingsOpen=false;detailsOpen=false">
<aside class="absolute inset-y-0 right-0 w-full max-w-md overflow-auto border-l border-[var(--line)] bg-[var(--bg)] p-6">
@@ -318,6 +322,7 @@ import {DEFAULT_MUSIC_LYRICS,DEFAULT_MUSIC_TAGS} from '~/utils/music'
const xaigen=useRuntimeConfig().public.studio==='xaigen'
const instanceName=String(useRuntimeConfig().public.instanceName || 'AIGen')
const {displayMode,isClassic}=useDisplayMode()
const {isMobile}=useIsMobile()
const classicHideInput=ref(true)
const classicHideOutput=ref(true)
const picker=ref('')
@@ -591,4 +596,55 @@ onMounted(async()=>{
const poll=async()=>{try{await refresh();const health=await $fetch<any>('/api/health');healthState.value=health}catch(e:any){healthState.value=null;error.value=e.data?.statusMessage||e.message}if(!disposed)timer=setTimeout(poll,3000)};void poll()
})
onBeforeUnmount(()=>{disposed=true;clearTimeout(timer);clearInterval(clock)})
function randomizeSeed(){form.settings.seedMode='random';form.settings.seed=randomSeed()}
function setPicker(key:string){picker.value=key}
const mobileCtx=computed(()=>({
instanceName,
xaigen,
form,
music,
library:library.value,
stills:stills.value,
tasks,
engineOptions:engineOptions.value,
gpu:gpu.value,
gpuChipLabel:gpuChipLabel.value,
queueCount:queueCount.value,
queueCards:queueCards.value,
focusedJobId:focusedJobId.value,
canClearFinished:canClearFinished.value,
selected:selected.value,
family:family.value,
sourceSlot:sourceSlot.value,
video:video.value,
submitting:submitting.value,
uploading:uploading.value,
uploadMessage:uploadMessage.value,
error:error.value,
batchText:batchText.value,
negativeChips:negativeChips.value,
pickerLoras:pickerLoras.value,
currentLoraUse:currentLoraUse.value,
loraAliases:{...loraNames.value.aliases,...library.value.loraAliases},
loraTriggers:{...loraNames.value.triggers,...library.value.loraTriggers},
aspectOptions,
stillUrl,
isHidden,
slotHidden,
toggleReveal,
chooseTask,
selectOutput,
deleteOutput,
queue,
upload,
cancel,
focusQueueCard,
clearFinished,
removeNegative,
randomizeSeed,
setPicker,
setBatchText:(v:string)=>{batchText.value=v},
}))
</script>
+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>
+29
View File
@@ -0,0 +1,29 @@
/** Portrait shell gate. SSR defaults desktop to avoid hydration flip. */
export function useIsMobile() {
const isMobile = useState('studio2.isMobile', () => false)
function read() {
if (!import.meta.client) return false
return window.matchMedia('(max-width: 767px)').matches
}
function sync() {
isMobile.value = read()
}
if (import.meta.client) {
onMounted(() => {
sync()
const mq = window.matchMedia('(max-width: 767px)')
const onChange = () => { isMobile.value = mq.matches }
mq.addEventListener?.('change', onChange)
window.addEventListener('resize', sync)
onBeforeUnmount(() => {
mq.removeEventListener?.('change', onChange)
window.removeEventListener('resize', sync)
})
})
}
return { isMobile }
}