diff --git a/package.json b/package.json
index dbae9a1..3f9b4a5 100644
--- a/package.json
+++ b/package.json
@@ -26,6 +26,7 @@
"@threlte/core": "^8.3.0",
"@types/three": "^0.181.0",
"cors": "^2.8.5",
+ "discord.js": "^14.25.1",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"hotkeys-js": "^4.0.0-beta.7",
diff --git a/src/lib/assets/css/navbar-waybar.css b/src/lib/assets/css/navbar-waybar.css
index fe033ab..67ffb7d 100644
--- a/src/lib/assets/css/navbar-waybar.css
+++ b/src/lib/assets/css/navbar-waybar.css
@@ -110,7 +110,7 @@
/* Workspaces */
.workspaces {
- gap: 0.25rem;
+ gap: 0.15rem;
background: transparent;
padding: 0;
}
@@ -118,7 +118,7 @@
.workspace {
display: flex;
align-items: center;
- gap: 0.35rem;
+ gap: 0.20rem;
padding: 0.35rem 0.4rem;
color: var(--bar-muted);
text-decoration: none;
@@ -413,3 +413,396 @@
.mobile-mode-btn:active {
transform: scale(0.98);
}
+
+/* Battery styles */
+.module.battery {
+ background: transparent;
+}
+
+.battery-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.25rem 0.5rem;
+ background: var(--bar-bg-module);
+ border: 1px solid var(--bar-border);
+ border-radius: 6px;
+ color: var(--bar-text);
+ cursor: default;
+ font-family: inherit;
+ font-size: 0.8rem;
+ transition: all 0.12s ease;
+}
+
+.battery-btn:hover {
+ background: color-mix(in srgb, var(--bar-primary) 12%, transparent);
+ border-color: var(--bar-primary);
+}
+
+.battery-level {
+ font-weight: 600;
+ color: var(--bar-text);
+}
+
+.battery-alert {
+ color: var(--bar-warning);
+}
+
+/* Low battery visuals */
+.battery-btn.low {
+ border-color: color-mix(in srgb, var(--bar-warning) 60%, var(--bar-border));
+ /* animation: battery-pulse 1.6s infinite ease-in-out; */
+ box-shadow: 0 0 0 0 rgba(0,0,0,0);
+}
+
+@keyframes battery-pulse {
+ 0% { box-shadow: 0 0 0 0 rgba(0,0,0,0); }
+ 50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--bar-warning) 12%, transparent); }
+ 100% { box-shadow: 0 0 0 0 rgba(0,0,0,0); }
+}
+
+/* Charging visuals (green) - overrides low state */
+.battery-btn.charging {
+ border-color: color-mix(in srgb, var(--bar-success) 60%, var(--bar-border));
+ background: color-mix(in srgb, var(--bar-success) 8%, var(--bar-bg-module));
+ animation: none;
+}
+
+.battery-level.charging {
+ color: var(--bar-success);
+}
+
+/* Discord status module */
+.module.discord-status {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.25rem 0.5rem;
+ background: var(--bar-bg-module);
+ border: 1px solid var(--bar-border);
+ border-radius: 6px;
+ color: var(--bar-text);
+ font-size: 0.85rem;
+}
+
+.discord-status .status-dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ display: inline-block;
+ background: var(--bar-muted);
+ box-shadow: 0 0 0 2px rgba(0,0,0,0.04) inset;
+}
+
+.discord-status .status-dot.online { background: var(--bar-success); }
+.discord-status .status-dot.idle { background: var(--bar-warning); }
+.discord-status .status-dot.dnd { background: var(--bar-error); }
+.discord-status .status-dot.offline { background: var(--bar-muted); opacity: 0.7; }
+
+.discord-status .status-text {
+ color: var(--bar-text);
+ font-weight: 600;
+ font-size: 0.85rem;
+}
+
+/* Keep the status on a single line and truncate if too long */
+.module.discord-status {
+ min-width: 0;
+}
+
+.discord-status .status-text {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ display: inline-block;
+ max-width: 9rem; /* desktop default */
+ vertical-align: middle;
+}
+
+@media (max-width: 768px) {
+ /* tighter max width on mobile to keep everything on one line */
+ .discord-status .status-text {
+ max-width: 6rem;
+ }
+ .module.discord-status {
+ padding: 0.2rem 0.45rem;
+ }
+}
+
+/* Discord status button */
+.discord-status-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0;
+ background: transparent;
+ border: none;
+ color: inherit;
+ font-family: inherit;
+ font-size: inherit;
+ cursor: pointer;
+ transition: opacity 0.15s ease;
+}
+
+.discord-status-btn:hover {
+ opacity: 0.85;
+}
+
+/* Discord Popover */
+.module.discord-status {
+ position: relative;
+}
+
+.discord-popover {
+ position: absolute;
+ top: calc(100% + 0.5rem);
+ right: -0.5rem;
+ width: 320px;
+ background: var(--bar-bg);
+ border: 1px solid var(--bar-border);
+ border-radius: 12px;
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
+ z-index: 1002;
+ overflow: hidden;
+}
+
+.popover-header {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 1rem;
+ background: color-mix(in srgb, var(--bar-primary) 8%, var(--bar-bg));
+}
+
+.popover-avatar {
+ position: relative;
+ flex-shrink: 0;
+ color: var(--bar-muted);
+ width: 48px;
+ height: 48px;
+}
+
+.popover-avatar .avatar-img {
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
+ object-fit: cover;
+}
+
+.popover-status-dot {
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ border: 3px solid color-mix(in srgb, var(--bar-primary) 8%, var(--bar-bg));
+ background: var(--bar-muted);
+}
+
+.popover-status-dot.online { background: var(--bar-success); }
+.popover-status-dot.idle { background: var(--bar-warning); }
+.popover-status-dot.dnd { background: var(--bar-error); }
+.popover-status-dot.offline { background: var(--bar-muted); opacity: 0.7; }
+
+.popover-user-info {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 0.15rem;
+ min-width: 0;
+}
+
+.popover-name-row {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ min-width: 0;
+}
+
+.popover-username {
+ font-weight: 700;
+ font-size: 1rem;
+ color: var(--bar-text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.popover-name-row {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ min-width: 0;
+}
+
+.popover-username {
+ font-weight: 700;
+ font-size: 1rem;
+ color: var(--bar-text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: calc(100% - 92px);
+}
+
+.popover-clan-tag {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ height: 20px;
+ padding: 0 0.5rem;
+ background: color-mix(in srgb, var(--bar-primary) 15%, transparent);
+ border: 1px solid color-mix(in srgb, var(--bar-primary) 30%, transparent);
+ border-radius: 4px;
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: var(--bar-primary);
+ text-transform: uppercase;
+ letter-spacing: 0.02em;
+ line-height: 1;
+ flex-shrink: 0;
+}
+
+/* .popover-clan-badge {
+ width: 36px;
+ height: 20px;
+ object-fit: contain;
+ border-radius: 4px;
+ flex-shrink: 0;
+ background: transparent;
+ border: none;
+ box-shadow: none;
+} */
+
+.popover-clan-tag {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ height: 22px; /* slightly taller to match larger text */
+ padding: 0 0.6rem;
+ background: color-mix(in srgb, var(--bar-primary) 12%, transparent);
+ border-radius: 4px;
+ font-size: 0.9rem; /* a bit larger than the badge */
+ font-weight: 700;
+ color: var(--bar-primary);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ line-height: 1;
+ flex-shrink: 0;
+ margin-left: 0.25rem;
+}
+
+.popover-clan-badge {
+ width: 15px; /* slightly smaller than tag text */
+ height: 15px;
+ object-fit: contain;
+ border-radius: 4px;
+ flex-shrink: 0;
+ background: transparent;
+ border: none;
+ box-shadow: none;
+ margin-right: 0.35rem;
+}
+
+/* Activities section */
+.popover-activities {
+ padding: 0.75rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.activity-card {
+ background: var(--bar-bg-module);
+ border-radius: 8px;
+ padding: 0.75rem;
+}
+
+.activity-type-label {
+ font-size: 0.65rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--bar-muted);
+ margin-bottom: 0.5rem;
+ font-weight: 600;
+}
+
+.activity-content {
+ display: flex;
+ gap: 0.75rem;
+}
+
+.activity-images {
+ position: relative;
+ flex-shrink: 0;
+ width: 60px;
+ height: 60px;
+}
+
+.activity-large-image {
+ width: 60px;
+ height: 60px;
+ border-radius: 8px;
+ object-fit: cover;
+}
+
+.activity-small-image {
+ position: absolute;
+ bottom: -4px;
+ right: -4px;
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ border: 3px solid var(--bar-bg-module);
+ object-fit: cover;
+}
+
+.activity-info {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+ min-width: 0;
+ justify-content: center;
+}
+
+.activity-name {
+ font-weight: 600;
+ font-size: 0.85rem;
+ color: var(--bar-text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.activity-details {
+ font-size: 0.75rem;
+ color: var(--bar-muted);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+/* No activity state */
+.popover-no-activity {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 1.5rem;
+ color: var(--bar-muted);
+ font-size: 0.8rem;
+}
+
+/* Mobile adjustments for popover */
+@media (max-width: 768px) {
+ .discord-popover {
+ position: fixed;
+ top: var(--navbar-height);
+ right: 0.5rem;
+ left: 0.5rem;
+ width: auto;
+ }
+}
+
diff --git a/src/lib/assets/css/tui-body.css b/src/lib/assets/css/tui-body.css
index d0cdce9..b8c3f64 100644
--- a/src/lib/assets/css/tui-body.css
+++ b/src/lib/assets/css/tui-body.css
@@ -246,3 +246,33 @@
.tui-body::-webkit-scrollbar-thumb:hover {
background: var(--terminal-muted);
}
+
+/* Mobile: stack inline groups vertically */
+@media (max-width: 768px) {
+ .tui-inline-group {
+ flex-direction: column;
+ flex-wrap: nowrap;
+ align-items: stretch;
+ }
+
+ .tui-inline-group:has(.inline-image) {
+ flex-wrap: nowrap;
+ flex-direction: column;
+ }
+
+ .tui-inline-group .inline-image img {
+ max-width: 100% !important;
+ width: 100%;
+ }
+
+ /* TuiGroup inline on mobile should stack */
+ .tui-group.inline {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .tui-group.inline .inline-image img {
+ max-width: 100% !important;
+ width: 100%;
+ }
+}
diff --git a/src/lib/assets/css/tui-button.css b/src/lib/assets/css/tui-button.css
index 071726f..9d6283c 100644
--- a/src/lib/assets/css/tui-button.css
+++ b/src/lib/assets/css/tui-button.css
@@ -58,3 +58,11 @@
vertical-align: middle;
margin: 0 0.15em;
}
+
+/* Mobile: inline buttons become full-width */
+@media (max-width: 768px) {
+ .tui-button.inline {
+ width: 100%;
+ display: flex;
+ }
+}
diff --git a/src/lib/assets/css/tui-card-grid.css b/src/lib/assets/css/tui-card-grid.css
index 2e780e3..acd9a64 100644
--- a/src/lib/assets/css/tui-card-grid.css
+++ b/src/lib/assets/css/tui-card-grid.css
@@ -164,7 +164,7 @@
text-transform: lowercase;
}
-.warning {
+.tui-card-grid .warning {
font-size: 0.6rem;
color: #f9e2af;
padding: 0.2rem 0.35rem;
diff --git a/src/lib/components/NavbarWaybar.svelte b/src/lib/components/NavbarWaybar.svelte
index 21379d7..20ea776 100644
--- a/src/lib/components/NavbarWaybar.svelte
+++ b/src/lib/components/NavbarWaybar.svelte
@@ -12,6 +12,32 @@
let mobileMenuOpen = $state(false);
let currentTime = $state(new Date());
+ // Discord presence from layout data
+ let discordStatus = $state('offline');
+ let discordDisplayName: string | null = $state(null);
+ let discordActivities: any[] = $state([]);
+ let discordAvatarUrl: string | null = $state(null);
+ let discordGuildTag: string | null = $state(null);
+ let guildTagBadgeImage: string | null = $state(null);
+ let discordPopoverOpen = $state(false);
+
+ $effect(() => {
+ discordStatus = $page.data?.status ?? 'offline';
+ discordDisplayName = $page.data?.displayName ?? null;
+ discordAvatarUrl = $page.data?.avatarUrl ?? null;
+ discordGuildTag = $page.data?.guildTag ?? null;
+ guildTagBadgeImage = $page.data?.guildTagBadgeImage ?? null;
+ // Filter out Custom Status (type 4)
+ discordActivities = ($page.data?.activities ?? []).filter((a: any) => a.type !== 4);
+ });
+
+ // Derived: first displayable activity (if any)
+ let currentActivity = $derived(discordActivities.length > 0 ? discordActivities[0] : null);
+
+ // Battery state (computed from EST time)
+ let batteryLevel = $state(50);
+ let batteryCharging = $state(false);
+
// Derived values (Svelte 5 runes)
let currentPath = $derived($page.url.pathname);
@@ -22,6 +48,51 @@
// Update time every second
timeInterval = setInterval(() => {
currentTime = new Date();
+
+ // Compute battery using America/New_York timezone (DST-aware)
+ function zoneEpochFromMs(ms: number, timeZone: string) {
+ const date = new Date(ms);
+ const fmt = new Intl.DateTimeFormat('en-US', {
+ timeZone,
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ hour12: false
+ });
+ const parts = fmt.formatToParts(date).reduce((acc: any, p: any) => {
+ acc[p.type] = p.value;
+ return acc;
+ }, {});
+ const iso = `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}Z`;
+ return Date.parse(iso);
+ }
+
+ function computeBatteryFromMs(ms: number) {
+ const dischargeMs = 5 * 3600 * 1000; // 5 hours
+ const chargeMs = 0.5 * 3600 * 1000; // 30 minutes
+ const cycleMs = dischargeMs + chargeMs; // 5.5 hours
+ // position in cycle using America/New_York wall-clock epoch (DST-aware)
+ const estMs = zoneEpochFromMs(ms, 'America/New_York');
+ let pos = estMs % cycleMs;
+ if (pos < 0) pos += cycleMs;
+ if (pos < dischargeMs) {
+ // discharging: 100 -> 0
+ const pct = 100 - (pos / dischargeMs) * 100;
+ return { level: Math.max(0, Math.min(100, Math.round(pct))), charging: false };
+ } else {
+ // charging: 0 -> 100
+ const chargePos = pos - dischargeMs;
+ const pct = (chargePos / chargeMs) * 100;
+ return { level: Math.max(0, Math.min(100, Math.round(pct))), charging: true };
+ }
+ }
+
+ const { level, charging } = computeBatteryFromMs(Date.now());
+ batteryLevel = level;
+ batteryCharging = charging;
}, 1000);
});
@@ -72,6 +143,22 @@
if (event.key === 'Escape') {
themeDropdownOpen = false;
mobileMenuOpen = false;
+ discordPopoverOpen = false;
+ }
+ }
+
+ function toggleDiscordPopover() {
+ discordPopoverOpen = !discordPopoverOpen;
+ }
+
+ function getActivityTypeLabel(type: number): string {
+ switch (type) {
+ case 0: return 'Playing';
+ case 1: return 'Streaming';
+ case 2: return 'Listening to';
+ case 3: return 'Watching';
+ case 5: return 'Competing in';
+ default: return '';
}
}
@@ -184,6 +271,123 @@
+
+
+
+
+ {#if discordPopoverOpen}
+
+
+
+
+
+
+
+
+ {#if discordActivities.length > 0}
+
+ {#each discordActivities as activity}
+
+
{getActivityTypeLabel(activity.type)}
+
+ {#if activity.assets?.large_image}
+
+

+ {#if activity.assets?.small_image}
+

+ {/if}
+
+ {/if}
+
+ {activity.name}
+ {#if activity.details}
+ {activity.details}
+ {/if}
+
+
+
+ {/each}
+
+ {:else}
+
+
+ No current activity
+
+ {/if}
+
+ {/if}
+
+
+
+
+
+
+
-
- {#if themeDropdownOpen}
+
+ {#if themeDropdownOpen || discordPopoverOpen}
{/if}
@@ -320,10 +524,9 @@
class="mobile-theme-btn"
class:active={$colorTheme === option.value}
onclick={() => { handleThemeSelect(option.value); closeMobileMenu(); }}
- title={`Press T+${i+1} to switch to ${option.label}`}
>
-
{i+1}. {option.label}
+
{option.label} [{i+1}]
{/each}
diff --git a/src/lib/components/tui/TuiBody.svelte b/src/lib/components/tui/TuiBody.svelte
index ac71631..acb20f7 100644
--- a/src/lib/components/tui/TuiBody.svelte
+++ b/src/lib/components/tui/TuiBody.svelte
@@ -16,6 +16,7 @@
import TuiRadio from './TuiRadio.svelte';
import TuiSelect from './TuiSelect.svelte';
import TuiToggle from './TuiToggle.svelte';
+ import TuiGroup from './TuiGroup.svelte';
import type { DisplayedLine } from './types';
import '$lib/assets/css/tui-body.css';
@@ -85,6 +86,8 @@
{:else if line.type === 'toggle'}
+ {:else if line.type === 'group'}
+
{:else if line.type === 'image' && showImage}

@@ -146,6 +149,8 @@
{:else if line.type === 'toggle'}
+ {:else if line.type === 'group'}
+
{:else}
{#if line.type === 'command' || line.type === 'prompt'}
diff --git a/src/lib/components/tui/TuiCardGrid.svelte b/src/lib/components/tui/TuiCardGrid.svelte
index 39c3e5a..e6a3fb5 100644
--- a/src/lib/components/tui/TuiCardGrid.svelte
+++ b/src/lib/components/tui/TuiCardGrid.svelte
@@ -1,5 +1,6 @@
-
+
{#each cards as card}
{#if card.image}
@@ -67,7 +68,7 @@
{/if}
{#if card.liveWarning}
- ⚠ Demo may be unavailable
+ Demo may be unavailable
{/if}
@@ -268,12 +269,25 @@
} */
.warning {
- font-size: 0.6rem;
- color: #f9e2af;
- padding: 0.2rem 0.35rem;
- background: rgba(249, 226, 175, 0.1);
- border-radius: 3px;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+ font-size: 0.75rem;
+ color: var(--card-warning, #b8860b);
+ padding: 0.3rem 0.6rem;
+ background: color-mix(in srgb, var(--card-warning, #b8860b) 8%, transparent);
+ border: 1px solid color-mix(in srgb, var(--card-warning, #b8860b) 20%, transparent);
+ border-radius: 6px;
width: fit-content;
+ font-weight: 600;
+ box-shadow: 0 1px 0 rgba(0,0,0,0.02) inset;
+ }
+
+ .warning:before {
+ content: '⚠';
+ display: inline-block;
+ font-size: 0.9rem;
+ line-height: 1;
}
.card-actions {
diff --git a/src/lib/components/tui/TuiCheckbox.svelte b/src/lib/components/tui/TuiCheckbox.svelte
index fe0d004..6ee2a7c 100644
--- a/src/lib/components/tui/TuiCheckbox.svelte
+++ b/src/lib/components/tui/TuiCheckbox.svelte
@@ -129,4 +129,13 @@
.tui-checkbox.disabled .checkbox-label {
color: var(--terminal-muted);
}
+
+ /* Mobile: inline checkboxes become full-width */
+ @media (max-width: 768px) {
+ .tui-checkbox.inline {
+ display: flex;
+ width: 100%;
+ margin: 0.35rem 0;
+ }
+ }
diff --git a/src/lib/components/tui/TuiGroup.svelte b/src/lib/components/tui/TuiGroup.svelte
new file mode 100644
index 0000000..101a2ab
--- /dev/null
+++ b/src/lib/components/tui/TuiGroup.svelte
@@ -0,0 +1,266 @@
+
+
+
+ {#each parsedChildren as parsed, idx}
+ {@const child = parsed.line}
+ {@const visibleSegments = parsed.segments}
+ {@const childInline = child.inline !== false}
+
+ {#if child.type === 'image'}
+
+

+ {#if child.content}
+
{child.content}
+ {/if}
+
+ {:else if child.type === 'button'}
+
+ {:else if child.type === 'link'}
+
onLinkClick(idx)} />
+ {:else if child.type === 'tooltip'}
+
+ {:else if child.type === 'progress'}
+
+ {:else if child.type === 'input'}
+
+ {:else if child.type === 'checkbox'}
+
+ {:else if child.type === 'toggle'}
+
+ {:else if child.type === 'header'}
+
+ {:else if child.type === 'blank'}
+
+ {:else if child.type === 'command' || child.type === 'prompt'}
+
+
+ {user.username}@{user.hostname}
+ :~$
+
+
+ {#each visibleSegments as segment}
+ {#if segment.icon}
+
+ {:else if getSegmentStyle(segment)}
+ {segment.text}
+ {:else}
+ {segment.text}
+ {/if}
+ {/each}
+
+
+ {:else}
+
+ {getLinePrefix(child.type)}{#each visibleSegments as segment}{#if segment.icon}{:else if getSegmentStyle(segment)}{segment.text}{:else}{segment.text}{/if}{/each}
+
+ {/if}
+ {/each}
+
+
+
diff --git a/src/lib/components/tui/TuiInput.svelte b/src/lib/components/tui/TuiInput.svelte
index 6b09521..246a21b 100644
--- a/src/lib/components/tui/TuiInput.svelte
+++ b/src/lib/components/tui/TuiInput.svelte
@@ -195,4 +195,13 @@
color: #f38ba8;
font-size: 0.8rem;
}
+
+ /* Mobile: inline inputs become full-width */
+ @media (max-width: 768px) {
+ .tui-input.inline {
+ display: flex;
+ width: 100%;
+ margin: 0.5rem 0;
+ }
+ }
diff --git a/src/lib/components/tui/TuiProgress.svelte b/src/lib/components/tui/TuiProgress.svelte
index f4230c3..73e2f49 100644
--- a/src/lib/components/tui/TuiProgress.svelte
+++ b/src/lib/components/tui/TuiProgress.svelte
@@ -156,4 +156,19 @@
vertical-align: middle;
margin: 0 0.15em;
}
+
+ /* Mobile: inline progress becomes full-width */
+ @media (max-width: 768px) {
+ .tui-progress.inline {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ width: 100%;
+ margin: 0.5rem 0;
+ }
+
+ .tui-progress.inline .progress-bar {
+ min-width: 100%;
+ }
+ }
diff --git a/src/lib/components/tui/TuiToggle.svelte b/src/lib/components/tui/TuiToggle.svelte
index f7c934c..a3d68bf 100644
--- a/src/lib/components/tui/TuiToggle.svelte
+++ b/src/lib/components/tui/TuiToggle.svelte
@@ -173,4 +173,13 @@
color: var(--toggle-color);
transform: translateX(0.5rem);
}
+
+ /* Mobile: inline toggles become full-width */
+ @media (max-width: 768px) {
+ .tui-toggle.inline {
+ display: flex;
+ width: 100%;
+ margin: 0.35rem 0;
+ }
+ }
diff --git a/src/lib/components/tui/types.ts b/src/lib/components/tui/types.ts
index fdde6d7..a9a854e 100644
--- a/src/lib/components/tui/types.ts
+++ b/src/lib/components/tui/types.ts
@@ -5,7 +5,8 @@ export type LineType =
| 'command' | 'output' | 'prompt' | 'error' | 'success' | 'info' | 'warning'
| 'image' | 'blank' | 'header' | 'button' | 'divider' | 'link'
| 'card' | 'progress' | 'accordion' | 'table' | 'tooltip' | 'cardgrid'
- | 'input' | 'textarea' | 'checkbox' | 'radio' | 'select' | 'toggle';
+ | 'input' | 'textarea' | 'checkbox' | 'radio' | 'select' | 'toggle'
+ | 'group';
// Option type for radio and select components
export interface FormOption {
@@ -117,6 +118,14 @@ export interface TerminalLine {
toggleOnLabel?: string;
toggleOffLabel?: string;
toggleShowLabels?: boolean;
+ // For group type - contains child lines rendered together
+ children?: TerminalLine[];
+ // Group layout direction
+ groupDirection?: 'row' | 'column';
+ // Group alignment
+ groupAlign?: 'start' | 'center' | 'end';
+ // Group gap
+ groupGap?: string;
}
// Pre-parsed line with segments ready for rendering
diff --git a/src/lib/components/tui/utils.ts b/src/lib/components/tui/utils.ts
index 3a32851..1e78a75 100644
--- a/src/lib/components/tui/utils.ts
+++ b/src/lib/components/tui/utils.ts
@@ -188,6 +188,8 @@ export function getLinePrefix(type: string): string {
return '✗ ';
case 'success':
return '✓ ';
+ case 'warning':
+ return '⚠ ';
case 'info':
return '› ';
default:
diff --git a/src/lib/config/terminal.ts b/src/lib/config/terminal.ts
index 9858751..9b7d193 100644
--- a/src/lib/config/terminal.ts
+++ b/src/lib/config/terminal.ts
@@ -134,7 +134,7 @@ export const pageSpeedSettings: Record
= {
// 'models': 'instant', // No typing animation
// 'hackathons': 0.5, // Custom: 2x faster than normal
'home': 'fast',
- 'portfolio': 'fast',
+ 'portfolio': 'instant',
'models': 'fast',
'projects': 'fast',
'components': 'fast'
diff --git a/src/lib/config/user.ts b/src/lib/config/user.ts
index 38f809c..ea92bf6 100644
--- a/src/lib/config/user.ts
+++ b/src/lib/config/user.ts
@@ -10,7 +10,7 @@ export const user = {
title: 'Engineering Student',
email: 'sirblob0@gmail.com',
location: 'USA (EAST)',
- bio: `Hi, I am Sir Blob — a engineer who loves making things. ` +
+ bio: `A engineer who loves making things. ` +
`I build fun coding projects, participate in game jams and hackathons, and enjoy games like Minecraft and Pokémon TCG Live. ` +
`I'm interested in Open Source, Game Development, Embedded Systems, and AI/ML.`,
diff --git a/src/lib/discord/fetchUserImages.ts b/src/lib/discord/fetchUserImages.ts
new file mode 100644
index 0000000..e8b1f4d
--- /dev/null
+++ b/src/lib/discord/fetchUserImages.ts
@@ -0,0 +1,146 @@
+export enum ImageSize {
+ USER_AVATAR = 32,
+ USER_DECORATION = 64,
+ SERVER_TAG = 32,
+ ACTIVITY_LARGE = 128,
+ ACTIVITY_SMALL = 64,
+ EMOJI = 32,
+}
+
+export type Activity = {
+ name?: string;
+ type?: number;
+ details?: string | null;
+ url?: string | null;
+ application_id?: string | null;
+ assets?: { large_image?: string | null; small_image?: string | null };
+ emoji?: { id?: string; animated?: boolean } | null;
+};
+
+export type Data = {
+ discord_user: {
+ id: string;
+ avatar?: string | null;
+ discriminator?: string | null;
+ primary_guild?: { identity_guild_id?: string; badge?: string } | null;
+ avatar_decoration_data?: { asset?: string } | null;
+ };
+ activities: Activity[];
+ spotify?: { album_art_url?: string } | null;
+};
+
+export type ProfileSettings = {
+ optimized?: boolean;
+ animatedDecoration?: string | boolean;
+ ignoreAppId?: string[];
+ theme?: string | null;
+};
+
+async function encodeBase64(url: string, _size?: ImageSize, _theme?: string | null) {
+ try {
+ const res = await fetch(url);
+ if (!res.ok) return url;
+ const buf = await res.arrayBuffer();
+ const bytes = new Uint8Array(buf);
+ let binary = "";
+ const chunk = 0x8000;
+ for (let i = 0; i < bytes.length; i += chunk) {
+ binary += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + chunk)) as unknown as number[]);
+ }
+ const b64 = typeof globalThis.btoa === "function" ? globalThis.btoa(binary) : Buffer.from(binary, "binary").toString("base64");
+ // try to infer mime
+ const ext = url.split("?")[0].split(".").pop()?.toLowerCase() ?? "png";
+ const mime = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : ext === "gif" ? "image/gif" : ext === "webp" ? "image/webp" : "image/png";
+ return `data:${mime};base64,${b64}`;
+ } catch (err) {
+ return url;
+ }
+}
+
+export async function fetchUserImages(data: Data, settings: ProfileSettings) {
+ let avatar: string;
+ let avatarDecoration: string | null = null;
+ let clanBadge: string | null = null;
+ let assetLargeImage: string | null = null;
+ let assetSmallImage: string | null = null;
+ let userEmoji: string | null = null;
+ let albumCover: string | null = null;
+
+ const avatarExtension =
+ data.discord_user.avatar && data.discord_user.avatar.startsWith("a_") && !settings.optimized
+ ? "gif"
+ : "webp";
+
+ const statusExtension: string = data.activities[0]?.emoji?.animated && !settings.optimized ? "gif" : "webp";
+
+ const userStatus: Activity | undefined = data.activities[0] && data.activities[0].type === 4 ? data.activities[0] : undefined;
+
+ const activities = data.activities
+ .filter((activity) => activity.type === 0)
+ .filter((activity) => !settings.ignoreAppId?.includes(activity.application_id ?? ""));
+ const activity: Activity | undefined = activities.length > 0 ? activities[0] : undefined;
+
+ if (data.discord_user.avatar) {
+ avatar = await encodeBase64(
+ `https://cdn.discordapp.com/avatars/${data.discord_user.id}/${data.discord_user.avatar}.${avatarExtension}?size=${avatarExtension === "gif" ? "64" : "256"}`,
+ ImageSize.USER_AVATAR
+ );
+ } else {
+ avatar = await encodeBase64(
+ `https://cdn.discordapp.com/embed/avatars/${data.discord_user.discriminator === "0" ? Number(BigInt(data.discord_user.id) >> BigInt(22)) % 6 : Number(data.discord_user.discriminator ?? "0") % 5}.png?size=128`,
+ ImageSize.USER_AVATAR
+ );
+ }
+
+ if (data.discord_user.primary_guild && data.discord_user.primary_guild.identity_guild_id && data.discord_user.primary_guild.badge) {
+ clanBadge = await encodeBase64(
+ `https://cdn.discordapp.com/clan-badges/${data.discord_user.primary_guild.identity_guild_id}/${data.discord_user.primary_guild.badge}.png?size=32`,
+ ImageSize.SERVER_TAG
+ );
+ }
+
+ if (data.discord_user.avatar_decoration_data?.asset) {
+ avatarDecoration = await encodeBase64(
+ `https://cdn.discordapp.com/avatar-decoration-presets/${data.discord_user.avatar_decoration_data.asset}.png?size=64&passthrough=${settings.animatedDecoration || "false"}`,
+ ImageSize.USER_DECORATION
+ );
+ }
+
+ if (activity?.assets?.large_image)
+ assetLargeImage = await encodeBase64(
+ activity.assets?.large_image!.startsWith("mp:external/")
+ ? `https://media.discordapp.net/${activity.assets!.large_image!.replace("mp:", "")}`
+ : `https://cdn.discordapp.com/app-assets/${activity.application_id}/${activity.assets!.large_image}.webp`,
+ ImageSize.ACTIVITY_LARGE,
+ settings.theme || null
+ );
+
+ if (activity?.assets?.small_image)
+ assetSmallImage = await encodeBase64(
+ activity.assets.small_image!.startsWith("mp:external/")
+ ? `https://media.discordapp.net/${activity.assets.small_image!.replace("mp:", "")}`
+ : `https://cdn.discordapp.com/app-assets/${activity.application_id}/${activity.assets.small_image}.webp`,
+ ImageSize.ACTIVITY_SMALL,
+ settings.theme || null
+ );
+
+ if (userStatus?.emoji?.id)
+ userEmoji = await encodeBase64(
+ `https://cdn.discordapp.com/emojis/${userStatus.emoji.id}.${statusExtension}?size=32`,
+ ImageSize.EMOJI
+ );
+
+ if (data.spotify?.album_art_url) albumCover = await encodeBase64(data.spotify.album_art_url, ImageSize.ACTIVITY_LARGE);
+
+ return {
+ avatar,
+ clanBadge,
+ avatarDecoration,
+ assetLargeImage,
+ assetSmallImage,
+ userEmoji,
+ albumCover,
+ };
+}
+
+export default fetchUserImages;
diff --git a/src/lib/discordbot.ts b/src/lib/discordbot.ts
new file mode 100644
index 0000000..fc571ed
--- /dev/null
+++ b/src/lib/discordbot.ts
@@ -0,0 +1,106 @@
+import { Client, Partials, GatewayIntentBits, ActivityType } from "discord.js";
+
+import { DISCORD_TOKEN, DISCORD_USER_ID, DISCORD_GUILD_ID } from "$env/static/private";
+
+class Bot {
+ private client: Client;
+
+ // Normalize a discord.js Activity object into a simple POJO
+ private mapActivity(a: any) {
+ const type = typeof a.type === 'number' ? a.type : (ActivityType[a.type] ?? a.type);
+ const application_id = a.applicationId ?? a.application_id ?? null;
+
+ const rawLarge = a.assets?.largeImage ?? a.assets?.large_image ?? null;
+ const rawSmall = a.assets?.smallImage ?? a.assets?.small_image ?? null;
+
+ const formatAsset = (raw: string | null) => {
+ if (!raw) return null;
+ if (raw.startsWith('mp:external/')) {
+ return `https://media.discordapp.net/${raw.replace(/^mp:/, '')}`;
+ }
+ if (/^https?:\/\//.test(raw)) return raw;
+ if (application_id) return `https://cdn.discordapp.com/app-assets/${application_id}/${raw}.webp`;
+ return raw;
+ };
+
+ return {
+ name: a.name ?? null,
+ type,
+ details: a.details ?? a.state ?? null,
+ url: a.url ?? null,
+ application_id,
+ assets: {
+ large_image: formatAsset(rawLarge),
+ small_image: formatAsset(rawSmall)
+ },
+ emoji: a.emoji ? { id: a.emoji.id ?? null, animated: !!a.emoji.animated } : null
+ };
+ }
+
+ constructor() {
+ // Need presence and members intents to read user presence/activity
+ this.client = new Client({
+ partials: [Partials.User],
+ intents: [
+ GatewayIntentBits.Guilds,
+ GatewayIntentBits.GuildMembers,
+ GatewayIntentBits.GuildPresences
+ ]
+ });
+
+ this.client.login(DISCORD_TOKEN).catch((error) => {
+ console.error("Failed to login to Discord:", error);
+ });
+
+ this.client.once("clientReady", async () => {
+ console.log(`Logged in as ${this.client.user?.tag}`);
+ });
+ }
+
+ getClient(): Client {
+ return this.client;
+ }
+
+ /**
+ * Look up the user's presence specifically in the given guild.
+ * Returns null if guild/member/presence not available.
+ */
+ async getUserActivityInGuild(guildId: string = DISCORD_GUILD_ID, userId: string = DISCORD_USER_ID) {
+ try {
+ // Fetch guild from cache or API
+ let guild = this.client.guilds.cache.get(guildId) || await this.client.guilds.fetch(guildId).catch(() => null);
+ if (!guild) return null;
+
+ // Fetch member (will populate presence if available)
+ const member = await guild.members.fetch(userId).catch(() => null);
+ if (!member) return null;
+
+ const presence = member.presence;
+ const user = member.user;
+
+ // Build avatar URL
+ const avatarHash = user.avatar;
+ const avatarUrl = avatarHash
+ ? `https://cdn.discordapp.com/avatars/${user.id}/${avatarHash}.${avatarHash.startsWith('a_') ? 'gif' : 'webp'}?size=128`
+ : `https://cdn.discordapp.com/embed/avatars/${(BigInt(user.id) >> BigInt(22)) % BigInt(6)}.png`;
+
+ // Get guild/clan tag if available
+ const guildTag = (user as any).primaryGuild?.tag ?? null;
+ const guildTagBadgeImage = `https://cdn.discordapp.com/clan-badges/${(user as any).primaryGuild?.identityGuildId}/${(user as any).primaryGuild?.badge}.png?size=32`;
+
+ if (!presence) return { guildId, status: 'offline', activities: [], displayName: member.displayName, avatarUrl, guildTag, guildTagBadgeImage };
+
+ const status = presence.status;
+ const activities = (presence.activities || []).map((a: any) => this.mapActivity(a));
+
+ return { guildId, status, activities, displayName: member.displayName, avatarUrl, guildTag, guildTagBadgeImage };
+ } catch (err) {
+ return null;
+ }
+ }
+}
+
+export const bot = new Bot();
+
+// Re-export image helper for convenience
+export { fetchUserImages } from "./discord/fetchUserImages";
\ No newline at end of file
diff --git a/src/lib/pages/home.ts b/src/lib/pages/home.ts
index 6834cfd..3d36a2e 100644
--- a/src/lib/pages/home.ts
+++ b/src/lib/pages/home.ts
@@ -13,32 +13,26 @@ export const lines: TerminalLine[] = [
{ type: 'output', content: '(&dim,red)███(&)(&dim,orange)███(&)(&dim,yellow)███(&)(&dim,green)███(&)(&dim,cyan)███(&)(&dim,blue)███(&)(&dim,magenta)███(&)(&dim,pink)███(&)', inline: true },
{ type: 'blank', content: '' },
- { type: 'header', content: `Welcome to ${user.displayname}'s Portfolio` },
+ { type: 'header', content: `HI, I'm ${user.displayname}` },
{ type: 'image', content: '', image: user.avatar, imageAlt: user.name, imageWidth: 120, inline: true },
{ type: 'output', content: `(&muted)${user.bio}(&)`, inline: true },
{ type: 'divider', content: 'NAVIGATION' },
// Interactive navigation buttons
- ...navigation.map(nav => ({
+ ...navigation.map((nav, i) => ({
type: 'button' as const,
- content: nav.name,
+ content: nav.name + ` (&text, bold)[${i+1}](&)`,
icon: nav.icon === '📁' ? 'mdi:folder' : nav.icon === '🎨' ? 'mdi:palette' : 'mdi:trophy',
style: 'primary' as const,
href: nav.path,
inline: true
})),
+
{ type: 'divider', content: 'Website Keybinds' },
{ type: 'output', content: '(&orange)Toggle Light/Dark Mode(&) (&text, bold)(Alt/Option+B)(&)' , inline: true },
- { type: 'output', content: '(&muted)•(&)' , inline: true },
- { type: 'output', content: '(&orange)Select Theme(&) (&text, bold)(Alt/Option+T + [#])(&)' , inline: true },
- { type: 'output', content: '(&muted)•(&)' , inline: true },
- { type: 'output', content: '(&orange)Skip typing animation(&) (&text, bold)(Y)(&)' , inline: true },
- { type: 'output', content: '(&muted)•(&)' , inline: true },
- { type: 'output', content: '(&orange)Navigate to page(&) (&text, bold)(Ctrl/Cmd+ [#])(&)' , inline: true },
- { type: 'blank', content: '' },
- // list navigation with numeric shortcuts
- { type: 'output', content: '(&text, bold)Pages -(&)', inline: true },
- ...navigation.map((nav, i) => ({ type: 'output' as const, content: `(&blue)${nav.name}(&) (&text, bold)[${i+1}](&)`, inline: true })),
+ { type: 'output', content: '(&muted)•(&) (&orange)Select Theme(&) (&text, bold)(Alt/Option+T + [#])(&)' , inline: true },
+ { type: 'output', content: '(&muted)•(&) (&orange)Skip typing animation(&) (&text, bold)(Y)(&)' , inline: true },
+ { type: 'output', content: '(&muted)•(&) (&orange)Navigate to page(&) (&text, bold)(Ctrl/Cmd+ [#])(&)' , inline: true },
{ type: 'blank', content: '' },
];
\ No newline at end of file
diff --git a/src/lib/pages/portfolio.ts b/src/lib/pages/portfolio.ts
index 4ff3402..d21b260 100644
--- a/src/lib/pages/portfolio.ts
+++ b/src/lib/pages/portfolio.ts
@@ -6,26 +6,35 @@ export const lines: TerminalLine[] = [
// Header command
{ type: 'command', content: 'cat ~/about.md' },
{ type: 'blank', content: '' },
-
+
// Avatar image
- { type: 'image', content: '', image: user.avatar, imageAlt: user.name, imageWidth: 150 },
- { type: 'blank', content: '' },
+ { type: 'image', content: '', image: user.avatar, imageAlt: user.name, imageWidth: 180, inline: true },
+
+ {
+ type: 'group',
+ content: '',
+ groupDirection: 'column',
+ groupAlign: 'start',
+ inline: true,
+ children: [
+ // User info
+ { type: 'header', content: `(&primary,bold)${user.name}(&)` },
+ { type: 'info', content: `(&accent)${user.title}(&)` },
+ { type: 'output', content: `(&white)Location:(&) (&primary)${user.location}(&)` },
+ { type: 'output', content: `(&muted)${user.bio}(&)` },
+ ]
+ },
- // User info
- { type: 'header', content: `(&primary,bold)${user.name}(&)` },
- { type: 'info', content: `(&accent)${user.title}(&)` },
- { type: 'output', content: `(&white)Location:(&) (&primary)${user.location}(&)` },
- { type: 'output', content: `(&muted)${user.bio}(&)` },
{ type: 'blank', content: '' },
- { type: 'output', content: `(&primary, bold)Links >(&)`, inline: true },
- { type: 'link', href: "/portfolio#contact", content: `(&bg-blue,black)Contact(&)`, inline: true },
- { type: 'link', href: "/portfolio#skills", content: `(&bg-orange,black)Skills(&)`, inline: true },
- { type: 'link', href: "/portfolio#projects", content: `(&bg-green,black)Projects(&)`, inline: true },
- { type: 'blank', content: '' },
-
+ { type: 'group', content: '', groupAlign: 'start', groupGap: '1rem',
+ children: [
+ { type: 'output', content: `(&primary, bold)Links >(&)`, inline: true },
+ { type: 'link', href: "/portfolio#contact", content: `(&bg-blue,black)Contact(&)`, inline: true },
+ { type: 'link', href: "/portfolio#skills", content: `(&bg-orange,black)Skills(&)`, inline: true },
+ { type: 'link', href: "/portfolio#projects", content: `(&bg-green,black)Projects(&)`, inline: true },
+ ]
+ },
{ type: 'divider', content: 'CONTACT', id: 'contact' },
- { type: 'blank', content: '' },
-
// Contact buttons - dynamically generated from socials array
...user.socials.map(social => ({
type: 'button' as const,
@@ -45,7 +54,6 @@ export const lines: TerminalLine[] = [
{ type: 'blank', content: '' },
{ type: 'divider', content: 'SKILLS', id: 'skills' },
- { type: 'blank', content: '' },
// Skills as TUI sections
@@ -81,9 +89,9 @@ export const lines: TerminalLine[] = [
// Interests
{ type: 'info', content: '(&accent,bold)▸ Interests(&)' },
- { type: 'output', content: ' ' + skills.interests.map(s => `(&muted)${s}(&)`).join(' (&muted)•(&) ') },
-
+ { type: 'output', content: ' ' + skills.interests.map(s => `(&muted)${s}(&)`).join(' (&muted)•(&) ') },
{ type: 'blank', content: '' },
+
{ type: 'divider', content: 'PROJECTS', id: 'projects' },
{ type: 'blank', content: '' },
diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts
new file mode 100644
index 0000000..add3c31
--- /dev/null
+++ b/src/routes/+layout.server.ts
@@ -0,0 +1,16 @@
+import type { LayoutServerLoad } from './$types';
+
+import { bot } from '$lib/discordbot';
+
+export const load: LayoutServerLoad = async () => {
+ const info = await bot.getUserActivityInGuild().catch(() => null);
+
+ return {
+ status: info?.status ?? 'offline',
+ activities: info?.activities ?? [],
+ displayName: info?.displayName ?? null,
+ avatarUrl: info?.avatarUrl ?? null,
+ guildTag: info?.guildTag ?? null,
+ guildTagBadgeImage: info?.guildTagBadgeImage ?? null
+ };
+};
\ No newline at end of file
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 66a63c9..359c1d0 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -20,7 +20,7 @@
diff --git a/src/routes/models/+page.svelte b/src/routes/models/+page.svelte
index 2bb7fd3..9851901 100644
--- a/src/routes/models/+page.svelte
+++ b/src/routes/models/+page.svelte
@@ -44,7 +44,6 @@
{ type: 'blank', content: '' },
{ type: 'divider', content: 'VIEWER' },
- { type: 'blank', content: '' },
// Interactive model buttons
...glbModels.map(model => ({