feat: 3-state size toggle, readability overlay, and input persistence

- Add pin/medium/large size mode button in titlebar with pixel art icons
- Dark overlay appears on hover for better text readability
- User-input stays visible when textarea has text (CSS :has selector)
- Animated size transitions between modes
This commit is contained in:
2026-02-19 15:44:35 -06:00
parent f7391f83b4
commit 3adfd189e1

View File

@@ -72,6 +72,17 @@ const dragOffset = ref({ x: 0, y: 0 })
const isResizing = ref(false) const isResizing = ref(false)
const size = ref({ w: 480, h: 600 }) const size = ref({ w: 480, h: 600 })
// Size mode: pin (small, anchored to FAB), medium (default), large
type SizeMode = 'pin' | 'medium' | 'large'
const sizeMode = ref<SizeMode>('medium')
function cycleSizeMode() {
const modes: SizeMode[] = ['pin', 'medium', 'large']
const i = modes.indexOf(sizeMode.value)
sizeMode.value = modes[(i + 1) % modes.length]
hasCustomPosition.value = false
}
// Mobile bottom sheet state // Mobile bottom sheet state
const isMobile = ref(false) const isMobile = ref(false)
const sheetHeight = ref(55) const sheetHeight = ref(55)
@@ -217,6 +228,12 @@ function stopResize() {
// COMPUTED STYLE // COMPUTED STYLE
// ============================================================================ // ============================================================================
const sizeModePresets: Record<SizeMode, { w: number; h: number }> = {
pin: { w: 240, h: 300 },
medium: { w: 480, h: 600 },
large: { w: 800, h: 760 }
}
const windowStyle = computed((): Record<string, string> => { const windowStyle = computed((): Record<string, string> => {
if (isMobile.value) { if (isMobile.value) {
return { return {
@@ -230,22 +247,49 @@ const windowStyle = computed((): Record<string, string> => {
} }
} }
if (!hasCustomPosition.value) { const preset = sizeModePresets[sizeMode.value]
// Custom position from dragging (uses current resize size or preset)
if (hasCustomPosition.value) {
const w = sizeMode.value === 'medium' ? size.value.w : preset.w
const h = sizeMode.value === 'medium' ? size.value.h : preset.h
return {
width: `${w}px`,
height: `${h}px`,
top: `${position.value.y}px`,
left: `${position.value.x}px`,
bottom: 'auto',
right: 'auto'
}
}
// Pin: anchored to FAB button (bottom-left corner aligned)
if (sizeMode.value === 'pin') {
return {
width: `${preset.w}px`,
height: `${preset.h}px`,
bottom: '20px',
left: '80px'
}
}
// Large: centered-ish, generous size
if (sizeMode.value === 'large') {
return {
width: `${preset.w}px`,
height: `${preset.h}px`,
bottom: '16px',
left: '90px'
}
}
// Medium (default)
return { return {
width: `${size.value.w}px`, width: `${size.value.w}px`,
height: `${size.value.h}px`, height: `${size.value.h}px`,
bottom: '16px', bottom: '16px',
left: '90px' left: '90px'
} }
}
return {
width: `${size.value.w}px`,
height: `${size.value.h}px`,
top: `${position.value.y}px`,
left: `${position.value.x}px`,
bottom: 'auto',
right: 'auto'
}
}) })
// ============================================================================ // ============================================================================
@@ -352,6 +396,22 @@ onBeforeUnmount(() => {
<span v-if="selectedAgent" class="agent-badge">{{ selectedAgent }}</span> <span v-if="selectedAgent" class="agent-badge">{{ selectedAgent }}</span>
</div> </div>
<div class="window-controls"> <div class="window-controls">
<button @click.stop="cycleSizeMode" class="size-btn" :title="`Size: ${sizeMode}`">
<!-- Pin icon: small square bottom-left -->
<svg v-if="sizeMode === 'pin'" width="10" height="10" viewBox="0 0 10 10" shape-rendering="crispEdges">
<rect x="0" y="6" width="4" height="4" fill="currentColor"/>
</svg>
<!-- Medium icon: medium square centered -->
<svg v-else-if="sizeMode === 'medium'" width="10" height="10" viewBox="0 0 10 10" shape-rendering="crispEdges">
<rect x="2" y="2" width="6" height="6" fill="currentColor" opacity="0.35"/>
<rect x="3" y="3" width="4" height="4" fill="currentColor"/>
</svg>
<!-- Large icon: full square -->
<svg v-else width="10" height="10" viewBox="0 0 10 10" shape-rendering="crispEdges">
<rect x="0" y="0" width="10" height="10" fill="currentColor" opacity="0.35"/>
<rect x="1" y="1" width="8" height="8" fill="currentColor"/>
</svg>
</button>
<button @click.stop="showSelector = !showSelector" :class="{ active: showSelector }" title="Agent/Session"> <button @click.stop="showSelector = !showSelector" :class="{ active: showSelector }" title="Agent/Session">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/> <circle cx="12" cy="12" r="3"/>
@@ -409,6 +469,7 @@ onBeforeUnmount(() => {
<!-- Content --> <!-- Content -->
<div class="content"> <div class="content">
<BackgroundPixelArt /> <BackgroundPixelArt />
<div class="readability-overlay" />
<ChatContainer <ChatContainer
v-if="conversation" v-if="conversation"
:conversation="conversation" :conversation="conversation"
@@ -435,9 +496,15 @@ onBeforeUnmount(() => {
<style scoped> <style scoped>
.aero-win { .aero-win {
position: fixed; position: fixed;
min-width: 360px; min-width: 200px;
min-height: 300px; min-height: 200px;
z-index: 9999; z-index: 9999;
transition: width 0.3s ease, height 0.3s ease, top 0.3s ease, left 0.3s ease, bottom 0.3s ease;
}
.aero-win.dragging,
.aero-win.resizing {
transition: none;
} }
.glass { .glass {
@@ -507,13 +574,20 @@ onBeforeUnmount(() => {
pointer-events: none !important; pointer-events: none !important;
} }
/* Idle: slide user-input down and fade out */ /* Idle: slide user-input down and fade out (only if empty) */
.aero-win:not(.chrome-visible) .content :deep(.user-input) { .aero-win:not(.chrome-visible) .content :deep(.user-input) {
opacity: 0 !important; opacity: 0 !important;
transform: translateY(100%) !important; transform: translateY(100%) !important;
pointer-events: none !important; pointer-events: none !important;
} }
/* Keep user-input visible when textarea has text */
.aero-win:not(.chrome-visible) .content :deep(.user-input:has(.input-field:not(:placeholder-shown))) {
opacity: 1 !important;
transform: none !important;
pointer-events: auto !important;
}
/* Idle: also hide selection bar */ /* Idle: also hide selection bar */
.aero-win:not(.chrome-visible) .content :deep(.selection-bar) { .aero-win:not(.chrome-visible) .content :deep(.selection-bar) {
opacity: 0 !important; opacity: 0 !important;
@@ -643,6 +717,16 @@ onBeforeUnmount(() => {
color: #a5b4fc; color: #a5b4fc;
} }
.window-controls .size-btn {
color: #0ea5e9;
}
.window-controls .size-btn:hover {
color: #38bdf8;
background: rgba(14, 165, 233, 0.15);
border-color: rgba(14, 165, 233, 0.25);
}
.window-controls button.x:hover { .window-controls button.x:hover {
background: rgba(239, 68, 68, 0.3); background: rgba(239, 68, 68, 0.3);
border-color: rgba(239, 68, 68, 0.4); border-color: rgba(239, 68, 68, 0.4);
@@ -772,6 +856,21 @@ onBeforeUnmount(() => {
/* Background handled by BackgroundPixelArt component */ /* Background handled by BackgroundPixelArt component */
} }
/* Dark readability overlay: between ocean bg (z-index:0) and chat (z-index:1) */
.readability-overlay {
position: absolute;
inset: 0;
z-index: 0;
background: rgba(0, 0, 0, 0.55);
pointer-events: none;
opacity: 0;
transition: opacity 0.35s ease;
}
.aero-win.chrome-visible .readability-overlay {
opacity: 1;
}
/* Override ChatContainer backgrounds: glass-transparent */ /* Override ChatContainer backgrounds: glass-transparent */
.content :deep(.chat-container) { .content :deep(.chat-container) {
background: transparent !important; background: transparent !important;