Enhance VibeTunnel web interface with modern visual design (#177)

This commit is contained in:
Peter Steinberger 2025-07-01 17:57:57 +01:00 committed by GitHub
parent b452251a47
commit fcda54a5f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 632 additions and 301 deletions

View file

@ -113,6 +113,11 @@ export class VibeTunnelApp extends LitElement {
this.hasActiveOverlay =
this.showFileBrowser || this.showCreateModal || this.showSSHKeyManager || this.showSettings;
}
// Force re-render when sessions change or view changes to update log button position
if (changedProperties.has('sessions') || changedProperties.has('currentView')) {
this.requestUpdate();
}
}
disconnectedCallback() {
@ -1155,6 +1160,54 @@ export class VibeTunnelApp extends LitElement {
return /iPad|iPhone|iPod/.test(navigator.userAgent) && !('MSStream' in window);
}
private getLogButtonPosition(): string {
// Check if we're in grid view and not in split view
const isGridView = !this.showSplitView && this.currentView === 'list';
if (isGridView) {
// Calculate if we need to move the button up
const runningSessions = this.sessions.filter((s) => s.status === 'running');
const viewportHeight = window.innerHeight;
// Grid layout: auto-fill with 360px min width, 400px height, 1.25rem gap
const gridItemHeight = 400;
const gridGap = 20; // 1.25rem
const containerPadding = 16; // Approximate padding
const headerHeight = 200; // Approximate header + controls height
// Calculate available height for grid
const availableHeight = viewportHeight - headerHeight;
// Calculate how many rows can fit
const rowsCanFit = Math.floor(
(availableHeight - containerPadding) / (gridItemHeight + gridGap)
);
// Calculate grid columns based on viewport width
const viewportWidth = window.innerWidth;
const gridItemMinWidth = 360;
const sidebarWidth = this.sidebarCollapsed
? 0
: this.mediaState.isMobile
? 0
: this.sidebarWidth;
const availableWidth = viewportWidth - sidebarWidth - containerPadding * 2;
const columnsCanFit = Math.floor(availableWidth / (gridItemMinWidth + gridGap));
// Calculate total items that can fit in viewport
const itemsInViewport = rowsCanFit * columnsCanFit;
// If we have more running sessions than can fit in viewport, items will be at bottom
if (runningSessions.length >= itemsInViewport && itemsInViewport > 0) {
// Move button up to avoid overlapping with kill buttons
return 'bottom-20'; // ~80px up
}
}
// Default position with equal margins
return 'bottom-4';
}
private get isInSidebarDismissMode(): boolean {
if (!this.mediaState.isMobile || !this.shouldShowMobileOverlay) return false;
@ -1372,13 +1425,13 @@ export class VibeTunnelApp extends LitElement {
@error=${this.handleError}
></session-create-form>
<!-- Version and logs link in bottom right -->
<!-- Version and logs link with smart positioning -->
${
this.showLogLink
? html`
<div class="fixed bottom-4 right-4 text-dark-text-muted text-xs font-mono z-20">
<div class="fixed ${this.getLogButtonPosition()} right-4 text-dark-text-muted text-xs font-mono z-20 bg-dark-bg-secondary px-3 py-1.5 rounded-lg border border-dark-border shadow-sm transition-all duration-200">
<a href="/logs" class="hover:text-dark-text transition-colors">Logs</a>
<span class="ml-2">v${VERSION}</span>
<span class="ml-2 opacity-75">v${VERSION}</span>
</div>
`
: ''

View file

@ -176,7 +176,7 @@ export class AuthLogin extends LitElement {
<div class="flex flex-col items-center gap-2 sm:gap-3 mb-4 sm:mb-8">
<terminal-icon
size="${this.isMobile ? '48' : '56'}"
style="filter: drop-shadow(0 0 15px rgba(124, 230, 161, 0.4));"
style="filter: drop-shadow(0 0 15px rgba(16, 185, 129, 0.4));"
></terminal-icon>
<h2 class="auth-title text-2xl sm:text-3xl mt-1 sm:mt-2">VibeTunnel</h2>
<p class="auth-subtitle text-xs sm:text-sm">Please authenticate to continue</p>
@ -233,7 +233,7 @@ export class AuthLogin extends LitElement {
<div class="flex flex-col items-center mb-4 sm:mb-6">
<div
class="w-24 h-24 sm:w-28 sm:h-28 rounded-full mb-3 sm:mb-4 overflow-hidden"
style="box-shadow: 0 0 25px rgba(124, 230, 161, 0.3);"
style="box-shadow: 0 0 25px rgba(16, 185, 129, 0.3);"
>
${
this.userAvatar
@ -361,7 +361,7 @@ export class AuthLogin extends LitElement {
<div class="ssh-key-item p-6 sm:p-8">
<div class="flex items-center justify-between mb-3 sm:mb-4">
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full bg-accent-green"></div>
<div class="w-2 h-2 rounded-full bg-primary"></div>
<span class="font-mono text-xs sm:text-sm">SSH Key Management</span>
</div>
<button

View file

@ -39,7 +39,7 @@ export class AuthQuickKeys extends LitElement {
({ key, label }) => html`
<button
type="button"
class="quick-key-btn px-3 py-1.5 bg-dark-bg-tertiary text-dark-text text-xs font-mono rounded border border-dark-border hover:bg-dark-surface hover:border-accent-green transition-all whitespace-nowrap flex-shrink-0"
class="quick-key-btn px-3 py-1.5 bg-dark-bg-tertiary text-dark-text text-xs font-mono rounded border border-dark-border hover:bg-dark-surface hover:border-primary transition-all whitespace-nowrap flex-shrink-0"
@click=${() => this.handleKeyPress(key)}
>
${label}

View file

@ -484,7 +484,7 @@ export class FileBrowser extends LitElement {
let className = 'text-dark-text-muted';
if (line.startsWith('+')) className = 'text-status-success bg-green-900/20';
else if (line.startsWith('-')) className = 'text-status-error bg-red-900/20';
else if (line.startsWith('@@')) className = 'text-accent-blue font-semibold';
else if (line.startsWith('@@')) className = 'text-status-info font-semibold';
return html`<div class="whitespace-pre ${className}">${line}</div>`;
})}
@ -550,7 +550,7 @@ export class FileBrowser extends LitElement {
@input=${this.handlePathInput}
@keydown=${this.handlePathKeyDown}
@blur=${this.handlePathBlur}
class="bg-dark-bg border border-dark-border rounded px-2 py-1 text-blue-400 text-xs sm:text-sm font-mono w-full min-w-0 focus:outline-none focus:border-accent-green"
class="bg-dark-bg border border-dark-border rounded px-2 py-1 text-status-info text-xs sm:text-sm font-mono w-full min-w-0 focus:outline-none focus:border-primary"
placeholder="Enter path and press Enter"
/>
`
@ -598,7 +598,7 @@ export class FileBrowser extends LitElement {
<div class="flex gap-2">
<button
class="btn-secondary text-xs px-2 py-1 font-mono ${
this.gitFilter === 'changed' ? 'bg-accent-green text-dark-bg' : ''
this.gitFilter === 'changed' ? 'bg-primary text-black' : ''
}"
@click=${this.toggleGitFilter}
title="Show only Git changes"
@ -607,7 +607,7 @@ export class FileBrowser extends LitElement {
</button>
<button
class="btn-secondary text-xs px-2 py-1 font-mono ${
this.showHidden ? 'bg-accent-green text-dark-bg' : ''
this.showHidden ? 'bg-primary text-black' : ''
}"
@click=${this.toggleHidden}
title="Show hidden files"
@ -657,7 +657,7 @@ export class FileBrowser extends LitElement {
class="p-3 hover:bg-dark-bg-lighter cursor-pointer transition-colors flex items-center gap-2
${
this.selectedFile?.path === file.path
? 'bg-dark-bg-lighter border-l-2 border-accent-green'
? 'bg-dark-bg-lighter border-l-2 border-primary'
: ''
}"
@click=${() => this.handleFileClick(file)}
@ -684,7 +684,7 @@ export class FileBrowser extends LitElement {
</span>
<span
class="flex-1 text-sm whitespace-nowrap ${
file.type === 'directory' ? 'text-accent-blue' : 'text-dark-text'
file.type === 'directory' ? 'text-status-info' : 'text-dark-text'
}"
title="${file.name}${file.isSymlink ? ' (symlink)' : ''}"
>${file.name}</span
@ -805,7 +805,7 @@ export class FileBrowser extends LitElement {
? html`
<button
class="btn-secondary text-xs px-2 py-1 font-mono ${
this.showDiff ? 'bg-accent-green text-dark-bg' : ''
this.showDiff ? 'bg-primary text-black' : ''
} ${
this.isMobile &&
this.selectedFile.type === 'file' &&

View file

@ -1,3 +1,4 @@
// @vitest-environment happy-dom
/**
* Unit tests for FilePicker component
*/
@ -63,7 +64,7 @@ describe('FilePicker Component', () => {
const progressText = element.querySelector('span');
expect(progressText?.textContent).toContain('Uploading...');
const progressBar = element.querySelector('.bg-blue-500');
const progressBar = element.querySelector('.bg-gradient-to-r');
expect(progressBar).toBeTruthy();
});
@ -115,7 +116,7 @@ describe('FilePicker Component', () => {
const cancelEventSpy = vi.fn();
element.addEventListener('file-cancel', cancelEventSpy);
const modal = element.querySelector('.bg-white');
const modal = element.querySelector('.bg-dark-bg-elevated');
expect(modal).toBeTruthy();
modal?.dispatchEvent(new MouseEvent('click', { bubbles: true }));

View file

@ -35,6 +35,7 @@ export class FilePicker extends LitElement {
@property({ type: Boolean }) visible = false;
@property({ type: Boolean }) showPathOption = true; // Whether to show "Send path to terminal" option
@property({ type: Boolean }) directSelect = false; // Skip dialog and open file picker directly
@state() private uploading = false;
@state() private uploadProgress = 0;
@ -45,6 +46,20 @@ export class FilePicker extends LitElement {
this.createFileInput();
}
updated(changedProperties: Map<string, unknown>) {
super.updated(changedProperties);
// If directSelect is enabled and visible becomes true, immediately open file picker
if (changedProperties.has('visible') && this.visible && this.directSelect) {
// Small delay to ensure the component is ready
setTimeout(() => {
this.handleFileClick();
// Reset visible state since we're not showing the dialog
this.visible = false;
}, 10);
}
}
disconnectedCallback() {
super.disconnectedCallback();
if (this.fileInput) {
@ -94,6 +109,13 @@ export class FilePicker extends LitElement {
return this.uploadFileToServer(file);
}
/**
* Public method to directly open the file picker without showing dialog
*/
openFilePicker(): void {
this.handleFileClick();
}
private async uploadFileToServer(file: File): Promise<void> {
this.uploading = true;
this.uploadProgress = 0;
@ -187,52 +209,53 @@ export class FilePicker extends LitElement {
}
render() {
// Always render a container so the file input is available
if (!this.visible) {
return html``;
return html`<div style="display: none;"></div>`;
}
return html`
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" @click=${this.handleCancel}>
<div class="bg-white dark:bg-gray-800 rounded-lg p-6 m-4 max-w-sm w-full" @click=${(e: Event) => e.stopPropagation()}>
<h3 class="text-lg font-semibold mb-4 text-gray-900 dark:text-white">
<div class="fixed inset-0 bg-black bg-opacity-80 backdrop-blur-sm flex items-center justify-center z-50 animate-fade-in" @click=${this.handleCancel}>
<div class="bg-dark-bg-elevated border border-dark-border rounded-xl shadow-2xl p-8 m-4 max-w-sm w-full animate-scale-in" @click=${(e: Event) => e.stopPropagation()}>
<h3 class="text-xl font-bold text-dark-text mb-6">
Select File
</h3>
${
this.uploading
? html`
<div class="mb-4">
<div class="flex items-center justify-between mb-2">
<span class="text-sm text-gray-600 dark:text-gray-400">Uploading...</span>
<span class="text-sm text-gray-600 dark:text-gray-400">${Math.round(this.uploadProgress)}%</span>
<div class="mb-6">
<div class="flex items-center justify-between mb-3">
<span class="text-sm text-dark-text-muted font-mono">Uploading...</span>
<span class="text-sm text-accent-primary font-mono font-medium">${Math.round(this.uploadProgress)}%</span>
</div>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div class="w-full bg-dark-bg-secondary rounded-full h-2 overflow-hidden">
<div
class="bg-blue-500 h-2 rounded-full transition-all duration-300"
class="bg-gradient-to-r from-accent-primary to-accent-primary-light h-2 rounded-full transition-all duration-300 shadow-glow-primary-sm"
style="width: ${this.uploadProgress}%"
></div>
</div>
</div>
`
: html`
<div class="space-y-3">
<div class="space-y-4">
<button
@click=${this.handleFileClick}
class="w-full bg-blue-500 hover:bg-blue-600 text-white font-medium py-3 px-4 rounded-lg flex items-center justify-center space-x-2 transition-colors"
class="w-full bg-accent-primary text-dark-bg font-medium py-4 px-6 rounded-lg flex items-center justify-center gap-3 transition-all duration-200 hover:bg-accent-primary-light hover:shadow-glow-primary active:scale-95"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4 4a2 2 0 00-2 2v8a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-5L9 2H4z" clip-rule="evenodd"/>
</svg>
<span>Choose File</span>
<span class="font-mono">Choose File</span>
</button>
</div>
`
}
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-600">
<div class="mt-6 pt-6 border-t border-dark-border">
<button
@click=${this.handleCancel}
class="w-full bg-gray-300 hover:bg-gray-400 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 font-medium py-2 px-4 rounded-lg transition-colors"
class="w-full bg-dark-bg-secondary border border-dark-border text-dark-text font-mono py-3 px-6 rounded-lg transition-all duration-200 hover:bg-dark-surface-hover hover:border-dark-border-light active:scale-95"
?disabled=${this.uploading}
>
Cancel

View file

@ -370,7 +370,7 @@ describe('SessionCard', () => {
await element.updateComplete;
const card = element.querySelector('.card');
expect(card?.classList.contains('shadow-glow-green-sm')).toBe(true);
expect(card?.classList.contains('shadow-glow-sm')).toBe(true);
});
it('should apply opacity when killing', async () => {

View file

@ -224,7 +224,7 @@ export class SessionCard extends LitElement {
this.killing ? 'opacity-60' : ''
} ${
this.isActive && this.session.status === 'running'
? 'shadow-[0_0_0_2px_#00ff88] shadow-glow-green-sm'
? 'ring-2 ring-primary shadow-glow-sm'
: ''
}"
style="view-transition-name: session-${this.session.id}; --session-id: session-${
@ -238,7 +238,7 @@ export class SessionCard extends LitElement {
>
<!-- Compact Header -->
<div
class="flex justify-between items-center px-3 py-2 border-b border-dark-border bg-dark-bg-secondary"
class="flex justify-between items-center px-3 py-2 border-b border-dark-border bg-gradient-to-r from-dark-bg-secondary to-dark-bg-tertiary"
>
<div class="text-xs font-mono pr-2 flex-1 min-w-0 text-accent-green">
<div class="truncate" title="${this.session.name || this.session.command.join(' ')}">
@ -249,12 +249,10 @@ export class SessionCard extends LitElement {
this.session.status === 'running' || this.session.status === 'exited'
? html`
<button
class="btn-ghost ${
this.session.status === 'running' ? 'text-status-error' : 'text-status-warning'
} disabled:opacity-50 flex-shrink-0 p-1 rounded-full hover:bg-opacity-20 transition-all ${
class="p-1 rounded-full transition-all duration-200 disabled:opacity-50 flex-shrink-0 ${
this.session.status === 'running'
? 'hover:bg-status-error'
: 'hover:bg-status-warning'
? 'text-status-error hover:bg-status-error hover:bg-opacity-20'
: 'text-status-warning hover:bg-status-warning hover:bg-opacity-20'
}"
@click=${this.handleKillClick}
?disabled=${this.killing}
@ -292,9 +290,10 @@ export class SessionCard extends LitElement {
<!-- Terminal display (main content) -->
<div
class="session-preview bg-black overflow-hidden flex-1 ${
class="session-preview bg-black overflow-hidden flex-1 relative ${
this.session.status === 'exited' ? 'session-exited' : ''
}"
style="background: linear-gradient(to bottom, #0a0a0a, #080808); box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.5);"
>
${
this.killing
@ -319,18 +318,20 @@ export class SessionCard extends LitElement {
<!-- Compact Footer -->
<div
class="px-3 py-2 text-dark-text-muted text-xs border-t border-dark-border bg-dark-bg-secondary"
class="px-3 py-2 text-dark-text-muted text-xs border-t border-dark-border bg-gradient-to-r from-dark-bg-tertiary to-dark-bg-secondary"
>
<div class="flex justify-between items-center min-w-0">
<span
class="${this.getStatusColor()} text-xs flex items-center gap-1 flex-shrink-0"
class="${this.getActivityStatusColor()} text-xs flex items-center gap-1 flex-shrink-0"
data-status="${this.session.status}"
data-killing="${this.killing}"
>
<div class="w-2 h-2 rounded-full ${this.getStatusDotColor()}"></div>
${this.getStatusText()}
${this.getActivityStatusText()}
${
this.session.status === 'running' && this.isActive
this.session.status === 'running' &&
this.isActive &&
!this.session.activityStatus?.specificStatus
? html`<span class="text-accent-green animate-pulse ml-1">●</span>`
: ''
}
@ -364,6 +365,19 @@ export class SessionCard extends LitElement {
return this.session.status;
}
private getActivityStatusText(): string {
if (this.killing) {
return 'killing...';
}
if (this.session.active === false) {
return 'waiting';
}
if (this.session.status === 'running' && this.session.activityStatus?.specificStatus) {
return this.session.activityStatus.specificStatus.status;
}
return this.session.status;
}
private getStatusColor(): string {
if (this.killing) {
return 'text-status-error';
@ -374,6 +388,19 @@ export class SessionCard extends LitElement {
return this.session.status === 'running' ? 'text-status-success' : 'text-status-warning';
}
private getActivityStatusColor(): string {
if (this.killing) {
return 'text-status-error';
}
if (this.session.active === false) {
return 'text-dark-text-muted';
}
if (this.session.status === 'running' && this.session.activityStatus?.specificStatus) {
return 'text-status-warning';
}
return this.session.status === 'running' ? 'text-status-success' : 'text-status-warning';
}
private getStatusDotColor(): string {
if (this.killing) {
return 'bg-status-error animate-pulse';
@ -381,6 +408,15 @@ export class SessionCard extends LitElement {
if (this.session.active === false) {
return 'bg-dark-text-muted';
}
return this.session.status === 'running' ? 'bg-status-success' : 'bg-status-warning';
if (this.session.status === 'running') {
if (this.session.activityStatus?.specificStatus) {
return 'bg-status-warning animate-pulse'; // Claude active - amber with pulse
} else if (this.session.activityStatus?.isActive || this.isActive) {
return 'bg-status-success'; // Generic active - solid green
} else {
return 'bg-status-success ring-1 ring-status-success ring-opacity-50'; // Idle - green with ring
}
}
return 'bg-status-warning';
}
}

View file

@ -380,16 +380,16 @@ export class SessionCreateForm extends LitElement {
class="modal-content font-mono text-sm w-full max-w-[calc(100vw-1rem)] sm:max-w-md lg:max-w-[576px] mx-2 sm:mx-4"
style="view-transition-name: create-session-modal"
>
<div class="p-4 pb-4 mb-3 border-b border-dark-border relative">
<h2 class="text-accent-green text-lg font-bold">New Session</h2>
<div class="p-6 pb-4 mb-3 border-b border-dark-border relative bg-gradient-to-r from-dark-bg-secondary to-dark-bg-tertiary">
<h2 class="text-primary text-xl font-bold">New Session</h2>
<button
class="absolute top-4 right-4 text-dark-text-muted hover:text-dark-text transition-colors p-1"
class="absolute top-6 right-6 text-dark-text-muted hover:text-dark-text transition-all duration-200 p-2 hover:bg-dark-bg-tertiary rounded-lg"
@click=${this.handleCancel}
title="Close"
title="Close (Esc)"
aria-label="Close modal"
>
<svg
class="w-6 h-6"
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@ -405,10 +405,10 @@ export class SessionCreateForm extends LitElement {
</button>
</div>
<div class="p-3 sm:p-3 lg:p-4">
<div class="p-6">
<!-- Session Name -->
<div class="mb-4">
<label class="form-label">Session Name (Optional):</label>
<div class="mb-5">
<label class="form-label text-dark-text-muted">Session Name (Optional):</label>
<input
type="text"
class="input-field"
@ -420,8 +420,8 @@ export class SessionCreateForm extends LitElement {
</div>
<!-- Command -->
<div class="mb-4">
<label class="form-label">Command:</label>
<div class="mb-5">
<label class="form-label text-dark-text-muted">Command:</label>
<input
type="text"
class="input-field"
@ -433,9 +433,9 @@ export class SessionCreateForm extends LitElement {
</div>
<!-- Working Directory -->
<div class="mb-4">
<label class="form-label">Working Directory:</label>
<div class="flex gap-4">
<div class="mb-5">
<label class="form-label text-dark-text-muted">Working Directory:</label>
<div class="flex gap-2">
<input
type="text"
class="input-field"
@ -445,27 +445,32 @@ export class SessionCreateForm extends LitElement {
?disabled=${this.disabled || this.isCreating}
/>
<button
class="btn-secondary font-mono px-4"
class="bg-dark-bg-elevated border border-dark-border rounded-lg p-3 font-mono text-dark-text-muted transition-all duration-200 hover:text-primary hover:bg-dark-surface-hover hover:border-primary hover:shadow-sm flex-shrink-0"
@click=${this.handleBrowse}
?disabled=${this.disabled || this.isCreating}
title="Browse directories"
>
📁
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path
d="M1.75 1h5.5c.966 0 1.75.784 1.75 1.75v1h4c.966 0 1.75.784 1.75 1.75v7.75A1.75 1.75 0 0113 15H3a1.75 1.75 0 01-1.75-1.75V2.75C1.25 1.784 1.784 1 1.75 1zM2.75 2.5v10.75c0 .138.112.25.25.25h10a.25.25 0 00.25-.25V5.5a.25.25 0 00-.25-.25H8.75v-2.5a.25.25 0 00-.25-.25h-5.5a.25.25 0 00-.25.25z"
/>
</svg>
</button>
</div>
</div>
<!-- Spawn Window Toggle -->
<div class="mb-4 flex items-center justify-between">
<div class="mb-5 flex items-center justify-between bg-dark-bg-elevated border border-dark-border rounded-lg p-4">
<div class="flex-1 pr-4">
<span class="text-dark-text text-sm">Spawn window</span>
<p class="text-xs text-dark-text-muted mt-1">Opens native terminal window</p>
<span class="text-dark-text text-sm font-medium">Spawn window</span>
<p class="text-xs text-dark-text-muted mt-0.5">Opens native terminal window</p>
</div>
<button
role="switch"
aria-checked="${this.spawnWindow}"
@click=${this.handleSpawnWindowChange}
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-accent-green focus:ring-offset-2 focus:ring-offset-dark-bg ${
this.spawnWindow ? 'bg-accent-green' : 'bg-dark-border'
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:ring-offset-dark-bg ${
this.spawnWindow ? 'bg-primary' : 'bg-dark-border'
}"
?disabled=${this.disabled || this.isCreating}
>
@ -478,10 +483,10 @@ export class SessionCreateForm extends LitElement {
</div>
<!-- Terminal Title Mode -->
<div class="mb-4 flex items-center justify-between">
<div class="mb-6 flex items-center justify-between bg-dark-bg-elevated border border-dark-border rounded-lg p-4">
<div class="flex-1 pr-4">
<span class="text-dark-text text-sm">Terminal Title Mode</span>
<p class="text-xs text-dark-text-muted mt-1 opacity-50">
<span class="text-dark-text text-sm font-medium">Terminal Title Mode</span>
<p class="text-xs text-dark-text-muted mt-0.5">
${this.getTitleModeDescription()}
</p>
</div>
@ -489,14 +494,14 @@ export class SessionCreateForm extends LitElement {
<select
.value=${this.titleMode}
@change=${this.handleTitleModeChange}
class="bg-[#1a1a1a] border border-dark-border rounded-lg px-3 py-2 pr-8 text-dark-text text-sm transition-all duration-200 hover:border-accent-green-darker focus:border-accent-green focus:outline-none appearance-none cursor-pointer"
class="bg-dark-bg-secondary border border-dark-border rounded-lg px-3 py-2 pr-8 text-dark-text text-sm transition-all duration-200 hover:border-primary-hover focus:border-primary focus:outline-none appearance-none cursor-pointer"
style="min-width: 140px"
?disabled=${this.disabled || this.isCreating}
>
<option value="${TitleMode.NONE}" class="bg-[#1a1a1a] text-dark-text" ?selected=${this.titleMode === TitleMode.NONE}>None</option>
<option value="${TitleMode.FILTER}" class="bg-[#1a1a1a] text-dark-text" ?selected=${this.titleMode === TitleMode.FILTER}>Filter</option>
<option value="${TitleMode.STATIC}" class="bg-[#1a1a1a] text-dark-text" ?selected=${this.titleMode === TitleMode.STATIC}>Static</option>
<option value="${TitleMode.DYNAMIC}" class="bg-[#1a1a1a] text-dark-text" ?selected=${this.titleMode === TitleMode.DYNAMIC}>Dynamic</option>
<option value="${TitleMode.NONE}" class="bg-dark-bg-secondary text-dark-text" ?selected=${this.titleMode === TitleMode.NONE}>None</option>
<option value="${TitleMode.FILTER}" class="bg-dark-bg-secondary text-dark-text" ?selected=${this.titleMode === TitleMode.FILTER}>Filter</option>
<option value="${TitleMode.STATIC}" class="bg-dark-bg-secondary text-dark-text" ?selected=${this.titleMode === TitleMode.STATIC}>Static</option>
<option value="${TitleMode.DYNAMIC}" class="bg-dark-bg-secondary text-dark-text" ?selected=${this.titleMode === TitleMode.DYNAMIC}>Dynamic</option>
</select>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-dark-text-muted">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -507,8 +512,8 @@ export class SessionCreateForm extends LitElement {
</div>
<!-- Quick Start Section -->
<div class="mb-4">
<label class="form-label text-dark-text-muted uppercase text-xs tracking-wider"
<div class="mb-6">
<label class="form-label text-dark-text-muted uppercase text-xs tracking-wider mb-3"
>Quick Start</label
>
<div class="grid grid-cols-2 gap-3 mt-2">
@ -518,8 +523,8 @@ export class SessionCreateForm extends LitElement {
@click=${() => this.handleQuickStart(command)}
class="${
this.command === command
? 'px-4 py-3 rounded border text-left transition-all bg-accent-green bg-opacity-20 border-accent-green text-accent-green'
: 'px-4 py-3 rounded border text-left transition-all bg-dark-border bg-opacity-10 border-dark-border text-dark-text hover:bg-opacity-20 hover:border-dark-text-secondary'
? 'px-4 py-3 rounded-lg border text-left transition-all bg-primary bg-opacity-10 border-primary text-primary hover:bg-opacity-20 font-medium'
: 'px-4 py-3 rounded-lg border text-left transition-all bg-dark-bg-elevated border-dark-border text-dark-text hover:bg-dark-surface-hover hover:border-primary hover:text-primary'
}"
?disabled=${this.disabled || this.isCreating}
>
@ -532,16 +537,16 @@ export class SessionCreateForm extends LitElement {
</div>
</div>
<div class="flex gap-4 mt-4">
<div class="flex gap-3 mt-6">
<button
class="btn-ghost font-mono flex-1 py-3"
class="flex-1 bg-dark-bg-elevated border border-dark-border text-dark-text px-6 py-3 rounded-lg font-mono text-sm transition-all duration-200 hover:bg-dark-surface-hover hover:border-dark-border-light"
@click=${this.handleCancel}
?disabled=${this.isCreating}
>
Cancel
</button>
<button
class="btn-primary font-mono flex-1 py-3 disabled:opacity-50 disabled:cursor-not-allowed"
class="flex-1 bg-primary text-black px-6 py-3 rounded-lg font-mono text-sm font-medium transition-all duration-200 hover:bg-primary-hover hover:shadow-glow disabled:opacity-50 disabled:cursor-not-allowed"
@click=${this.handleCreate}
?disabled=${
this.disabled ||

View file

@ -148,14 +148,19 @@ export class SessionList extends LitElement {
}
render() {
const filteredSessions = this.hideExited
? this.sessions.filter((session) => session.status !== 'exited')
: this.sessions;
// Group sessions by status
const runningSessions = this.sessions.filter((session) => session.status === 'running');
const exitedSessions = this.sessions.filter((session) => session.status === 'exited');
const hasRunningSessions = runningSessions.length > 0;
const hasExitedSessions = exitedSessions.length > 0;
const showExitedSection = !this.hideExited && hasExitedSessions;
return html`
<div class="font-mono text-sm p-4 bg-black" data-testid="session-list-container">
<div class="font-mono text-sm" data-testid="session-list-container">
<div class="p-4 pt-5">
${
filteredSessions.length === 0
!hasRunningSessions && (!hasExitedSessions || this.hideExited)
? html`
<div class="text-dark-text-muted text-center py-8">
${
@ -229,11 +234,19 @@ export class SessionList extends LitElement {
</div>
`
: html`
<div class="${this.compactMode ? 'space-y-2' : 'session-flex-responsive'}">
${repeat(
filteredSessions,
(session) => session.id,
(session) => html`
<!-- Active Sessions -->
${
hasRunningSessions
? html`
<div class="mb-6">
<h3 class="text-xs font-semibold text-dark-text-muted uppercase tracking-wider mb-4">
Active <span class="text-dark-text-dim">(${runningSessions.length})</span>
</h3>
<div class="${this.compactMode ? 'space-y-2' : 'session-flex-responsive'} relative">
${repeat(
runningSessions,
(session) => session.id,
(session) => html`
${
this.compactMode
? html`
@ -409,10 +422,144 @@ export class SessionList extends LitElement {
`
}
`
)}
</div>
)}
</div>
</div>
`
: ''
}
<!-- Idle/Exited Sessions -->
${
showExitedSection
? html`
<div>
<h3 class="text-xs font-semibold text-dark-text-muted uppercase tracking-wider mb-4">
Idle <span class="text-dark-text-dim">(${exitedSessions.length})</span>
</h3>
<div class="${this.compactMode ? 'space-y-2' : 'session-flex-responsive'} relative">
${repeat(
exitedSessions,
(session) => session.id,
(session) => html`
${
this.compactMode
? html`
<!-- Enhanced compact list item for sidebar -->
<div
class="group flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-all duration-200 animate-fade-in ${
session.id === this.selectedSessionId
? 'bg-dark-bg-elevated border border-accent-primary shadow-card-hover'
: 'bg-dark-bg-secondary border border-dark-border hover:bg-dark-bg-tertiary hover:border-dark-border-light hover:shadow-card opacity-75'
}"
@click=${() =>
this.handleSessionSelect({ detail: session } as CustomEvent)}
>
<!-- Status indicator -->
<div class="relative flex-shrink-0">
<div class="w-2.5 h-2.5 rounded-full bg-status-warning"></div>
</div>
<!-- Elegant divider line -->
<div class="w-px h-8 bg-gradient-to-b from-transparent via-dark-border to-transparent"></div>
<!-- Session content -->
<div class="flex-1 min-w-0">
<div
class="text-sm font-mono truncate ${
session.id === this.selectedSessionId
? 'text-accent-primary font-medium'
: 'text-dark-text-muted group-hover:text-dark-text transition-colors'
}"
title="${
session.name ||
(Array.isArray(session.command)
? session.command.join(' ')
: session.command)
}"
>
${
session.name ||
(Array.isArray(session.command)
? session.command.join(' ')
: session.command)
}
</div>
<div class="text-xs text-dark-text-dim truncate">
${formatPathForDisplay(session.workingDir)}
</div>
</div>
<!-- Right side: duration and close button -->
<div class="relative flex items-center flex-shrink-0">
<!-- Session duration -->
<div class="text-xs text-dark-text-dim font-mono">
${session.startedAt ? formatSessionDuration(session.startedAt) : ''}
</div>
<!-- Clean up button -->
<button
class="btn-ghost text-dark-text-muted p-1.5 rounded-md transition-all ml-2 hover:text-status-warning hover:bg-dark-bg-elevated hover:shadow-sm"
@click=${async (e: Event) => {
e.stopPropagation();
try {
const response = await fetch(
`/api/sessions/${session.id}/cleanup`,
{
method: 'DELETE',
headers: this.authClient.getAuthHeader(),
}
);
if (response.ok) {
this.handleSessionKilled({
detail: { sessionId: session.id },
} as CustomEvent);
}
} catch (error) {
logger.error('Failed to clean up session', error);
}
}}
title="Clean up session"
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
`
: html`
<!-- Full session card for main view -->
<session-card
.session=${session}
.authClient=${this.authClient}
@session-select=${this.handleSessionSelect}
@session-killed=${this.handleSessionKilled}
@session-kill-error=${this.handleSessionKillError}
>
</session-card>
`
}
`
)}
</div>
</div>
`
: ''
}
`
}
</div>
${this.renderExitedControls()}
</div>
@ -427,45 +574,25 @@ export class SessionList extends LitElement {
if (exitedSessions.length === 0 && runningSessions.length === 0) return '';
return html`
<div class="flex flex-col sm:flex-row sm:flex-wrap gap-2 mt-8 pb-4 px-4 w-full">
<!-- First group: Show/Hide Exited and Clean Exited (when visible) -->
<div class="sticky bottom-0 border-t border-dark-border bg-dark-bg-secondary p-3 flex flex-wrap gap-2 shadow-lg">
<!-- Control buttons with consistent styling -->
${
exitedSessions.length > 0
? html`
<div class="flex flex-col gap-2 w-full sm:w-auto">
<!-- Show/Hide Exited button -->
<button
class="font-mono text-xs sm:text-sm px-3 sm:px-6 py-2 rounded-lg border transition-all duration-200 flex-1 sm:flex-none sm:w-auto sm:min-w-[180px] ${
class="font-mono text-xs px-4 py-2 rounded-lg border transition-all duration-200 ${
this.hideExited
? 'border-dark-border bg-dark-bg-secondary text-dark-text-muted hover:bg-dark-bg-tertiary hover:text-dark-text'
: 'border-dark-border bg-dark-bg-tertiary text-dark-text hover:bg-dark-bg-secondary'
? 'border-dark-border bg-dark-bg-elevated text-dark-text-muted hover:bg-dark-surface-hover hover:text-accent-primary hover:border-accent-primary hover:shadow-sm'
: 'border-accent-primary bg-accent-primary bg-opacity-10 text-accent-primary hover:bg-opacity-20 hover:shadow-glow-primary-sm'
}"
@click=${() =>
this.dispatchEvent(
new CustomEvent('hide-exited-change', { detail: !this.hideExited })
)}
>
<div class="flex items-center justify-center gap-2 sm:gap-3">
<span class="hidden sm:inline"
>${this.hideExited ? 'Show' : 'Hide'} Exited (${exitedSessions.length})</span
>
<span class="sm:hidden"
>${this.hideExited ? 'Show' : 'Hide'} (${exitedSessions.length})</span
>
<div
class="w-8 h-4 rounded-full transition-colors duration-200 ${
this.hideExited ? 'bg-dark-surface' : 'bg-dark-bg'
}"
>
<div
class="w-3 h-3 rounded-full transition-transform duration-200 mt-0.5 ${
this.hideExited
? 'translate-x-0.5 bg-dark-text-muted'
: 'translate-x-4 bg-accent-green'
}"
></div>
</div>
</div>
${this.hideExited ? 'Show' : 'Hide'} Exited
<span class="text-dark-text-dim">(${exitedSessions.length})</span>
</button>
<!-- Clean Exited button (only when Show Exited is active) -->
@ -473,23 +600,15 @@ export class SessionList extends LitElement {
!this.hideExited
? html`
<button
class="font-mono text-xs sm:text-sm px-3 sm:px-6 py-2 rounded-lg border transition-all duration-200 flex-1 sm:flex-none sm:w-auto sm:min-w-[120px] border-dark-border bg-dark-bg-secondary text-status-warning hover:bg-dark-bg-tertiary hover:border-status-warning"
class="font-mono text-xs px-4 py-2 rounded-lg border transition-all duration-200 border-status-warning bg-status-warning bg-opacity-10 text-status-warning hover:bg-opacity-20 hover:shadow-glow-warning-sm disabled:opacity-50"
@click=${this.handleCleanupExited}
?disabled=${this.cleaningExited}
>
<span class="hidden sm:inline"
>${
this.cleaningExited
? 'Cleaning...'
: `Clean Exited (${exitedSessions.length})`
}</span
>
<span class="sm:hidden">${this.cleaningExited ? 'Cleaning...' : 'Clean'}</span>
${this.cleaningExited ? 'Cleaning...' : 'Clean Exited'}
</button>
`
: ''
}
</div>
`
: ''
}
@ -499,10 +618,10 @@ export class SessionList extends LitElement {
runningSessions.length > 0
? html`
<button
class="font-mono text-xs sm:text-sm px-3 sm:px-6 py-2 rounded-lg border transition-all duration-200 w-full sm:w-auto sm:min-w-[120px] border-status-error bg-dark-bg-secondary text-status-error hover:bg-dark-bg-tertiary hover:border-status-error"
class="font-mono text-xs px-4 py-2 rounded-md border transition-all duration-200 border-status-error bg-status-error bg-opacity-10 text-status-error hover:bg-opacity-20 ml-auto"
@click=${() => this.dispatchEvent(new CustomEvent('kill-all-sessions'))}
>
Kill All (${runningSessions.length})
Kill All <span class="text-dark-text-dim">(${runningSessions.length})</span>
</button>
`
: ''

View file

@ -761,7 +761,16 @@ export class SessionView extends LitElement {
}
private handleOpenFilePicker() {
this.showImagePicker = true;
if (!this.isMobile) {
// On desktop, directly open the file picker without showing the dialog
const filePicker = this.querySelector('file-picker') as FilePicker | null;
if (filePicker && typeof filePicker.openFilePicker === 'function') {
filePicker.openFilePicker();
}
} else {
// On mobile, show the file picker dialog
this.showImagePicker = true;
}
}
private handleCloseFilePicker() {
@ -1028,12 +1037,12 @@ export class SessionView extends LitElement {
box-shadow: none !important;
}
session-view:focus {
outline: 2px solid #00ff88 !important;
outline: 2px solid rgb(16 185 129) !important;
outline-offset: -2px;
}
</style>
<div
class="flex flex-col bg-black font-mono relative"
class="flex flex-col bg-dark-bg font-mono relative"
style="height: 100vh; height: 100dvh; outline: none !important; box-shadow: none !important;"
>
<!-- Session Header -->
@ -1066,7 +1075,7 @@ export class SessionView extends LitElement {
<!-- Enhanced Terminal Container -->
<div
class="${this.terminalContainerHeight === '100%' ? 'flex-1' : ''} bg-black overflow-hidden min-h-0 relative border-t border-dark-border shadow-inner ${
class="${this.terminalContainerHeight === '100%' ? 'flex-1' : ''} bg-dark-bg overflow-hidden min-h-0 relative ${
this.session?.status === 'exited' ? 'session-exited opacity-90' : ''
}"
id="terminal-container"
@ -1281,12 +1290,23 @@ export class SessionView extends LitElement {
${
this.isDragOver
? html`
<div class="fixed inset-0 bg-black bg-opacity-80 flex items-center justify-center z-50 pointer-events-none">
<div class="bg-dark-bg-secondary border-2 border-dashed border-terminal-green text-terminal-green rounded-lg p-8 text-center">
<div class="text-6xl mb-4">📁</div>
<div class="text-xl font-semibold mb-2">Drop files here</div>
<div class="text-sm opacity-80">Files will be uploaded and the path sent to terminal</div>
<div class="text-xs opacity-60 mt-2">Or press CMD+V to paste from clipboard</div>
<div class="fixed inset-0 bg-black bg-opacity-90 backdrop-blur-sm flex items-center justify-center z-50 pointer-events-none animate-fade-in">
<div class="bg-dark-bg-elevated border-2 border-dashed border-accent-primary rounded-xl p-10 text-center max-w-md mx-4 shadow-2xl animate-scale-in">
<div class="relative mb-6">
<div class="w-24 h-24 mx-auto bg-gradient-to-br from-accent-primary to-accent-primary-light rounded-full flex items-center justify-center shadow-glow-primary">
<svg class="w-12 h-12 text-dark-bg" fill="currentColor" viewBox="0 0 20 20">
<path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"/>
</svg>
</div>
<div class="absolute -bottom-2 left-1/2 transform -translate-x-1/2 w-32 h-1 bg-gradient-to-r from-transparent via-accent-primary to-transparent opacity-50"></div>
</div>
<h3 class="text-2xl font-bold text-dark-text mb-3">Drop files here</h3>
<p class="text-sm text-dark-text-muted mb-4">Files will be uploaded and the path sent to terminal</p>
<div class="inline-flex items-center gap-2 text-xs text-dark-text-dim bg-dark-bg-secondary px-4 py-2 rounded-lg">
<span class="opacity-75">Or press</span>
<kbd class="px-2 py-1 bg-dark-bg-tertiary border border-dark-border rounded text-accent-primary font-mono text-xs">V</kbd>
<span class="opacity-75">to paste from clipboard</span>
</div>
</div>
</div>
`

View file

@ -49,10 +49,10 @@ export class CtrlAlphaOverlay extends LitElement {
style="background: black; border: 1px solid #569cd6; border-radius: 8px; padding: 10px; margin-bottom: ${this.keyboardHeight > 0 ? `${this.keyboardHeight + 180}px` : 'calc(env(keyboard-inset-height, 0px) + 180px)'};/* 180px = estimated quick keyboard height (3 rows) */"
@click=${(e: Event) => e.stopPropagation()}
>
<div class="text-vs-user text-center mb-2 font-bold">Ctrl + Key</div>
<div class="text-primary text-center mb-2 font-bold">Ctrl + Key</div>
<!-- Help text -->
<div class="text-xs text-vs-muted text-center mb-3 opacity-70">
<div class="text-xs text-dark-text-muted text-center mb-3 opacity-70">
Build sequences like ctrl+c ctrl+c
</div>
@ -60,9 +60,9 @@ export class CtrlAlphaOverlay extends LitElement {
${
this.ctrlSequence.length > 0
? html`
<div class="text-center mb-4 p-2 border border-vs-muted rounded bg-vs-bg">
<div class="text-xs text-vs-muted mb-1">Current sequence:</div>
<div class="text-sm text-vs-accent font-bold">
<div class="text-center mb-4 p-2 border border-dark-border rounded bg-dark-bg">
<div class="text-xs text-dark-text-muted mb-1">Current sequence:</div>
<div class="text-sm text-primary font-bold">
${this.ctrlSequence.map((letter) => `Ctrl+${letter}`).join(' ')}
</div>
</div>
@ -112,7 +112,7 @@ export class CtrlAlphaOverlay extends LitElement {
</div>
<!-- Common shortcuts info -->
<div class="text-xs text-vs-muted text-center mb-3">
<div class="text-xs text-dark-text-muted text-center mb-3">
<div>Common: C=interrupt, X=exit, O=save, W=search</div>
</div>

View file

@ -77,8 +77,8 @@ export class SessionHeader extends LitElement {
return html`
<!-- Enhanced Header with gradient background -->
<div
class="flex items-center justify-between border-b border-dark-border text-sm min-w-0 bg-gradient-to-r from-dark-bg-secondary to-dark-bg-tertiary p-4 shadow-sm"
style="padding-top: max(1rem, calc(1rem + env(safe-area-inset-top))); padding-left: max(1rem, env(safe-area-inset-left)); padding-right: max(1rem, env(safe-area-inset-right));"
class="flex items-center justify-between border-b border-dark-border text-sm min-w-0 bg-gradient-to-r from-dark-bg-secondary to-dark-bg-tertiary px-4 py-2 shadow-sm"
style="padding-top: max(0.5rem, env(safe-area-inset-top)); padding-left: max(1rem, env(safe-area-inset-left)); padding-right: max(1rem, env(safe-area-inset-right));"
>
<div class="flex items-center gap-3 min-w-0 flex-1">
<!-- Sidebar Toggle and Create Session Buttons (shown when sidebar is collapsed) -->
@ -118,7 +118,7 @@ export class SessionHeader extends LitElement {
this.showBackButton
? html`
<button
class="btn-secondary font-mono text-xs px-3 py-1 flex-shrink-0"
class="bg-dark-bg-elevated border border-dark-border rounded-lg px-3 py-1.5 font-mono text-xs text-dark-text-muted transition-all duration-200 hover:text-accent-primary hover:bg-dark-surface-hover hover:border-accent-primary hover:shadow-sm flex-shrink-0"
@click=${() => this.onBack?.()}
>
Back

View file

@ -51,29 +51,29 @@ export class WidthSelector extends LitElement {
return html`
<div
class="width-selector-container absolute top-8 right-0 bg-dark-bg-secondary border border-dark-border rounded-md shadow-lg z-50 min-w-48"
class="width-selector-container absolute top-full mt-2 right-0 bg-dark-bg-elevated border border-dark-border rounded-lg shadow-elevated z-50 min-w-[280px] animate-fade-in"
>
<div class="p-2">
<div class="text-xs text-dark-text-muted mb-2 px-2">Terminal Width</div>
<div class="p-4">
<div class="text-sm font-semibold text-dark-text mb-3">Terminal Width</div>
${COMMON_TERMINAL_WIDTHS.map(
(width) => html`
<button
class="w-full text-left px-2 py-1 text-xs hover:bg-dark-border rounded-sm flex justify-between items-center
class="w-full text-left px-3 py-2 text-sm rounded-md flex justify-between items-center transition-all duration-200
${
this.terminalMaxCols === width.value
? 'bg-dark-border text-accent-green'
: 'text-dark-text'
? 'bg-accent-primary bg-opacity-10 text-accent-primary border border-accent-primary'
: 'text-dark-text hover:bg-dark-surface-hover hover:text-dark-text-bright border border-transparent'
}"
@click=${() => this.onWidthSelect?.(width.value)}
>
<span class="font-mono">${width.label}</span>
<span class="text-dark-text-muted text-xs">${width.description}</span>
<span class="font-mono font-medium">${width.label}</span>
<span class="text-dark-text-muted text-xs ml-4">${width.description}</span>
</button>
`
)}
<div class="border-t border-dark-border mt-2 pt-2">
<div class="text-xs text-dark-text-muted mb-1 px-2">Custom (20-500)</div>
<div class="flex gap-1">
<div class="border-t border-dark-border mt-3 pt-3">
<div class="text-sm font-semibold text-dark-text mb-2">Custom (20-500)</div>
<div class="flex gap-2">
<input
type="number"
min="20"
@ -83,10 +83,17 @@ export class WidthSelector extends LitElement {
@input=${this.handleCustomWidthInput}
@keydown=${this.handleCustomWidthKeydown}
@click=${(e: Event) => e.stopPropagation()}
class="flex-1 bg-dark-bg border border-dark-border rounded px-2 py-1 text-xs font-mono text-dark-text"
class="flex-1 bg-dark-bg-secondary border border-dark-border rounded-md px-3 py-2 text-sm font-mono text-dark-text placeholder:text-dark-text-dim focus:border-accent-primary focus:shadow-glow-primary-sm transition-all"
/>
<button
class="btn-secondary text-xs px-2 py-1"
class="px-4 py-2 rounded-md text-sm font-medium transition-all duration-200
${
!this.customWidth ||
Number.parseInt(this.customWidth) < 20 ||
Number.parseInt(this.customWidth) > 500
? 'bg-dark-bg-secondary border border-dark-border text-dark-text-muted cursor-not-allowed'
: 'bg-accent-primary text-dark-bg hover:bg-accent-primary-light active:scale-95'
}"
@click=${this.handleCustomWidthSubmit}
?disabled=${
!this.customWidth ||
@ -98,28 +105,47 @@ export class WidthSelector extends LitElement {
</button>
</div>
</div>
<div class="border-t border-dark-border mt-2 pt-2">
<div class="text-xs text-dark-text-muted mb-2 px-2">Font Size</div>
<div class="flex items-center gap-2 px-2">
<div class="border-t border-dark-border mt-3 pt-3">
<div class="text-sm font-semibold text-dark-text mb-3">Font Size</div>
<div class="flex items-center gap-3">
<button
class="btn-secondary text-xs px-2 py-1"
class="w-10 h-10 rounded-md border transition-all duration-200 flex items-center justify-center
${
this.terminalFontSize <= 8
? 'border-dark-border bg-dark-bg-secondary text-dark-text-muted cursor-not-allowed'
: 'border-dark-border bg-dark-bg-elevated text-dark-text hover:border-accent-primary hover:text-accent-primary active:scale-95'
}"
@click=${() => this.onFontSizeChange?.(this.terminalFontSize - 1)}
?disabled=${this.terminalFontSize <= 8}
>
<svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"/>
</svg>
</button>
<span class="font-mono text-xs text-dark-text min-w-8 text-center">
<span class="font-mono text-lg font-medium text-dark-text min-w-[60px] text-center">
${this.terminalFontSize}px
</span>
<button
class="btn-secondary text-xs px-2 py-1"
class="w-10 h-10 rounded-md border transition-all duration-200 flex items-center justify-center
${
this.terminalFontSize >= 32
? 'border-dark-border bg-dark-bg-secondary text-dark-text-muted cursor-not-allowed'
: 'border-dark-border bg-dark-bg-elevated text-dark-text hover:border-accent-primary hover:text-accent-primary active:scale-95'
}"
@click=${() => this.onFontSizeChange?.(this.terminalFontSize + 1)}
?disabled=${this.terminalFontSize >= 32}
>
+
<svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/>
</svg>
</button>
<button
class="btn-ghost text-xs px-2 py-1 ml-auto"
class="ml-auto px-3 py-2 rounded-md text-sm transition-all duration-200
${
this.terminalFontSize === 14
? 'text-dark-text-muted cursor-not-allowed'
: 'text-dark-text-muted hover:text-dark-text hover:bg-dark-surface-hover'
}"
@click=${() => this.onFontSizeChange?.(14)}
?disabled=${this.terminalFontSize === 14}
>

View file

@ -16,8 +16,8 @@ export class SidebarHeader extends HeaderBase {
return html`
<div
class="app-header sidebar-header bg-dark-bg-secondary border-b border-dark-border p-3"
style="padding-top: max(0.75rem, calc(0.75rem + env(safe-area-inset-top)));"
class="app-header sidebar-header bg-gradient-to-r from-dark-bg-secondary to-dark-bg-tertiary border-b border-dark-border px-4 py-2 shadow-sm"
style="padding-top: max(0.625rem, env(safe-area-inset-top));"
>
<!-- Compact layout for sidebar -->
<div class="flex items-center gap-2">
@ -44,7 +44,7 @@ export class SidebarHeader extends HeaderBase {
<terminal-icon size="20"></terminal-icon>
<div class="min-w-0">
<h1
class="text-sm font-bold text-accent-green font-mono group-hover:underline truncate"
class="text-sm font-bold text-accent-primary font-mono group-hover:underline truncate"
>
VibeTunnel
</h1>
@ -54,8 +54,8 @@ export class SidebarHeader extends HeaderBase {
</div>
</button>
<!-- Action buttons group -->
<div class="flex items-center gap-1 flex-shrink-0">
<!-- Action buttons group with consistent styling -->
<div class="flex items-center gap-2 flex-shrink-0">
<!-- Notification button -->
<notification-status
@open-settings=${() => this.dispatchEvent(new CustomEvent('open-settings'))}
@ -63,7 +63,7 @@ export class SidebarHeader extends HeaderBase {
<!-- File Browser button -->
<button
class="p-2 text-dark-text border border-dark-border hover:border-accent-green hover:text-accent-green rounded-lg transition-all duration-200 flex-shrink-0"
class="p-2 text-dark-text-muted bg-dark-bg-elevated border border-dark-border hover:border-accent-primary hover:text-accent-primary rounded-md transition-all duration-200 flex-shrink-0"
@click=${() => this.dispatchEvent(new CustomEvent('open-file-browser'))}
title="Browse Files (⌘O)"
>
@ -74,11 +74,11 @@ export class SidebarHeader extends HeaderBase {
</svg>
</button>
<!-- Create Session button -->
<!-- Create Session button with primary styling -->
<button
class="p-2 text-accent-green border border-accent-green hover:bg-accent-green hover:text-dark-bg rounded-lg transition-all duration-200 flex-shrink-0"
class="p-2 text-accent-primary bg-accent-primary bg-opacity-10 border border-accent-primary hover:bg-opacity-20 rounded-md transition-all duration-200 flex-shrink-0"
@click=${this.handleCreateSession}
title="Create New Session"
title="Create New Session (⌘K)"
>
<svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor">
<path d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"/>

View file

@ -433,8 +433,8 @@ export class TerminalQuickKeys extends LitElement {
/* The actual bar with buttons */
.quick-keys-bar {
background: rgb(17, 17, 17);
border-top: 1px solid rgb(51, 51, 51);
background: #0a0a0a;
border-top: 1px solid #2a2a2a;
padding: 0.5rem 0.25rem;
/* Prevent iOS from adding its own styling */
-webkit-appearance: none;
@ -455,12 +455,12 @@ export class TerminalQuickKeys extends LitElement {
/* Modifier key styling */
.modifier-key {
background-color: #1a1a1a;
background-color: #141414;
border-color: #444;
}
.modifier-key:hover {
background-color: #2a2a2a;
background-color: #1f1f1f;
}
/* Arrow key styling */
@ -471,23 +471,23 @@ export class TerminalQuickKeys extends LitElement {
/* Combo key styling (like ^C, ^Z) */
.combo-key {
background-color: #1e1e1e;
background-color: #141414;
border-color: #555;
}
.combo-key:hover {
background-color: #2e2e2e;
background-color: #1f1f1f;
}
/* Special key styling (like ABC) */
.special-key {
background-color: rgb(0, 122, 255);
border-color: rgb(0, 122, 255);
background-color: rgb(16, 185, 129);
border-color: rgb(16, 185, 129);
color: white;
}
.special-key:hover {
background-color: rgb(0, 100, 220);
background-color: rgb(5, 150, 105);
}
/* Function key styling */
@ -501,22 +501,22 @@ export class TerminalQuickKeys extends LitElement {
/* Toggle button styling */
.toggle-key {
background-color: #2a2a2a;
background-color: #1f1f1f;
border-color: #666;
}
.toggle-key:hover {
background-color: #3a3a3a;
background-color: #2a2a2a;
}
.toggle-key.active {
background-color: rgb(0, 122, 255);
border-color: rgb(0, 122, 255);
background-color: rgb(16, 185, 129);
border-color: rgb(16, 185, 129);
color: white;
}
.toggle-key.active:hover {
background-color: rgb(0, 100, 220);
background-color: rgb(5, 150, 105);
}
/* Ctrl shortcut button styling */

View file

@ -79,6 +79,9 @@ describe('Terminal', () => {
// Now terminal should be created
const terminal = (element as unknown as { terminal: MockTerminal }).terminal;
expect(terminal).toBeDefined();
// Should call scrollToTop on initialization
expect(terminal.scrollToTop).toHaveBeenCalled();
});
it('should handle custom dimensions', async () => {

View file

@ -238,6 +238,12 @@ export class Terminal extends LitElement {
this.setupResize();
this.setupScrolling();
// Ensure terminal starts at the top
this.viewportY = 0;
if (this.terminal) {
this.terminal.scrollToTop();
}
this.requestUpdate();
} catch (error: unknown) {
logger.error('failed to initialize terminal:', error);
@ -283,7 +289,7 @@ export class Terminal extends LitElement {
theme: {
background: '#1e1e1e',
foreground: '#d4d4d4',
cursor: '#00ff00',
cursor: '#10B981',
cursorAccent: '#1e1e1e',
// Standard 16 colors (0-15) - using proper xterm colors
black: '#000000',
@ -938,7 +944,7 @@ export class Terminal extends LitElement {
// Apply cursor styling after inverse to ensure it takes precedence
if (isCursor) {
style += `background-color: #23d18b;`;
style += `background-color: #10B981;`;
}
// Handle invisible text

View file

@ -102,7 +102,7 @@ export class VibeLogo extends LitElement {
<div class="font-mono text-sm select-none leading-tight text-center">
<pre
class="whitespace-pre"
>${coloredLeft} <span class="text-vs-user">VibeTunnel</span> ${coloredRight}</pre>
>${coloredLeft} <span class="text-primary">VibeTunnel</span> ${coloredRight}</pre>
</div>
`;
}

View file

@ -40,7 +40,7 @@
/* Default focus styles */
:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(0, 255, 136, 0.3);
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.3);
}
/* iOS Safe Area Support */
@ -65,45 +65,84 @@
@layer components {
/* Glowing terminal icon */
.terminal-icon {
@apply text-accent-green;
filter: drop-shadow(0 0 10px rgba(0, 255, 136, 0.6));
@apply text-primary;
filter: drop-shadow(0 0 10px rgba(16, 185, 129, 0.6));
}
/* Input fields with glow effect */
/* Input fields with unified style */
.input-field {
@apply bg-[#121212] border border-dark-border rounded-lg px-4 py-3 text-dark-text w-full;
@apply bg-dark-bg-secondary border border-dark-border rounded-lg px-4 py-3 text-dark-text w-full;
@apply transition-all duration-200 ease-in-out;
@apply hover:border-accent-green-darker focus:border-accent-green;
@apply focus:shadow-glow-green-sm;
@apply text-center placeholder:text-center;
@apply hover:border-primary/50 focus:border-primary;
@apply focus:shadow-glow-sm focus:outline-none;
@apply text-left placeholder:text-dark-text-muted;
}
/* Unified button styles */
.btn {
@apply font-medium rounded-lg transition-all duration-200 ease-in-out;
@apply focus:outline-none focus:ring-2 focus:ring-primary/50;
@apply disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-sm {
@apply btn px-3 py-1.5 text-sm;
}
.btn-md {
@apply btn px-4 py-2;
}
.btn-lg {
@apply btn px-6 py-3 text-lg;
}
/* Button styles */
.btn-primary {
@apply bg-accent-green text-dark-bg font-medium px-6 py-3 rounded-lg;
@apply transition-all duration-200 ease-in-out;
@apply hover:bg-accent-green-light hover:shadow-glow-green;
@apply btn-md bg-primary text-black;
@apply hover:bg-primary-hover hover:shadow-glow;
@apply active:scale-95;
}
.btn-secondary {
@apply border border-accent-green text-accent-green px-6 py-3 rounded-lg;
@apply transition-all duration-200 ease-in-out;
@apply hover:bg-accent-green hover:text-dark-bg hover:shadow-glow-green;
@apply btn-md border border-primary text-primary;
@apply hover:bg-primary/10 hover:border-primary-hover hover:shadow-glow-sm;
@apply active:scale-95;
}
.btn-ghost {
@apply text-dark-text-muted px-4 py-2 rounded-lg;
@apply transition-all duration-200 ease-in-out;
@apply btn-md text-dark-text-muted;
@apply hover:text-dark-text hover:bg-dark-bg-tertiary;
}
/* Enhanced card styles */
.card {
@apply bg-dark-bg-secondary border border-dark-border rounded-lg p-0;
@apply transition-all duration-200 ease-in-out shadow-sm;
@apply hover:bg-dark-bg-tertiary hover:border-dark-border-light hover:shadow-card-hover;
@apply transition-all duration-300 ease-out shadow-sm;
@apply hover:border-dark-border-light hover:shadow-lg hover:-translate-y-1;
position: relative;
overflow: hidden;
/* Ensure cards have proper z-index for hover */
z-index: 1;
}
.card:hover {
z-index: 10;
}
/* Card glow effect for active sessions */
.card::before {
content: '';
position: absolute;
inset: -2px;
background: radial-gradient(circle at 50% 50%, transparent 60%, rgba(16, 185, 129, 0.1) 100%);
opacity: 0;
transition: opacity 0.3s ease-out;
pointer-events: none;
z-index: -1;
}
.card:hover::before {
opacity: 1;
}
.card-elevated {
@ -118,7 +157,7 @@
}
.status-badge-success {
@apply bg-accent-green bg-opacity-20 text-accent-green;
@apply bg-status-success bg-opacity-20 text-status-success;
}
.status-badge-warning {
@ -137,12 +176,12 @@
.quick-start-btn {
@apply bg-dark-bg-tertiary border border-dark-border rounded-lg px-4 py-3 h-12;
@apply transition-all duration-200 ease-in-out text-dark-text-muted;
@apply hover:border-accent-primary hover:text-accent-primary hover:shadow-glow-primary-sm;
@apply hover:border-primary hover:text-primary hover:shadow-glow-sm;
@apply active:scale-95;
}
.quick-start-btn.active {
@apply bg-accent-primary text-dark-bg border-accent-primary shadow-glow-primary-sm;
@apply bg-primary text-black border-primary shadow-glow-sm;
}
/* Modal backdrop */
@ -168,14 +207,15 @@
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
grid-auto-rows: 400px;
gap: 1.25rem;
padding: 0 0.75rem;
padding: 0.5rem 0.75rem;
/* Enable smooth grid transitions */
transition: grid-template-columns 0.3s ease-out;
}
.session-flex-responsive > * {
height: 100%;
overflow: hidden;
/* Allow overflow for hover effects */
overflow: visible;
}
@media (max-width: 420px) {
@ -255,6 +295,16 @@
}
}
/* Width selector specific styles */
.width-selector-container {
@apply backdrop-blur-sm;
}
/* Ensure width selector button doesn't get clipped */
.width-selector-button {
position: relative;
}
/* Micro-interactions and animations */
@layer utilities {
/* Smooth transitions for interactive elements */
@ -280,17 +330,21 @@
}
/* Hover glow effect */
.hover-glow-primary:hover {
@apply shadow-glow-primary;
.hover-glow:hover {
@apply shadow-glow;
}
.hover-glow-green:hover {
@apply shadow-glow-green;
.hover-glow-sm:hover {
@apply shadow-glow-sm;
}
.hover-glow-lg:hover {
@apply shadow-glow-lg;
}
/* Focus within styles */
.focus-within-glow:focus-within {
@apply shadow-glow-primary-sm ring-2 ring-accent-primary ring-opacity-50;
@apply shadow-glow-sm ring-2 ring-primary ring-opacity-50;
}
/* Smooth color transitions */
@ -714,11 +768,8 @@ body.initial-session-load .session-flex-responsive > session-card:nth-child(n +
/* Enhanced terminal container styling */
.terminal-container {
color: #e4e4e4;
white-space: pre;
overflow: hidden;
background-color: #0a0a0a;
border-radius: 0.5rem;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.5);
}
/* Terminal line styling */
@ -773,7 +824,7 @@ body.initial-session-load .session-flex-responsive > session-card:nth-child(n +
/* Enhanced cursor styling */
.terminal-char.cursor {
animation: cursor-blink 1s infinite;
background-color: #00D9FF;
background-color: #10B981;
color: #0a0a0a;
}
@ -781,12 +832,12 @@ body.initial-session-load .session-flex-responsive > session-card:nth-child(n +
0%,
50% {
opacity: 1;
background-color: #00D9FF;
background-color: #10B981;
}
51%,
100% {
opacity: 0.4;
background-color: #00D9FF;
background-color: #10B981;
}
}
@ -804,8 +855,8 @@ body.initial-session-load .session-flex-responsive > session-card:nth-child(n +
/* Enhanced terminal focus indicator */
.terminal-focused {
box-shadow: 0 0 0 2px #00D9FF, 0 0 20px rgba(0, 217, 255, 0.3);
border-color: #00D9FF !important;
box-shadow: 0 0 0 2px #10B981, 0 0 20px rgba(16, 185, 129, 0.3);
border-color: #10B981 !important;
transition: all 0.2s ease-out;
}
@ -814,17 +865,17 @@ body.initial-session-load .session-flex-responsive > session-card:nth-child(n +
position: fixed;
top: 10px;
right: 10px;
background: rgba(0, 217, 255, 0.1);
background: rgba(16, 185, 129, 0.1);
backdrop-filter: blur(8px);
border: 1px solid #00D9FF;
color: #00D9FF;
border: 1px solid #10B981;
color: #10B981;
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
z-index: 1000;
animation: slideInFromTop 0.3s ease-out;
box-shadow: 0 2px 8px rgba(0, 217, 255, 0.2);
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.2);
}
@keyframes slideInFromTop {
@ -1161,8 +1212,8 @@ body.initial-session-load .session-flex-responsive > session-card:nth-child(n +
/* Hover state for both buttons */
.scroll-to-bottom:hover,
.keyboard-button:hover {
@apply bg-dark-bg/40 border-accent-green text-accent-green;
@apply -translate-y-0.5 shadow-glow-green-sm;
@apply bg-dark-bg/40 border-primary text-primary;
@apply -translate-y-0.5 shadow-glow-sm;
backdrop-filter: blur(16px) saturate(1.8);
-webkit-backdrop-filter: blur(16px) saturate(1.8);
}
@ -1228,10 +1279,10 @@ body.initial-session-load .session-flex-responsive > session-card:nth-child(n +
.fit-toggle:hover {
background: rgba(26, 26, 26, 1);
border-color: #00ff88;
color: #00ff88;
border-color: #10B981;
color: #10B981;
transform: translateY(-1px);
box-shadow: 0 0 10px rgba(0, 255, 136, 0.3);
box-shadow: 0 0 10px rgba(16, 185, 129, 0.3);
}
.fit-toggle:active {
@ -1239,9 +1290,9 @@ body.initial-session-load .session-flex-responsive > session-card:nth-child(n +
}
.fit-toggle.active {
border-color: #00ff88;
color: #00ff88;
box-shadow: 0 0 10px rgba(0, 255, 136, 0.3);
border-color: #10B981;
color: #10B981;
box-shadow: 0 0 10px rgba(16, 185, 129, 0.3);
}
/* View Transitions */

View file

@ -4,7 +4,7 @@ module.exports = {
theme: {
extend: {
colors: {
// Enhanced Dark theme colors with better depth
// Unified Dark theme colors with consistent depth
"dark-bg": "#0a0a0a",
"dark-bg-secondary": "#141414",
"dark-bg-tertiary": "#1f1f1f",
@ -15,64 +15,52 @@ module.exports = {
"dark-border-light": "#3a3a3a",
"dark-border-focus": "#4a4a4a",
// Enhanced Text colors
// Text colors
"dark-text": "#e4e4e4",
"dark-text-bright": "#ffffff",
"dark-text-muted": "#888888",
"dark-text-dim": "#666666",
"dark-text-muted": "#a3a3a3",
"dark-text-dim": "#737373",
// Modern accent colors - Cyan/Teal primary
"accent-primary": "#00D9FF",
"accent-primary-dark": "#00B8E6",
"accent-primary-darker": "#0096CC",
"accent-primary-light": "#33E1FF",
"accent-primary-glow": "#00D9FF66",
// Unified accent color - Vibrant teal-green
"primary": "#10B981",
"primary-hover": "#059669",
"primary-dark": "#047857",
"primary-light": "#34D399",
"primary-muted": "#10B98133",
"primary-glow": "#10B98166",
// Green accent colors (success/active)
"accent-green": "#4ADE80",
"accent-green-dark": "#22C55E",
"accent-green-darker": "#16A34A",
"accent-green-light": "#86EFAC",
"accent-green-glow": "#4ADE8066",
// Status colors
"status-error": "#EF4444",
"status-warning": "#F59E0B",
"status-success": "#10B981",
"status-info": "#3B82F6",
// Secondary accent colors
"accent-purple": "#A78BFA",
"accent-blue": "#60A5FA",
"accent-amber": "#FFA726",
// Enhanced Status colors
"status-error": "#FF6B6B",
"status-warning": "#FFA726",
"status-success": "#4ADE80",
"status-info": "#60A5FA",
// Legacy VS Code theme colors (for compatibility)
"vs-bg": "#0a0a0a",
"vs-text": "#e4e4e4",
"vs-muted": "#7a7a7a",
"vs-accent": "#00ff88",
"vs-user": "#00ff88",
"vs-assistant": "#00ccaa",
"vs-warning": "#ffaa44",
"vs-function": "#44ffaa",
"vs-type": "#00ffcc",
"vs-border": "#2a2a2a",
"vs-border-light": "#3a3a3a",
"vs-bg-secondary": "#1a1a1a",
"vs-nav": "#1a1a1a",
"vs-nav-hover": "#242424",
"vs-nav-active": "#00ff88",
"vs-highlight": "#8b6914",
// Legacy mappings for gradual migration
"accent-primary": "#10B981",
"accent-primary-dark": "#059669",
"accent-primary-darker": "#047857",
"accent-primary-light": "#34D399",
"accent-primary-glow": "#10B98166",
"accent-green": "#10B981",
"accent-green-dark": "#059669",
"accent-green-darker": "#047857",
"accent-green-light": "#34D399",
"accent-green-glow": "#10B98166",
},
boxShadow: {
// Updated glow effects with new colors
'glow-primary': '0 0 20px rgba(0, 217, 255, 0.4)',
'glow-primary-sm': '0 0 10px rgba(0, 217, 255, 0.3)',
'glow-primary-lg': '0 0 30px rgba(0, 217, 255, 0.5)',
'glow-green': '0 0 20px rgba(74, 222, 128, 0.4)',
'glow-green-sm': '0 0 10px rgba(74, 222, 128, 0.3)',
'glow-green-lg': '0 0 30px rgba(74, 222, 128, 0.5)',
// New subtle shadows
// Unified glow effects with primary color
'glow': '0 0 20px rgba(16, 185, 129, 0.4)',
'glow-sm': '0 0 10px rgba(16, 185, 129, 0.3)',
'glow-lg': '0 0 30px rgba(16, 185, 129, 0.5)',
'glow-intense': '0 0 40px rgba(16, 185, 129, 0.6)',
// Legacy mappings
'glow-primary': '0 0 20px rgba(16, 185, 129, 0.4)',
'glow-primary-sm': '0 0 10px rgba(16, 185, 129, 0.3)',
'glow-primary-lg': '0 0 30px rgba(16, 185, 129, 0.5)',
'glow-green': '0 0 20px rgba(16, 185, 129, 0.4)',
'glow-green-sm': '0 0 10px rgba(16, 185, 129, 0.3)',
'glow-green-lg': '0 0 30px rgba(16, 185, 129, 0.5)',
// Subtle shadows for depth
'card': '0 1px 3px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.4)',
'card-hover': '0 4px 6px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(0, 0, 0, 0.4)',
'elevated': '0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -2px rgba(0, 0, 0, 0.2)',