Rewrite website as a Hyprland desktop

This commit is contained in:
SirBlob committed 2026-10-26 14:33:05 -04:00
1 parent 7c52779cf6
commit 3065ad6092
197 files changed
+5609 -17779

No files matched your search

-14
View File
@@ -1,14 +0,0 @@
.scene-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -1;
pointer-events: none;
background-color: var(--scene-bg);
}
.scene-container :global(canvas) {
display: block;
}
-34
View File
@@ -1,34 +0,0 @@
/* TUI Components CSS Index */
/* This file can be imported to get all TUI component styles at once */
/* TUI Core Components */
@import './tui-header.css';
@import './tui-footer.css';
@import './tui-body.css';
/* TUI Display Components */
@import './tui-button.css';
@import './tui-link.css';
@import './tui-card.css';
@import './tui-card-grid.css';
@import './tui-progress.css';
@import './tui-accordion.css';
@import './tui-table.css';
@import './tui-tooltip.css';
/* TUI Form Components */
@import './tui-input.css';
@import './tui-textarea.css';
@import './tui-checkbox.css';
@import './tui-radio.css';
@import './tui-select.css';
@import './tui-toggle.css';
/* Main Components */
@import './terminal.css';
@import './terminal-tui.css';
@import './terminal-page.css';
@import './model-viewer.css';
@import './navbar.css';
@import './navbar-waybar.css';
@import './background-3d.css';
-172
View File
@@ -1,172 +0,0 @@
.model-viewer {
display: flex;
flex-direction: column;
background: var(--viewer-bg);
border: 1px solid var(--viewer-border);
border-radius: 8px;
overflow: hidden;
transition: all 0.3s ease;
}
.model-viewer.fullscreen {
position: fixed;
inset: 60px 0 0 0;
z-index: 100;
border-radius: 0;
border: none;
}
.viewer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem;
background: color-mix(in srgb, var(--viewer-bg) 80%, black);
border-bottom: 1px solid var(--viewer-border);
}
.header-left {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--viewer-primary);
font-size: 0.85rem;
font-weight: 500;
}
.header-controls {
display: flex;
gap: 0.25rem;
}
.control-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
color: var(--viewer-muted);
cursor: pointer;
transition: all 0.2s ease;
}
.control-btn:hover {
background: var(--viewer-border);
color: var(--viewer-text);
border-color: var(--viewer-border);
}
.control-btn.active {
background: var(--viewer-primary);
color: var(--viewer-bg);
border-color: var(--viewer-primary);
}
.control-btn.small {
width: 24px;
height: 24px;
}
/* Extended controls panel */
.controls-panel {
display: flex;
flex-wrap: wrap;
gap: 1rem;
padding: 0.5rem 0.75rem;
background: color-mix(in srgb, var(--viewer-bg) 90%, black);
border-bottom: 1px solid var(--viewer-border);
}
.control-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.control-label {
font-size: 0.7rem;
color: var(--viewer-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.control-buttons {
display: flex;
align-items: center;
gap: 0.25rem;
}
.control-value {
font-size: 0.7rem;
color: var(--viewer-text);
min-width: 2.5rem;
text-align: center;
font-family: 'JetBrains Mono', monospace;
}
.canvas-container {
flex: 1;
min-height: 300px;
position: relative;
background: radial-gradient(
circle at center,
color-mix(in srgb, var(--viewer-primary) 5%, transparent),
transparent 70%
);
outline: none;
}
.canvas-container:focus {
box-shadow: inset 0 0 0 2px var(--viewer-primary);
}
.canvas-container :global(canvas) {
display: block;
}
.loading-overlay,
.error-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
color: var(--viewer-muted);
font-size: 0.9rem;
}
.error-overlay {
color: #f38ba8;
}
:global(.spin) {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.viewer-footer {
display: flex;
align-items: center;
justify-content: center;
gap: 1.5rem;
padding: 0.5rem;
background: color-mix(in srgb, var(--viewer-bg) 80%, black);
border-top: 1px solid var(--viewer-border);
}
.hint {
display: flex;
align-items: center;
gap: 0.35rem;
color: var(--viewer-muted);
font-size: 0.75rem;
}
-808
View File
@@ -1,808 +0,0 @@
/* Waybar main container */
.waybar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
height: var(--navbar-height);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 0.75rem;
background: var(--bar-bg);
border-bottom: 1px solid var(--bar-border);
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 0.8rem;
}
/* Bar sections */
.bar-left,
.bar-center,
.bar-right {
display: flex;
align-items: center;
gap: 0.5rem;
}
.bar-left {
flex: 1;
}
.bar-right {
flex: 1;
justify-content: flex-end;
}
/* Desktop-only items */
.desktop-only {
display: flex;
}
.workspaces.desktop-only,
.window-title.desktop-only {
display: flex;
}
@media (max-width: 768px) {
.desktop-only,
.workspaces.desktop-only,
.window-title.desktop-only {
display: none !important;
}
.bar-left {
gap: 0.35rem;
}
.bar-center {
display: none;
}
}
/* Module base style */
.module {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.6rem;
background: var(--bar-bg-module);
border-radius: 6px;
transition: all 0.15s ease;
}
/* Launcher / Logo */
.launcher {
padding: 0.4rem;
border-radius: 6px;
}
.launcher:hover {
background: color-mix(in srgb, var(--bar-primary) 20%, transparent);
}
/* Mobile menu toggle button */
.mobile-menu-toggle {
display: none;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
background: var(--bar-bg-module);
border: 1px solid var(--bar-border);
border-radius: 6px;
color: var(--bar-text);
cursor: pointer;
transition: all 0.15s ease;
}
.mobile-menu-toggle:hover {
background: color-mix(in srgb, var(--bar-primary) 20%, transparent);
border-color: var(--bar-primary);
}
@media (max-width: 768px) {
.mobile-menu-toggle {
display: flex;
}
}
/* Workspaces */
.workspaces {
gap: 0.15rem;
background: transparent;
padding: 0;
}
.workspace {
display: flex;
align-items: center;
gap: 0.20rem;
padding: 0.35rem 0.4rem;
color: var(--bar-muted);
text-decoration: none;
border-radius: 4px;
transition: all 0.15s ease;
font-size: 0.75rem;
}
.workspace:hover {
color: var(--bar-text);
background: var(--bar-bg-module);
}
.workspace.active {
color: var(--bar-primary);
background: color-mix(in srgb, var(--bar-primary) 15%, transparent);
}
.workspace.external {
color: var(--bar-accent);
}
.workspace.external:hover {
background: color-mix(in srgb, var(--bar-accent) 15%, transparent);
}
.ws-name {
font-weight: 500;
}
/* Window title */
.window-title {
color: var(--bar-text);
font-size: 0.75rem;
}
.title-icon {
color: var(--bar-primary);
}
.title-text {
color: var(--bar-muted);
}
/* Clock */
.clock {
gap: 0.75rem;
}
.time {
color: var(--bar-text);
font-weight: 600;
}
.date {
color: var(--bar-muted);
}
/* Theme selector */
.theme-selector {
position: relative;
background: transparent;
padding: 0;
}
.theme-trigger {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
background: var(--bar-bg-module);
border: 1px solid var(--bar-border);
border-radius: 6px;
color: var(--bar-text);
cursor: pointer;
transition: all 0.15s ease;
}
.theme-trigger:hover {
background: color-mix(in srgb, var(--bar-primary) 20%, transparent);
border-color: var(--bar-primary);
}
.theme-dropdown {
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
min-width: 200px;
background: var(--bar-bg);
border: 1px solid var(--bar-border);
border-radius: 8px;
padding: 0.5rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
z-index: 1001;
}
.dropdown-header {
padding: 0.25rem 0.5rem;
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--bar-muted);
}
.dropdown-divider {
height: 1px;
background: var(--bar-border);
margin: 0.5rem 0;
}
.theme-option {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
background: transparent;
border: none;
border-radius: 4px;
color: var(--bar-text);
cursor: pointer;
font-family: inherit;
font-size: 0.8rem;
text-align: left;
transition: all 0.15s ease;
}
.theme-option:hover {
background: color-mix(in srgb, var(--bar-primary) 15%, transparent);
}
.theme-option.active {
color: var(--bar-primary);
}
:global(.check) {
margin-left: auto;
color: var(--bar-primary);
}
/* Backdrop */
.backdrop {
position: fixed;
inset: 0;
background: transparent;
z-index: 997;
border: none;
cursor: default;
}
/* Mobile menu dropdown */
.mobile-menu {
position: fixed;
top: var(--navbar-height);
left: 0;
right: 0;
background: var(--bar-bg);
border-bottom: 1px solid var(--bar-border);
z-index: 1000;
padding: 1rem;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
pointer-events: auto;
}
.mobile-nav-links {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.mobile-nav-link {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
color: var(--bar-text);
text-decoration: none;
border-radius: 6px;
transition: all 0.15s ease;
font-size: 0.9rem;
cursor: pointer;
position: relative;
z-index: 1;
}
.mobile-nav-link:hover {
background: var(--bar-bg-module);
color: var(--bar-primary);
}
.mobile-nav-link:active {
transform: scale(0.98);
}
.mobile-nav-link.active {
color: var(--bar-primary);
background: color-mix(in srgb, var(--bar-primary) 15%, transparent);
}
.mobile-nav-link.external {
color: var(--bar-accent);
}
.mobile-menu-divider {
height: 1px;
background: var(--bar-border);
margin: 1rem 0;
}
.mobile-theme-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.mobile-section-header {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--bar-muted);
padding: 0 0.5rem;
}
.mobile-theme-options {
display: flex;
gap: 0.5rem;
}
.mobile-theme-btn {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35rem;
padding: 0.75rem 0.5rem;
background: var(--bar-bg-module);
border: 1px solid var(--bar-border);
border-radius: 6px;
color: var(--bar-text);
cursor: pointer;
font-family: inherit;
font-size: 0.75rem;
transition: all 0.15s ease;
position: relative;
z-index: 1;
}
.mobile-theme-btn:hover {
background: color-mix(in srgb, var(--bar-primary) 15%, transparent);
border-color: var(--bar-primary);
color: var(--bar-primary);
}
.mobile-theme-btn:active {
transform: scale(0.98);
}
.mobile-theme-btn.active {
color: var(--bar-primary);
border-color: var(--bar-primary);
background: color-mix(in srgb, var(--bar-primary) 20%, transparent);
}
.mobile-mode-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem;
background: var(--bar-bg-module);
border: 1px solid var(--bar-border);
border-radius: 6px;
color: var(--bar-text);
cursor: pointer;
font-family: inherit;
font-size: 0.8rem;
transition: all 0.15s ease;
width: 100%;
position: relative;
z-index: 1;
}
.mobile-mode-btn:hover {
background: color-mix(in srgb, var(--bar-primary) 15%, transparent);
border-color: var(--bar-primary);
color: var(--bar-primary);
}
.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.35rem;
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;
}
}
-332
View File
@@ -1,332 +0,0 @@
.navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
background: var(--nav-bg);
border-bottom: 1px solid var(--nav-border);
backdrop-filter: blur(10px);
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
}
.nav-container {
max-width: 1400px;
margin: 0 auto;
padding: 0.75rem 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 2rem;
}
.nav-brand {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.9rem;
font-weight: 600;
}
.terminal-prompt {
display: flex;
align-items: center;
gap: 0;
}
.terminal-prompt .user {
color: var(--nav-accent);
}
.terminal-prompt .separator {
color: var(--nav-text);
}
.terminal-prompt .path {
color: var(--nav-primary);
}
.terminal-prompt .symbol {
color: var(--nav-text);
margin-left: 0.25rem;
}
.cursor {
width: 0.5em;
height: 1.1em;
background: var(--nav-primary);
animation: blink 1s step-end infinite;
margin-left: 0.25rem;
vertical-align: baseline;
transform: translateY(0.15em);
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.mobile-toggle {
display: none;
background: none;
border: none;
padding: 0.5rem;
cursor: pointer;
}
.hamburger {
display: block;
width: 24px;
height: 2px;
background: var(--nav-text);
position: relative;
transition: all 0.3s ease;
}
.hamburger::before,
.hamburger::after {
content: '';
position: absolute;
width: 24px;
height: 2px;
background: var(--nav-text);
transition: all 0.3s ease;
}
.hamburger::before {
top: -7px;
}
.hamburger::after {
top: 7px;
}
.hamburger.open {
background: transparent;
}
.hamburger.open::before {
top: 0;
transform: rotate(45deg);
}
.hamburger.open::after {
top: 0;
transform: rotate(-45deg);
}
.nav-links {
display: flex;
align-items: center;
gap: 1.5rem;
flex: 1;
}
.nav-link {
color: var(--nav-text);
text-decoration: none;
font-size: 0.85rem;
padding: 0.5rem 0.75rem;
border-radius: 4px;
transition: all 0.2s ease;
display: flex;
align-items: center;
position: relative;
overflow: hidden;
}
.nav-link::before {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 0;
height: 2px;
background: var(--nav-primary);
transition: width 0.3s ease;
}
.nav-link:hover::before {
width: 100%;
}
.nav-link:hover {
color: var(--nav-primary);
}
.link-prefix {
color: var(--nav-muted);
margin-right: 2px;
}
.nav-controls {
display: flex;
align-items: center;
gap: 1rem;
}
.theme-selector {
position: relative;
}
.theme-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: transparent;
border: 1px solid var(--nav-border);
border-radius: 6px;
color: var(--nav-text);
cursor: pointer;
font-family: inherit;
font-size: 0.8rem;
transition: all 0.2s ease;
}
.theme-button:hover {
border-color: var(--nav-primary);
background: color-mix(in srgb, var(--nav-primary) 10%, transparent);
}
.theme-icon {
font-size: 1rem;
display: flex;
align-items: center;
}
.theme-label {
text-transform: capitalize;
}
:global(.dropdown-arrow) {
transition: transform 0.2s ease;
}
:global(.dropdown-arrow.open) {
transform: rotate(180deg);
}
.theme-dropdown {
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
min-width: 180px;
background: var(--nav-bg);
border: 1px solid var(--nav-border);
border-radius: 8px;
padding: 0.5rem;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
z-index: 1001;
}
.theme-option {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.625rem 0.75rem;
background: transparent;
border: none;
border-radius: 6px;
color: var(--nav-text);
cursor: pointer;
font-family: inherit;
font-size: 0.85rem;
text-align: left;
transition: all 0.2s ease;
}
.theme-option:hover {
background: color-mix(in srgb, var(--nav-primary) 15%, transparent);
}
.theme-option.active {
background: color-mix(in srgb, var(--nav-primary) 20%, transparent);
color: var(--nav-primary);
}
.option-icon {
font-size: 1.1rem;
display: flex;
align-items: center;
}
.option-label {
flex: 1;
}
:global(.check-mark) {
color: var(--nav-primary);
}
.mode-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: transparent;
border: 1px solid var(--nav-border);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
color: var(--nav-text);
}
.mode-toggle:hover {
border-color: var(--nav-primary);
background: color-mix(in srgb, var(--nav-primary) 10%, transparent);
color: var(--nav-primary);
}
.backdrop {
position: fixed;
inset: 0;
background: transparent;
z-index: 999;
border: none;
cursor: default;
}
@media (max-width: 768px) {
.nav-container {
flex-wrap: wrap;
padding: 0.75rem 1rem;
}
.mobile-toggle {
display: block;
order: 2;
}
.nav-links {
order: 4;
width: 100%;
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease, padding 0.3s ease;
}
.nav-links.expanded {
max-height: 200px;
padding-top: 1rem;
}
.nav-controls {
order: 3;
margin-left: auto;
margin-right: 1rem;
}
.theme-label {
display: none;
}
.theme-button {
padding: 0.5rem;
}
}
-224
View File
@@ -1,224 +0,0 @@
.terminal-page {
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
background: var(--terminal-bg);
border: 1px solid var(--terminal-border);
border-radius: 12px;
overflow: hidden;
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
animation: terminalFadeIn 0.5s ease-out;
display: flex;
flex-direction: column;
height: 100%;
}
@keyframes terminalFadeIn {
from {
opacity: 0;
transform: translateY(20px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.terminal-header {
display: flex;
align-items: center;
padding: 0.75rem 1rem;
background: var(--terminal-bg-light);
border-bottom: 1px solid var(--terminal-border);
flex-shrink: 0;
}
.terminal-buttons {
display: flex;
gap: 8px;
}
.terminal-buttons .btn {
width: 12px;
height: 12px;
border-radius: 50%;
cursor: pointer;
transition: opacity 0.2s;
}
.terminal-buttons .btn:hover {
opacity: 0.8;
}
.terminal-buttons .close {
background: #ff5f56;
}
.terminal-buttons .minimize {
background: #ffbd2e;
}
.terminal-buttons .maximize {
background: #27ca40;
}
.terminal-title {
flex: 1;
text-align: center;
font-size: 0.8rem;
color: var(--terminal-muted);
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.terminal-icon {
font-size: 1rem;
}
.terminal-spacer {
width: 52px;
}
.terminal-body {
padding: 1rem 1.25rem;
flex: 1;
overflow-y: auto;
font-size: 0.9rem;
line-height: 1.6;
}
.terminal-line {
display: flex;
align-items: flex-start;
flex-wrap: wrap;
margin-bottom: 0.25rem;
animation: lineSlideIn 0.2s ease-out;
min-height: 1.6em;
}
.terminal-line.blank {
min-height: 0.8em;
}
@keyframes lineSlideIn {
from {
opacity: 0;
transform: translateX(-10px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.prompt {
display: inline-flex;
margin-right: 0.5rem;
flex-shrink: 0;
}
.prompt .user {
color: var(--terminal-user);
}
.prompt .separator {
color: var(--terminal-text);
}
.prompt .path {
color: var(--terminal-path);
}
.prompt .symbol {
color: var(--terminal-text);
margin-left: 0.25rem;
}
.content {
color: var(--terminal-text);
word-break: break-word;
white-space: pre-wrap;
}
.terminal-line.output .content {
color: var(--terminal-muted);
}
.terminal-line.error .content {
color: #f38ba8;
}
.terminal-line.success .content {
color: #a6e3a1;
}
.terminal-line.info .content {
color: var(--terminal-primary);
}
.terminal-line.header .content {
color: var(--terminal-accent);
font-weight: 600;
font-size: 1rem;
}
.header-text {
color: var(--terminal-accent);
font-weight: 600;
}
.terminal-image {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.5rem 0;
}
.terminal-image img {
border-radius: 8px;
border: 1px solid var(--terminal-border);
background: var(--terminal-bg-light);
object-fit: contain;
}
.image-caption {
color: var(--terminal-muted);
font-size: 0.8rem;
font-style: italic;
}
.cursor {
display: inline-block;
width: 0.5em;
height: 1.1em;
background: var(--terminal-primary);
animation: cursorBlink 1s step-end infinite;
margin-left: 2px;
vertical-align: baseline;
transform: translateY(0.15em);
}
@keyframes cursorBlink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* Scrollbar styling */
.terminal-body::-webkit-scrollbar {
width: 8px;
}
.terminal-body::-webkit-scrollbar-track {
background: transparent;
}
.terminal-body::-webkit-scrollbar-thumb {
background: var(--terminal-border);
border-radius: 4px;
}
.terminal-body::-webkit-scrollbar-thumb:hover {
background: var(--terminal-muted);
}
-61
View File
@@ -1,61 +0,0 @@
.tui-terminal {
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
background: var(--terminal-bg);
border: 2px solid var(--terminal-border);
border-radius: 0;
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
width: 100%;
margin: 0 auto;
height: calc(100vh - var(--navbar-height));
max-height: calc(100vh - var(--navbar-height));
}
.tui-terminal:focus-within .tui-border-glow {
opacity: 1;
}
/* Hyprland-style animated border glow */
.tui-border-glow {
position: absolute;
inset: -2px;
border-radius: 0;
background: linear-gradient(
45deg,
var(--terminal-primary),
var(--terminal-accent),
var(--terminal-primary),
var(--terminal-accent)
);
background-size: 400% 400%;
animation: borderGlow 8s ease infinite;
opacity: 0.5;
z-index: -1;
filter: blur(4px);
transition: opacity 0.3s ease;
}
@keyframes borderGlow {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
.tui-content {
display: flex;
flex-direction: column;
height: 100%;
background: var(--terminal-bg);
position: relative;
z-index: 1;
}
/* Responsive */
@media (max-width: 768px) {
.tui-terminal {
width: 100%;
height: calc(100vh - var(--navbar-height));
max-height: calc(100vh - var(--navbar-height));
}
}
-184
View File
@@ -1,184 +0,0 @@
.terminal {
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
background: var(--terminal-bg);
border: 1px solid var(--terminal-border);
border-radius: 12px;
overflow: hidden;
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
animation: terminalFadeIn 0.5s ease-out;
}
@keyframes terminalFadeIn {
from {
opacity: 0;
transform: translateY(20px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.terminal-header {
display: flex;
align-items: center;
padding: 0.75rem 1rem;
background: color-mix(in srgb, var(--terminal-bg) 80%, black);
border-bottom: 1px solid var(--terminal-border);
}
.terminal-buttons {
display: flex;
gap: 8px;
}
.terminal-buttons .btn {
width: 12px;
height: 12px;
border-radius: 50%;
cursor: pointer;
transition: opacity 0.2s;
}
.terminal-buttons .btn:hover {
opacity: 0.8;
}
.terminal-buttons .close {
background: #ff5f56;
}
.terminal-buttons .minimize {
background: #ffbd2e;
}
.terminal-buttons .maximize {
background: #27ca40;
}
.terminal-title {
flex: 1;
text-align: center;
font-size: 0.8rem;
color: var(--terminal-muted);
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.terminal-icon {
font-size: 1rem;
}
.terminal-spacer {
width: 52px;
}
.terminal-body {
padding: 1rem 1.25rem;
min-height: 200px;
max-height: 400px;
overflow-y: auto;
font-size: 0.9rem;
line-height: 1.6;
}
.terminal-line {
display: flex;
align-items: center;
flex-wrap: wrap;
margin-bottom: 0.25rem;
animation: lineSlideIn 0.2s ease-out;
}
@keyframes lineSlideIn {
from {
opacity: 0;
transform: translateX(-10px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.prompt {
display: inline-flex;
margin-right: 0.5rem;
flex-shrink: 0;
}
.prompt .user {
color: var(--terminal-user);
}
.prompt .separator {
color: var(--terminal-text);
}
.prompt .path {
color: var(--terminal-path);
}
.prompt .symbol {
color: var(--terminal-text);
margin-left: 0.25rem;
}
.content {
color: var(--terminal-text);
word-break: break-word;
}
.terminal-line.output .content {
color: var(--terminal-muted);
}
.terminal-line.error .content {
color: #ff6b6b;
}
.terminal-line.success .content {
color: #51cf66;
}
.terminal-line.info .content {
color: var(--terminal-primary);
}
.cursor {
display: inline-block;
width: 0.5em;
height: 1.1em;
background: var(--terminal-primary);
animation: cursorBlink 1s step-end infinite;
margin-left: 2px;
vertical-align: baseline;
transform: translateY(0.15em);
}
@keyframes cursorBlink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* Scrollbar styling */
.terminal-body::-webkit-scrollbar {
width: 8px;
}
.terminal-body::-webkit-scrollbar-track {
background: transparent;
}
.terminal-body::-webkit-scrollbar-thumb {
background: var(--terminal-border);
border-radius: 4px;
}
.terminal-body::-webkit-scrollbar-thumb:hover {
background: var(--terminal-muted);
}
-78
View File
@@ -1,78 +0,0 @@
.tui-accordion {
margin: 0.5rem 0;
border: 1px solid var(--terminal-border);
border-radius: 6px;
overflow: hidden;
}
.tui-accordion.inline {
display: inline-block;
width: auto;
min-width: 200px;
vertical-align: top;
margin: 0 0.5rem 0 0;
}
.accordion-item {
border-bottom: 1px solid var(--terminal-border);
}
.accordion-item:last-child {
border-bottom: none;
}
.accordion-header {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.6rem 0.75rem;
background: transparent;
border: none;
color: var(--terminal-text);
font-family: inherit;
font-size: 0.85rem;
font-weight: 500;
text-align: left;
cursor: pointer;
transition: background 0.15s ease;
}
.accordion-header:hover {
background: rgba(255, 255, 255, 0.03);
}
.accordion-item.open .accordion-header {
background: rgba(0, 0, 0, 0.1);
border-bottom: 1px solid var(--terminal-border);
}
:global(.accordion-icon) {
color: var(--accordion-accent);
transition: transform 0.2s ease;
}
.accordion-title {
flex: 1;
}
.accordion-content {
padding: 0.75rem;
color: var(--terminal-muted);
font-size: 0.85rem;
line-height: 1.6;
white-space: pre-wrap;
animation: slideDown 0.2s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
-307
View File
@@ -1,307 +0,0 @@
.tui-body {
flex: 1;
padding: 1rem 1.25rem 2rem 1.25rem;
overflow-y: auto;
overflow-x: hidden;
font-size: 0.9rem;
line-height: 1.7;
min-height: 0;
}
/* Inline group wrapper - groups consecutive inline elements */
.tui-inline-group {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 0.5rem;
margin-bottom: 0.2rem;
animation: lineSlideIn 0.15s ease-out;
}
/* When inline group contains an image, allow text to wrap beside it */
.tui-inline-group:has(.inline-image) {
flex-wrap: nowrap;
align-items: flex-start;
}
.tui-inline-group:has(.inline-image) .inline-content,
.tui-inline-group:has(.inline-image) .tui-group {
flex: 1;
min-width: 0;
}
.inline-content {
display: inline;
white-space: pre-wrap;
}
.inline-content.output {
color: var(--terminal-muted);
}
.inline-content.info {
color: var(--terminal-primary);
}
.inline-content.success {
color: #a6e3a1;
}
.inline-content.error {
color: #f38ba8;
}
.inline-content.warning {
color: #f9e2af;
}
/* Lines */
.tui-line {
display: flex;
align-items: flex-start;
flex-wrap: wrap;
margin-bottom: 0.2rem;
animation: lineSlideIn 0.15s ease-out;
min-height: 1.7em;
}
.tui-line.blank {
min-height: 0.15em;
}
@keyframes lineSlideIn {
from {
opacity: 0;
transform: translateX(-5px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* Prompt styling */
.prompt {
display: inline-flex;
margin-right: 0.5rem;
flex-shrink: 0;
}
.prompt .user {
color: var(--terminal-user);
}
.prompt .at {
color: var(--terminal-muted);
}
.prompt .host {
color: var(--terminal-accent);
}
.prompt .separator {
color: var(--terminal-muted);
}
.prompt .path {
color: var(--terminal-path);
}
.prompt .symbol {
color: var(--terminal-muted);
margin-left: 0.25rem;
}
/* Content */
.content {
color: var(--terminal-text);
word-break: break-word;
white-space: pre-wrap;
}
.tui-line.output .content {
color: var(--terminal-muted);
}
.tui-line.error .content {
color: #f38ba8;
}
.tui-line.success .content {
color: #a6e3a1;
}
.tui-line.info .content {
color: var(--terminal-primary);
}
.tui-line.warning .content {
color: #f9e2af;
}
.header-text {
color: var(--terminal-accent);
font-weight: 700;
font-size: 1.35rem;
display: flex;
align-items: center;
gap: 0.5rem;
line-height: 1.2;
}
.header-text.inline-header {
display: inline-flex;
}
:global(.header-icon) {
opacity: 0.7;
}
:global(.inline-icon) {
display: inline-block;
vertical-align: middle;
margin: 0 0.15em;
}
/* Divider */
.tui-divider {
display: flex;
align-items: center;
gap: 1rem;
margin: 0.75rem 0;
}
.divider-line {
flex: 1;
height: 1px;
background: linear-gradient(to right,
transparent,
var(--terminal-border),
transparent);
}
.divider-text {
color: var(--terminal-muted);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.1em;
}
/* Images */
.tui-image {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.5rem 0;
}
/* Inline images */
.tui-image.inline-image {
display: inline-flex;
padding: 0;
vertical-align: middle;
flex-shrink: 0;
}
.tui-image img {
border-radius: 6px;
border: 1px solid var(--terminal-border);
background: var(--terminal-bg-light);
object-fit: contain;
}
.image-caption {
color: var(--terminal-muted);
font-size: 0.8rem;
font-style: italic;
}
/* Cursor */
.cursor {
display: inline-block;
width: 0.5em;
height: 1.1em;
background: var(--terminal-primary);
background: var(--terminal-primary);
margin-left: 2px;
vertical-align: baseline;
transform: translateY(0.15em);
}
.cursor.blink {
animation: cursorBlink 1s step-end infinite;
}
@keyframes cursorBlink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
/* Scrollbar */
.tui-body::-webkit-scrollbar {
width: 6px;
}
.tui-body::-webkit-scrollbar-track {
background: transparent;
}
.tui-body::-webkit-scrollbar-thumb {
background: var(--terminal-border);
border-radius: 3px;
}
.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;
}
/* Keep inline flow for specific containers */
.tui-card .tui-inline-group,
.tui-group .tui-inline-group,
.tui-accordion .tui-inline-group {
flex-direction: row;
flex-wrap: wrap;
align-items: flex-start;
}
.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%;
}
.mobile-hidden {
display: none !important;
}
}
-77
View File
@@ -1,77 +0,0 @@
.tui-button {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
margin: 0.75rem 0;
background: transparent;
border: 1px solid var(--btn-color);
border-radius: 4px;
color: var(--btn-color);
font-family: inherit;
font-size: 0.9rem;
text-align: left;
cursor: pointer;
transition: all 0.15s ease;
}
.tui-button.no-border {
border-color: transparent;
}
.tui-button.flex-expand {
flex: 1;
width: auto;
}
/* Inline button styles */
.tui-button.inline {
width: auto;
display: inline-flex;
margin: 0;
padding: 0.35rem 0.6rem;
border: 1px solid color-mix(in srgb, var(--btn-color) 40%, transparent);
}
.tui-button.inline .btn-indicator {
display: none;
}
.tui-button:hover,
.tui-button.selected {
background: color-mix(in srgb, var(--btn-color) 15%, transparent);
border-color: var(--btn-color);
}
.btn-indicator {
color: var(--btn-color);
font-size: 0.8rem;
width: 1rem;
}
.btn-text {
flex: 1;
}
:global(.btn-arrow) {
opacity: 0.5;
}
.tui-button.selected :global(.btn-arrow) {
opacity: 1;
}
:global(.inline-icon) {
display: inline-block;
vertical-align: middle;
margin: 0 0.15em;
}
/* Mobile: inline buttons become full-width */
@media (max-width: 768px) {
.tui-button.inline {
width: 100%;
display: flex;
}
}
-232
View File
@@ -1,232 +0,0 @@
.tui-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
padding: 0.5rem 0;
width: 100%;
}
.tui-card-grid.inline {
display: inline-flex;
flex-wrap: wrap;
width: auto;
gap: 0.5rem;
vertical-align: top;
padding: 0;
margin: 0 0.5rem 0 0;
}
.tui-card {
background: var(--terminal-bg);
border: 1px solid var(--terminal-border);
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
transition: all 0.2s ease;
font-family: 'JetBrains Mono', monospace;
}
.tui-card:hover {
border-color: var(--terminal-primary);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
.tui-card.featured {
border-color: var(--terminal-accent);
}
.card-image {
position: relative;
width: 100%;
height: 140px;
overflow: hidden;
background: var(--terminal-bg-light);
}
.card-image img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.tui-card:hover .card-image img {
transform: scale(1.05);
}
.card-header-badge {
padding: 0.5rem 0.75rem 0;
}
.featured-badge {
position: absolute;
top: 0.5rem;
right: 0.5rem;
background: rgba(0, 0, 0, 0.75);
color: var(--terminal-accent);
padding: 0.15rem 0.4rem;
border-radius: 3px;
font-size: 0.65rem;
font-weight: 600;
backdrop-filter: blur(4px);
}
.card-header-badge .featured-badge {
position: static;
display: inline-block;
}
.card-body {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
flex: 1;
}
.card-title {
margin: 0;
font-size: 0.9rem;
font-weight: 600;
color: var(--terminal-text);
line-height: 1.3;
}
.card-meta {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.7rem;
color: var(--terminal-primary);
}
.meta-icon {
font-size: 0.75rem;
}
.hackathon-name {
font-weight: 500;
}
.year {
color: var(--terminal-muted);
}
.card-location {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.65rem;
color: var(--terminal-muted);
}
.awards {
display: flex;
flex-direction: column;
gap: 0.2rem;
margin: 0.25rem 0;
}
.award {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.7rem;
}
.award-icon {
font-size: 0.75rem;
}
.award-text {
color: #a6e3a1;
font-weight: 500;
}
.card-desc {
margin: 0;
font-size: 0.7rem;
color: var(--terminal-muted);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: auto;
}
.tag {
background: color-mix(in srgb, var(--terminal-primary) 15%, transparent);
color: var(--terminal-primary);
padding: 0.1rem 0.35rem;
border-radius: 3px;
font-size: 0.55rem;
font-weight: 500;
text-transform: lowercase;
}
.tui-card-grid .warning {
font-size: 0.6rem;
color: #f9e2af;
padding: 0.2rem 0.35rem;
background: rgba(249, 226, 175, 0.1);
border-radius: 3px;
width: fit-content;
}
.card-actions {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--terminal-border);
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: transparent;
border: 1px solid var(--terminal-border);
border-radius: 4px;
color: var(--terminal-text);
font-size: 0.65rem;
font-weight: 500;
text-decoration: none;
transition: all 0.15s ease;
font-family: inherit;
}
.action-btn:hover {
background: var(--terminal-primary);
border-color: var(--terminal-primary);
color: var(--terminal-bg);
}
.action-btn.primary {
background: var(--terminal-primary);
border-color: var(--terminal-primary);
color: var(--terminal-bg);
}
.action-btn.primary:hover {
background: var(--terminal-accent);
border-color: var(--terminal-accent);
}
@media (max-width: 600px) {
.tui-card-grid {
grid-template-columns: 1fr;
}
}
-46
View File
@@ -1,46 +0,0 @@
.tui-card {
border: 1px solid var(--terminal-border);
border-left: 3px solid var(--card-color);
background: color-mix(in srgb, var(--terminal-bg) 50%, var(--terminal-bg-light));
border-radius: 4px;
padding: 0;
margin: 0.5rem 0;
width: 100%;
}
.tui-card.inline {
display: inline-block;
width: auto;
margin: 0 0.5rem 0 0;
vertical-align: top;
}
.card-title {
color: var(--card-color);
font-weight: bold;
margin-bottom: 0.5rem;
font-size: 1rem;
}
.card-content {
color: var(--terminal-text);
font-size: 0.9rem;
line-height: 1.5;
}
.card-footer {
margin-top: 0.75rem;
padding-top: 0.5rem;
border-top: 1px dashed var(--terminal-border);
color: var(--terminal-muted);
font-size: 0.8rem;
}
/* Mobile: inline cards become full-width */
@media (max-width: 768px) {
.tui-card.inline {
display: block;
width: 100%;
margin: 0.5rem 0;
}
}
-55
View File
@@ -1,55 +0,0 @@
.tui-checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0.35rem 0;
padding: 0.35rem 0.5rem;
font-size: 0.9rem;
cursor: pointer;
border-radius: 4px;
transition: all 0.15s ease;
user-select: none;
}
.tui-checkbox.inline {
display: inline-flex;
margin: 0 0.75rem 0 0;
}
.tui-checkbox:hover:not(.disabled) {
background: color-mix(in srgb, var(--checkbox-color) 10%, transparent);
}
.tui-checkbox:focus-visible {
outline: 1px solid var(--checkbox-color);
outline-offset: 2px;
}
.tui-checkbox.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.checkbox-box {
font-family: inherit;
font-weight: bold;
color: var(--terminal-muted);
transition: color 0.15s ease;
}
.tui-checkbox.checked .checkbox-box,
.checkbox-box.indeterminate {
color: var(--checkbox-color);
}
:global(.checkbox-icon) {
color: var(--checkbox-color);
}
.checkbox-label {
color: var(--terminal-text);
}
.tui-checkbox.disabled .checkbox-label {
color: var(--terminal-muted);
}
-59
View File
@@ -1,59 +0,0 @@
.tui-statusbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.4rem 0.75rem;
font-size: 0.75rem;
background: var(--terminal-bg-light);
border-color: var(--terminal-border);
}
.tui-statusbar.bottom {
border-top: 1px solid var(--terminal-border);
}
.status-left, .status-right {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--terminal-muted);
}
.status-center {
color: var(--terminal-primary);
font-weight: 600;
}
.skip-btn {
display: flex;
align-items: center;
gap: 0.3rem;
padding: 0.15rem 0.5rem;
background: var(--terminal-border);
border: 1px solid transparent;
border-radius: 3px;
color: var(--terminal-muted);
font-family: inherit;
font-size: 0.7rem;
cursor: pointer;
transition: all 0.15s ease;
}
.skip-btn:hover {
background: var(--terminal-primary);
color: var(--terminal-bg);
border-color: var(--terminal-primary);
}
.typing-indicator {
animation: pulse 1s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.line-count {
color: var(--terminal-muted);
}
-71
View File
@@ -1,71 +0,0 @@
.tui-statusbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.4rem 0.75rem;
font-size: 0.75rem;
background: var(--terminal-bg-light);
border-color: var(--terminal-border);
gap: 1rem;
position: relative;
}
.tui-statusbar.top {
border-bottom: 1px solid var(--terminal-border);
}
.status-left {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--terminal-muted);
flex-shrink: 0;
z-index: 1;
}
.status-right {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--terminal-muted);
flex-shrink: 0;
z-index: 1;
}
.status-center {
position: absolute;
left: 50%;
transform: translateX(-50%);
color: var(--terminal-primary);
font-weight: 600;
text-align: center;
white-space: nowrap;
}
.hint {
padding: 0.1rem 0.4rem;
background: var(--terminal-border);
border-radius: 3px;
font-size: 0.7rem;
}
.user-host {
display: inline;
}
@media (max-width: 768px) {
.hint {
display: none;
}
.user-host {
display: none;
}
.status-center {
position: static;
transform: none;
left: 0;
}
}
-96
View File
@@ -1,96 +0,0 @@
.tui-input {
margin: 0.5rem 0;
font-size: 0.9rem;
}
.tui-input.inline {
display: inline-flex;
flex-direction: column;
margin: 0 0.5rem 0 0;
}
.input-label {
display: flex;
align-items: center;
gap: 0.35rem;
margin-bottom: 0.35rem;
color: var(--terminal-muted);
font-size: 0.85rem;
}
:global(.label-icon) {
color: var(--input-color);
}
.input-wrapper {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.5rem 0.75rem;
background: color-mix(in srgb, var(--terminal-bg) 80%, black);
border: 1px solid var(--terminal-muted);
border-radius: 4px;
transition: all 0.15s ease;
}
.input-wrapper:focus-within {
border-color: var(--input-color);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--input-color) 30%, transparent);
}
.tui-input.error .input-wrapper {
border-color: #f38ba8;
}
.tui-input.disabled .input-wrapper {
opacity: 0.5;
cursor: not-allowed;
}
.input-prompt {
color: var(--input-color);
font-weight: bold;
user-select: none;
}
.input-affix {
color: var(--terminal-muted);
font-size: 0.85rem;
}
.input-affix.prefix {
margin-right: 0.25rem;
}
.input-affix.suffix {
margin-left: 0.25rem;
}
input {
flex: 1;
min-width: 0;
background: transparent;
border: none;
color: var(--terminal-text);
font-family: inherit;
font-size: inherit;
outline: none;
}
input::placeholder {
color: var(--terminal-muted);
opacity: 0.6;
}
input:disabled {
cursor: not-allowed;
}
.input-error {
display: flex;
align-items: center;
gap: 0.35rem;
margin-top: 0.35rem;
color: #f38ba8;
font-size: 0.8rem;
}
-45
View File
@@ -1,45 +0,0 @@
.tui-link {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
:global(.link-icon) {
color: var(--link-color);
opacity: 0.8;
}
.link-text {
background: none;
border: none;
padding: 0;
margin: 0;
font-family: inherit;
font-size: inherit;
color: var(--link-color);
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 2px;
cursor: pointer;
transition: all 0.15s ease;
}
.link-text:hover {
text-decoration-style: solid;
filter: brightness(1.2);
}
:global(.link-external) {
color: var(--link-color);
opacity: 0.5;
}
.tui-link:hover :global(.link-external) {
opacity: 0.8;
}
:global(.inline-icon) {
display: inline-block;
vertical-align: middle;
margin: 0 0.15em;
}
-104
View File
@@ -1,104 +0,0 @@
.tui-progress {
margin: 0.5rem 0;
font-size: 0.85rem;
}
/* Inline progress styles */
.tui-progress.inline {
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin: 0;
min-width: 120px;
}
.tui-progress.inline .progress-label {
margin-bottom: 0;
white-space: nowrap;
}
.tui-progress.inline .progress-bar {
flex: 1;
min-width: 80px;
height: 0.8rem;
}
.tui-progress.inline .progress-value {
margin-top: 0;
white-space: nowrap;
}
.progress-label {
color: var(--terminal-text);
margin-bottom: 0.25rem;
}
.progress-bar {
position: relative;
height: 1.2rem;
background: var(--terminal-border);
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: linear-gradient(90deg, var(--progress-color), color-mix(in srgb, var(--progress-color) 80%, white 20%));
border-radius: 3px;
transition: width 0.3s ease;
}
.progress-glow {
position: absolute;
right: 0;
top: 0;
height: 100%;
width: 20px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3));
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { opacity: 0; }
50% { opacity: 1; }
100% { opacity: 0; }
}
.progress-blocks {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
gap: 2px;
padding: 3px;
}
.block {
flex: 1;
background: rgba(0, 0, 0, 0.2);
border-radius: 1px;
transition: background 0.2s;
}
.block.filled {
background: transparent;
}
.progress-value {
text-align: right;
color: var(--progress-color);
font-size: 0.75rem;
margin-top: 0.25rem;
font-weight: 600;
}
:global(.inline-icon) {
display: inline-block;
vertical-align: middle;
margin: 0 0.15em;
}
-87
View File
@@ -1,87 +0,0 @@
.tui-radio-group {
margin: 0.5rem 0;
font-size: 0.9rem;
}
.tui-radio-group.inline {
display: inline-flex;
flex-direction: column;
margin: 0 0.75rem 0 0;
}
.tui-radio-group.disabled {
opacity: 0.5;
}
.radio-label {
display: flex;
align-items: center;
gap: 0.35rem;
margin-bottom: 0.5rem;
color: var(--terminal-muted);
font-size: 0.85rem;
}
:global(.label-icon) {
color: var(--radio-color);
}
.radio-options {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.radio-options.horizontal {
flex-direction: row;
flex-wrap: wrap;
gap: 0.75rem;
}
.radio-option {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.5rem;
cursor: pointer;
border-radius: 4px;
transition: all 0.15s ease;
user-select: none;
}
.radio-option:hover:not(.option-disabled) {
background: color-mix(in srgb, var(--radio-color) 10%, transparent);
}
.radio-option:focus-visible {
outline: 1px solid var(--radio-color);
outline-offset: 2px;
}
.radio-option.option-disabled {
opacity: 0.5;
cursor: not-allowed;
}
.radio-symbol {
font-family: inherit;
font-weight: bold;
color: var(--terminal-muted);
transition: color 0.15s ease;
}
.radio-option.selected .radio-symbol {
color: var(--radio-color);
}
:global(.option-icon) {
color: var(--radio-color);
}
.option-label {
color: var(--terminal-text);
}
.radio-option.option-disabled .option-label {
color: var(--terminal-muted);
}
-162
View File
@@ -1,162 +0,0 @@
.tui-select {
position: relative;
margin: 0.5rem 0;
font-size: 0.9rem;
}
.tui-select.inline {
display: inline-flex;
flex-direction: column;
margin: 0 0.5rem 0 0;
}
.select-label {
display: flex;
align-items: center;
gap: 0.35rem;
margin-bottom: 0.35rem;
color: var(--terminal-muted);
font-size: 0.85rem;
}
:global(.label-icon) {
color: var(--select-color);
}
.select-trigger {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.5rem 0.75rem;
background: color-mix(in srgb, var(--terminal-bg) 80%, black);
border: 1px solid var(--terminal-muted);
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
}
.tui-select.open .select-trigger,
.select-trigger:focus-visible {
border-color: var(--select-color);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--select-color) 30%, transparent);
}
.tui-select.error .select-trigger {
border-color: #f38ba8;
}
.tui-select.disabled .select-trigger {
opacity: 0.5;
cursor: not-allowed;
}
.select-prompt {
color: var(--select-color);
font-weight: bold;
}
.select-value {
flex: 1;
color: var(--terminal-text);
}
.select-value.placeholder {
color: var(--terminal-muted);
opacity: 0.6;
}
.select-arrow {
color: var(--terminal-muted);
font-size: 0.7rem;
}
.select-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 0.25rem;
background: var(--terminal-bg);
border: 1px solid var(--select-color);
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 100;
overflow: hidden;
}
.select-search {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--terminal-muted);
}
.select-search input {
flex: 1;
background: transparent;
border: none;
color: var(--terminal-text);
font-family: inherit;
font-size: inherit;
outline: none;
}
.select-search input::placeholder {
color: var(--terminal-muted);
}
.select-options {
max-height: 200px;
overflow-y: auto;
}
.select-option {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
cursor: pointer;
transition: background 0.1s ease;
}
.select-option.highlighted {
background: color-mix(in srgb, var(--select-color) 15%, transparent);
}
.select-option.selected {
color: var(--select-color);
}
.select-option.option-disabled {
opacity: 0.5;
cursor: not-allowed;
}
:global(.option-icon) {
color: var(--select-color);
}
.option-label {
flex: 1;
}
:global(.check-icon) {
color: var(--select-color);
}
.select-empty {
padding: 0.75rem;
text-align: center;
color: var(--terminal-muted);
font-style: italic;
}
.select-error {
display: flex;
align-items: center;
gap: 0.35rem;
margin-top: 0.35rem;
color: #f38ba8;
font-size: 0.8rem;
}
-56
View File
@@ -1,56 +0,0 @@
.tui-table-wrapper {
margin: 0.5rem 0;
border: 1px solid var(--terminal-border);
border-radius: 6px;
overflow: hidden;
}
.tui-table-wrapper.inline {
display: inline-block;
width: auto;
vertical-align: top;
margin: 0 0.5rem 0 0;
}
.table-title {
padding: 0.5rem 0.75rem;
font-weight: 600;
font-size: 0.85rem;
color: var(--terminal-text);
background: rgba(0, 0, 0, 0.1);
border-bottom: 1px solid var(--terminal-border);
}
.tui-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
th {
padding: 0.5rem 0.75rem;
text-align: left;
font-weight: 600;
color: var(--table-accent);
background: rgba(0, 0, 0, 0.15);
border-bottom: 1px solid var(--terminal-border);
white-space: nowrap;
}
td {
padding: 0.4rem 0.75rem;
color: var(--terminal-text);
border-bottom: 1px solid var(--terminal-border);
}
tr:last-child td {
border-bottom: none;
}
tr.alt {
background: rgba(0, 0, 0, 0.03);
}
tr:hover {
background: color-mix(in srgb, var(--table-accent) 5%, transparent);
}
-113
View File
@@ -1,113 +0,0 @@
.tui-textarea {
margin: 0.5rem 0;
font-size: 0.9rem;
}
.tui-textarea.inline {
display: inline-flex;
flex-direction: column;
margin: 0 0.5rem 0 0;
}
.textarea-label {
display: flex;
align-items: center;
gap: 0.35rem;
margin-bottom: 0.35rem;
color: var(--terminal-muted);
font-size: 0.85rem;
}
:global(.label-icon) {
color: var(--input-color);
}
.textarea-wrapper {
display: flex;
background: color-mix(in srgb, var(--terminal-bg) 80%, black);
border: 1px solid var(--terminal-muted);
border-radius: 4px;
transition: all 0.15s ease;
overflow: hidden;
}
.tui-textarea.focused .textarea-wrapper {
border-color: var(--input-color);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--input-color) 30%, transparent);
}
.tui-textarea.error .textarea-wrapper {
border-color: #f38ba8;
}
.tui-textarea.disabled .textarea-wrapper {
opacity: 0.5;
cursor: not-allowed;
}
.line-numbers {
display: flex;
flex-direction: column;
padding: 0.5rem 0;
background: color-mix(in srgb, var(--terminal-bg) 60%, black);
border-right: 1px solid var(--terminal-muted);
user-select: none;
}
.line-num {
padding: 0 0.5rem;
color: var(--terminal-muted);
font-size: 0.8rem;
line-height: 1.5;
text-align: right;
min-width: 2rem;
}
textarea {
flex: 1;
min-width: 0;
padding: 0.5rem 0.75rem;
background: transparent;
border: none;
color: var(--terminal-text);
font-family: inherit;
font-size: inherit;
line-height: 1.5;
resize: vertical;
outline: none;
}
textarea::placeholder {
color: var(--terminal-muted);
opacity: 0.6;
}
textarea:disabled {
cursor: not-allowed;
resize: none;
}
.textarea-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.35rem;
}
.textarea-error {
display: flex;
align-items: center;
gap: 0.35rem;
color: #f38ba8;
font-size: 0.8rem;
}
.char-count {
margin-left: auto;
color: var(--terminal-muted);
font-size: 0.75rem;
}
.char-count.warning {
color: #f9e2af;
}
-94
View File
@@ -1,94 +0,0 @@
.tui-toggle {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0.35rem 0;
padding: 0.35rem 0.5rem;
font-size: 0.9rem;
cursor: pointer;
border-radius: 4px;
transition: all 0.15s ease;
user-select: none;
}
.tui-toggle.inline {
display: inline-flex;
margin: 0 0.75rem 0 0;
}
.tui-toggle:hover:not(.disabled) {
background: color-mix(in srgb, var(--toggle-color) 10%, transparent);
}
.tui-toggle:focus-visible {
outline: 1px solid var(--toggle-color);
outline-offset: 2px;
}
.tui-toggle.disabled {
opacity: 0.5;
cursor: not-allowed;
}
:global(.toggle-icon) {
color: var(--toggle-color);
}
.toggle-label {
color: var(--terminal-text);
}
.toggle-track {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: color-mix(in srgb, var(--terminal-bg) 80%, black);
border: 1px solid var(--terminal-muted);
border-radius: 4px;
font-size: 0.75rem;
transition: all 0.15s ease;
}
.tui-toggle.checked .toggle-track {
border-color: var(--toggle-color);
background: color-mix(in srgb, var(--toggle-color) 15%, transparent);
}
.toggle-off-label,
.toggle-on-label {
color: var(--terminal-muted);
font-weight: bold;
text-transform: uppercase;
min-width: 2rem;
text-align: center;
transition: color 0.15s ease;
}
.tui-toggle:not(.checked) .toggle-off-label {
color: var(--terminal-text);
}
.tui-toggle.checked .toggle-on-label {
color: var(--toggle-color);
}
.toggle-switch {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
position: relative;
}
.toggle-knob {
font-size: 1rem;
color: var(--terminal-muted);
transition: all 0.2s ease;
transform: translateX(-0.5rem);
}
.tui-toggle.checked .toggle-knob {
color: var(--toggle-color);
transform: translateX(0.5rem);
}
-78
View File
@@ -1,78 +0,0 @@
.tui-tooltip-trigger {
position: relative;
display: inline-flex;
align-items: center;
gap: 0.25rem;
cursor: help;
}
.trigger-text {
border-bottom: 1px dotted var(--tooltip-color);
}
.tooltip-indicator {
font-size: 0.7rem;
color: var(--tooltip-color);
opacity: 0.7;
}
.tooltip {
position: fixed;
z-index: 99999;
padding: 0.5rem 0.75rem;
background: var(--terminal-bg);
border: 1px solid var(--tooltip-color);
border-radius: 4px;
font-size: 0.8rem;
color: var(--terminal-text);
white-space: nowrap;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
animation: tooltipFadeIn 0.15s ease-out;
pointer-events: none;
}
@keyframes tooltipFadeIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* Arrow styles - simplified for fixed positioning */
.tooltip-arrow {
position: absolute;
width: 8px;
height: 8px;
background: var(--terminal-bg);
border: 1px solid var(--tooltip-color);
border-top: none;
border-left: none;
}
.tooltip.top .tooltip-arrow {
bottom: -5px;
left: 50%;
transform: translateX(-50%) rotate(45deg);
}
.tooltip.bottom .tooltip-arrow {
top: -5px;
left: 50%;
transform: translateX(-50%) rotate(-135deg);
}
.tooltip.left .tooltip-arrow {
right: -5px;
top: 50%;
transform: translateY(-50%) rotate(-45deg);
}
.tooltip.right .tooltip-arrow {
left: -5px;
top: 50%;
transform: translateY(-50%) rotate(135deg);
}
-90
View File
@@ -1,90 +0,0 @@
{
"name": "Arch Linux",
"icon": "🐧",
"dark": {
"colors": {
"primary": "#1793d1",
"secondary": "#21262d",
"accent": "#23d18b",
"background": "#0d1117",
"backgroundLight": "#161b22",
"text": "#c9d1d9",
"textMuted": "#8b949e",
"border": "#484f58",
"terminal": "#0d1117",
"terminalPrompt": "#1793d1",
"terminalUser": "#23d18b",
"terminalPath": "#1793d1"
},
"colorMap": {
"red": "#ff7b72",
"green": "#3fb950",
"yellow": "#d29922",
"blue": "#58a6ff",
"magenta": "#bc8cff",
"cyan": "#39c5cf",
"white": "#f0f6fc",
"gray": "#6e7681",
"orange": "#ffa657",
"pink": "#ff79c6",
"black": "#484f58",
"surface": "#21262d",
"teal": "#39c5cf",
"sky": "#58a6ff",
"sapphire": "#1793d1",
"lavender": "#bc8cff",
"peach": "#ffa657",
"maroon": "#ff7b72",
"mauve": "#bc8cff",
"flamingo": "#e06c75",
"rosewater": "#e8b4b8",
"error": "#ff7b72",
"success": "#3fb950",
"warning": "#d29922",
"info": "#1793d1"
}
},
"light": {
"colors": {
"primary": "#1793d1",
"secondary": "#ddf4ff",
"accent": "#0969da",
"background": "#ffffff",
"backgroundLight": "#f6f8fa",
"text": "#24292f",
"textMuted": "#57606a",
"border": "#d0d7de",
"terminal": "#ffffff",
"terminalPrompt": "#1793d1",
"terminalUser": "#0969da",
"terminalPath": "#1793d1"
},
"colorMap": {
"red": "#cf222e",
"green": "#1a7f37",
"yellow": "#9a6700",
"blue": "#0969da",
"magenta": "#8250df",
"cyan": "#1b7c83",
"white": "#24292f",
"gray": "#6e7781",
"orange": "#bc4c00",
"pink": "#bf3989",
"black": "#24292f",
"surface": "#f6f8fa",
"teal": "#1b7c83",
"sky": "#0969da",
"sapphire": "#1793d1",
"lavender": "#8250df",
"peach": "#bc4c00",
"maroon": "#cf222e",
"mauve": "#8250df",
"flamingo": "#bf3989",
"rosewater": "#d0d7de",
"error": "#cf222e",
"success": "#1a7f37",
"warning": "#9a6700",
"info": "#0969da"
}
}
}
@@ -1,90 +0,0 @@
{
"name": "Catppuccin",
"icon": "🐱",
"dark": {
"colors": {
"primary": "#89b4fa",
"secondary": "#585b70",
"accent": "#a6e3a1",
"background": "#1e1e2e",
"backgroundLight": "#313244",
"text": "#cdd6f4",
"textMuted": "#a6adc8",
"border": "#6c7086",
"terminal": "#1e1e2e",
"terminalPrompt": "#cba6f7",
"terminalUser": "#a6e3a1",
"terminalPath": "#89b4fa"
},
"colorMap": {
"red": "#f38ba8",
"green": "#a6e3a1",
"yellow": "#f9e2af",
"blue": "#89b4fa",
"magenta": "#cba6f7",
"cyan": "#94e2d5",
"white": "#cdd6f4",
"gray": "#6c7086",
"orange": "#fab387",
"pink": "#f5c2e7",
"black": "#45475a",
"surface": "#313244",
"teal": "#94e2d5",
"sky": "#89dceb",
"sapphire": "#74c7ec",
"lavender": "#b4befe",
"peach": "#fab387",
"maroon": "#eba0ac",
"mauve": "#cba6f7",
"flamingo": "#f2cdcd",
"rosewater": "#f5e0dc",
"error": "#f38ba8",
"success": "#a6e3a1",
"warning": "#f9e2af",
"info": "#89b4fa"
}
},
"light": {
"colors": {
"primary": "#1e66f5",
"secondary": "#acb0be",
"accent": "#40a02b",
"background": "#eff1f5",
"backgroundLight": "#e6e9ef",
"text": "#4c4f69",
"textMuted": "#6c6f85",
"border": "#9ca0b0",
"terminal": "#eff1f5",
"terminalPrompt": "#8839ef",
"terminalUser": "#40a02b",
"terminalPath": "#1e66f5"
},
"colorMap": {
"red": "#d20f39",
"green": "#40a02b",
"yellow": "#df8e1d",
"blue": "#1e66f5",
"magenta": "#8839ef",
"cyan": "#179299",
"white": "#4c4f69",
"gray": "#9ca0b0",
"orange": "#fe640b",
"pink": "#ea76cb",
"black": "#bcc0cc",
"surface": "#ccd0da",
"teal": "#179299",
"sky": "#04a5e5",
"sapphire": "#209fb5",
"lavender": "#7287fd",
"peach": "#fe640b",
"maroon": "#e64553",
"mauve": "#8839ef",
"flamingo": "#dd7878",
"rosewater": "#dc8a78",
"error": "#d20f39",
"success": "#40a02b",
"warning": "#df8e1d",
"info": "#1e66f5"
}
}
}
-90
View File
@@ -1,90 +0,0 @@
{
"name": "Cerberus",
"icon": "🐕",
"dark": {
"colors": {
"primary": "#818cf8",
"secondary": "#e879f9",
"accent": "#2dd4bf",
"background": "#171717",
"backgroundLight": "#262626",
"text": "#f5f5f5",
"textMuted": "#a3a3a3",
"border": "#404040",
"terminal": "#171717",
"terminalPrompt": "#818cf8",
"terminalUser": "#2dd4bf",
"terminalPath": "#e879f9"
},
"colorMap": {
"red": "#ef4444",
"green": "#2dd4bf",
"yellow": "#fbbf24",
"blue": "#818cf8",
"magenta": "#e879f9",
"cyan": "#67e8f9",
"white": "#f5f5f5",
"gray": "#737373",
"orange": "#fb923c",
"pink": "#f472b6",
"black": "#404040",
"surface": "#262626",
"teal": "#2dd4bf",
"sky": "#38bdf8",
"sapphire": "#3b82f6",
"lavender": "#a78bfa",
"peach": "#fdba74",
"maroon": "#f43f5e",
"mauve": "#c084fc",
"flamingo": "#f472b6",
"rosewater": "#fb7185",
"error": "#ef4444",
"success": "#2dd4bf",
"warning": "#fbbf24",
"info": "#818cf8"
}
},
"light": {
"colors": {
"primary": "#4f46e5",
"secondary": "#c026d3",
"accent": "#0d9488",
"background": "#ffffff",
"backgroundLight": "#f5f5f5",
"text": "#171717",
"textMuted": "#525252",
"border": "#d4d4d4",
"terminal": "#ffffff",
"terminalPrompt": "#4f46e5",
"terminalUser": "#0d9488",
"terminalPath": "#c026d3"
},
"colorMap": {
"red": "#dc2626",
"green": "#0f766e",
"yellow": "#d97706",
"blue": "#4f46e5",
"magenta": "#c026d3",
"cyan": "#0891b2",
"white": "#171717",
"gray": "#525252",
"orange": "#ea580c",
"pink": "#db2777",
"black": "#262626",
"surface": "#f5f5f5",
"teal": "#0f766e",
"sky": "#0284c7",
"sapphire": "#1d4ed8",
"lavender": "#7e22ce",
"peach": "#f97316",
"maroon": "#e11d48",
"mauve": "#9333ea",
"flamingo": "#be185d",
"rosewater": "#e11d48",
"error": "#dc2626",
"success": "#0f766e",
"warning": "#d97706",
"info": "#4f46e5"
}
}
}
-90
View File
@@ -1,90 +0,0 @@
{
"name": "Rose",
"icon": "🌹",
"dark": {
"colors": {
"primary": "#fb7185",
"secondary": "#c084fc",
"accent": "#2dd4bf",
"background": "#1f181b",
"backgroundLight": "#2e2327",
"text": "#ffe4e6",
"textMuted": "#fda4af",
"border": "#5f484f",
"terminal": "#1f181b",
"terminalPrompt": "#fb7185",
"terminalUser": "#2dd4bf",
"terminalPath": "#c084fc"
},
"colorMap": {
"red": "#f43f5e",
"green": "#34d399",
"yellow": "#fde047",
"blue": "#60a5fa",
"magenta": "#e879f9",
"cyan": "#67e8f9",
"white": "#ffe4e6",
"gray": "#886e75",
"orange": "#fb923c",
"pink": "#fb7185",
"black": "#5f484f",
"surface": "#2e2327",
"teal": "#2dd4bf",
"sky": "#7dd3fc",
"sapphire": "#3b82f6",
"lavender": "#c084fc",
"peach": "#fdba74",
"maroon": "#be123c",
"mauve": "#d8b4fe",
"flamingo": "#f472b6",
"rosewater": "#fecdd3",
"error": "#f43f5e",
"success": "#34d399",
"warning": "#fde047",
"info": "#60a5fa"
}
},
"light": {
"colors": {
"primary": "#be123c",
"secondary": "#7e22ce",
"accent": "#0f766e",
"background": "#fff1f2",
"backgroundLight": "#ffe4e6",
"text": "#4c0519",
"textMuted": "#9f1239",
"border": "#fecdd3",
"terminal": "#fff1f2",
"terminalPrompt": "#be123c",
"terminalUser": "#0f766e",
"terminalPath": "#7e22ce"
},
"colorMap": {
"red": "#e11d48",
"green": "#059669",
"yellow": "#d97706",
"blue": "#2563eb",
"magenta": "#9333ea",
"cyan": "#0891b2",
"white": "#4c0519",
"gray": "#9f1239",
"orange": "#ea580c",
"pink": "#db2777",
"black": "#881337",
"surface": "#ffe4e6",
"teal": "#0f766e",
"sky": "#0284c7",
"sapphire": "#1d4ed8",
"lavender": "#7e22ce",
"peach": "#f97316",
"maroon": "#881337",
"mauve": "#a855f7",
"flamingo": "#be185d",
"rosewater": "#e11d48",
"error": "#e11d48",
"success": "#059669",
"warning": "#d97706",
"info": "#2563eb"
}
}
}
-90
View File
@@ -1,90 +0,0 @@
{
"name": "Wintry",
"icon": "❄️",
"dark": {
"colors": {
"primary": "#7dd3fc",
"secondary": "#334155",
"accent": "#2dd4bf",
"background": "#0f172a",
"backgroundLight": "#1e293b",
"text": "#f1f5f9",
"textMuted": "#94a3b8",
"border": "#475569",
"terminal": "#0f172a",
"terminalPrompt": "#7dd3fc",
"terminalUser": "#2dd4bf",
"terminalPath": "#818cf8"
},
"colorMap": {
"red": "#f43f5e",
"green": "#4ade80",
"yellow": "#fde047",
"blue": "#60a5fa",
"magenta": "#c084fc",
"cyan": "#67e8f9",
"white": "#f1f5f9",
"gray": "#64748b",
"orange": "#fb923c",
"pink": "#f472b6",
"black": "#334155",
"surface": "#1e293b",
"teal": "#2dd4bf",
"sky": "#7dd3fc",
"sapphire": "#3b82f6",
"lavender": "#a78bfa",
"peach": "#fdba74",
"maroon": "#fda4af",
"mauve": "#d8b4fe",
"flamingo": "#f9a8d4",
"rosewater": "#fecdd3",
"error": "#f43f5e",
"success": "#4ade80",
"warning": "#fde047",
"info": "#60a5fa"
}
},
"light": {
"colors": {
"primary": "#0284c7",
"secondary": "#e2e8f0",
"accent": "#0d9488",
"background": "#f8fafc",
"backgroundLight": "#ffffff",
"text": "#0f172a",
"textMuted": "#64748b",
"border": "#cbd5e1",
"terminal": "#ffffff",
"terminalPrompt": "#0284c7",
"terminalUser": "#0d9488",
"terminalPath": "#4f46e5"
},
"colorMap": {
"red": "#e11d48",
"green": "#16a34a",
"yellow": "#d97706",
"blue": "#2563eb",
"magenta": "#9333ea",
"cyan": "#0891b2",
"white": "#0f172a",
"gray": "#64748b",
"orange": "#ea580c",
"pink": "#db2777",
"black": "#1e293b",
"surface": "#e2e8f0",
"teal": "#0d9488",
"sky": "#0284c7",
"sapphire": "#1d4ed8",
"lavender": "#7c3aed",
"peach": "#f97316",
"maroon": "#be123c",
"mauve": "#a855f7",
"flamingo": "#be185d",
"rosewater": "#e11d48",
"error": "#e11d48",
"success": "#16a34a",
"warning": "#d97706",
"info": "#2563eb"
}
}
}
+22 -24
View File
@@ -1,19 +1,27 @@
import { user } from '$lib/config';
import { profile } from '#lib/content';
import { parseFrontmatter } from './frontmatter';
import { renderMarkdown } from './markdown';
export interface BlogPost {
export interface BlogPostSummary {
slug: string;
title: string;
author: string;
date: string;
tags: string[];
excerpt: string;
html: string;
readingTime: number;
}
const postFiles = import.meta.glob('/blogs/*.md', {
export interface BlogPost extends BlogPostSummary {
html: string;
}
export interface BlogPostLink {
slug: string;
title: string;
}
const postFiles = import.meta.glob('/content/blog/*.md', {
eager: true,
query: '?raw',
import: 'default'
@@ -24,8 +32,8 @@ function slugFromPath(path: string): string {
}
function estimateReadingTime(content: string): number {
const words = content.trim().split(/\s+/).filter(Boolean).length;
return Math.max(1, Math.round(words / 200));
const wordCount = content.trim().split(/\s+/).filter(Boolean).length;
return Math.max(1, Math.round(wordCount / 200));
}
function buildPost(path: string, raw: string): BlogPost {
@@ -35,7 +43,7 @@ function buildPost(path: string, raw: string): BlogPost {
return {
slug,
title: (data.title as string) || slug,
author: (data.author as string) || user.displayname,
author: (data.author as string) || profile.displayName,
date: (data.date as string) || '',
tags: (data.tags as string[]) || [],
excerpt: (data.excerpt as string) || '',
@@ -46,27 +54,21 @@ function buildPost(path: string, raw: string): BlogPost {
const allPosts: BlogPost[] = Object.entries(postFiles)
.map(([path, raw]) => buildPost(path, raw))
.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));
.sort((first, second) => second.date.localeCompare(first.date));
export function getAllPosts(): BlogPost[] {
return allPosts;
}
export function getPostSummaries(): BlogPostSummary[] {
return allPosts.map(({ html, ...summary }) => summary);
}
export function getPostBySlug(slug: string): BlogPost | undefined {
return allPosts.find((post) => post.slug === slug);
}
export interface BlogPostNav {
slug: string;
title: string;
}
export interface AdjacentPosts {
previous: BlogPostNav | null;
next: BlogPostNav | null;
}
export function getAdjacentPosts(slug: string): AdjacentPosts {
export function getAdjacentPosts(slug: string): { previous: BlogPostLink | null; next: BlogPostLink | null } {
const index = allPosts.findIndex((post) => post.slug === slug);
if (index === -1) return { previous: null, next: null };
@@ -80,9 +82,5 @@ export function getAdjacentPosts(slug: string): AdjacentPosts {
}
export function getAllTags(): string[] {
const tags = new Set<string>();
for (const post of allPosts) {
for (const tag of post.tags) tags.add(tag);
}
return Array.from(tags).sort();
return [...new Set(allPosts.flatMap((post) => post.tags))].sort();
}
-61
View File
@@ -1,61 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Canvas } from '@threlte/core';
import { T } from '@threlte/core';
import ParticleField from './ParticleField.svelte';
import { themeColors, mode } from '$lib/stores/theme';
import '$lib/assets/css/background-3d.css';
// Reactive theme colors
const bgColor = $derived($themeColors.background);
const primaryColor = $derived($themeColors.primary);
let webglAvailable = $state(false);
function isWebGLAvailable() {
try {
const testCanvas = document.createElement('canvas');
const context =
testCanvas.getContext('webgl2') ||
testCanvas.getContext('webgl') ||
testCanvas.getContext('experimental-webgl');
return !!(window.WebGLRenderingContext && context);
} catch {
return false;
}
}
onMount(() => {
webglAvailable = isWebGLAvailable();
});
</script>
<div class="scene-container" style="--scene-bg: {bgColor};">
{#if webglAvailable}
<Canvas>
<T.PerspectiveCamera
makeDefault
position={[0, 0, 8]}
fov={75}
near={0.1}
far={1000}
/>
<!-- Ambient light -->
<T.AmbientLight intensity={0.3} />
<!-- Directional light with theme color -->
<T.DirectionalLight
position={[5, 5, 5]}
intensity={0.5}
color={primaryColor}
/>
<!-- Particle system -->
<ParticleField particleCount={600} />
<!-- Background color -->
<T.Color args={[bgColor]} attach="background" />
</Canvas>
{/if}
</div>
-624
View File
@@ -1,624 +0,0 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { themeColors } from "$lib/stores/theme";
import "$lib/assets/css/model-viewer.css";
import * as THREE from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import Icon from "@iconify/svelte";
interface Props {
modelPath: string;
modelName?: string;
class?: string;
}
let {
modelPath,
modelName = "Model",
class: className = "",
}: Props = $props();
let container: HTMLDivElement;
let scene: THREE.Scene;
let camera: THREE.PerspectiveCamera;
let renderer: THREE.WebGLRenderer;
let controls: OrbitControls;
let model: THREE.Group | null = null;
let animationId: number;
let sceneInitialized = false;
let isLoading = $state(true);
let loadError = $state<string | null>(null);
let isFullscreen = $state(false);
let autoRotate = $state(true);
let wireframe = $state(false);
let showGround = $state(true);
let showGrid = $state(false);
let showAxes = $state(false);
let lightIntensity = $state(1);
let rotationSpeed = $state(2);
let showControls = $state(false);
let ground: THREE.Mesh | null = null;
let gridHelper: THREE.GridHelper | null = null;
let axesHelper: THREE.AxesHelper | null = null;
// Watch for modelPath changes
$effect(() => {
if (modelPath && sceneInitialized) {
// Remove old model
if (model) {
scene.remove(model);
model.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.geometry.dispose();
if (Array.isArray(child.material)) {
child.material.forEach((m) => m.dispose());
} else {
child.material.dispose();
}
}
});
model = null;
}
isLoading = true;
loadError = null;
loadModel();
}
});
function initScene() {
const width = container.clientWidth;
const height = container.clientHeight;
// Scene
scene = new THREE.Scene();
scene.background = null; // Transparent background
// Camera
camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000);
camera.position.set(0, 1, 3);
// Renderer
renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
powerPreference: "high-performance",
});
renderer.setSize(width, height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.2;
container.appendChild(renderer.domElement);
// Controls
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.enablePan = false;
controls.minDistance = 1;
controls.maxDistance = 10;
controls.autoRotate = autoRotate;
controls.autoRotateSpeed = 2;
// Lighting
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
scene.add(ambientLight);
const directionalLight1 = new THREE.DirectionalLight(0xffffff, 1);
directionalLight1.position.set(5, 5, 5);
directionalLight1.castShadow = true;
directionalLight1.userData.baseIntensity = 1;
scene.add(directionalLight1);
const directionalLight2 = new THREE.DirectionalLight(0xffffff, 0.5);
directionalLight2.position.set(-5, 3, -5);
directionalLight2.userData.baseIntensity = 0.5;
scene.add(directionalLight2);
// Subtle rim light
const rimLight = new THREE.DirectionalLight(0x88ccff, 0.3);
rimLight.position.set(0, 3, -5);
rimLight.userData.baseIntensity = 0.3;
scene.add(rimLight);
// Ground plane (subtle)
const groundGeometry = new THREE.CircleGeometry(2, 32);
const groundMaterial = new THREE.MeshStandardMaterial({
color: 0x222222,
transparent: true,
opacity: 0.3,
});
ground = new THREE.Mesh(groundGeometry, groundMaterial);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -0.5;
ground.receiveShadow = true;
ground.receiveShadow = true;
scene.add(ground);
// Grid Helper
gridHelper = new THREE.GridHelper(10, 10, 0x444444, 0x222222);
gridHelper.position.y = -0.49; // Slightly above ground to avoid z-fighting
gridHelper.visible = showGrid;
scene.add(gridHelper);
// Axes Helper
axesHelper = new THREE.AxesHelper(2);
axesHelper.visible = showAxes;
scene.add(axesHelper);
sceneInitialized = true;
loadModel();
}
function loadModel() {
const loader = new GLTFLoader();
loader.load(
modelPath,
(gltf) => {
model = gltf.scene;
// Center and scale the model
const box = new THREE.Box3().setFromObject(model);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
// Center the model
model.position.sub(center);
// Scale to fit
const maxDim = Math.max(size.x, size.y, size.z);
const scale = 1.5 / maxDim;
model.scale.setScalar(scale);
// Enable shadows
model.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.castShadow = true;
child.receiveShadow = true;
}
});
scene.add(model);
isLoading = false;
// Adjust camera based on model
camera.position.set(0, size.y * scale * 0.5, 2.5);
controls.target.set(0, 0, 0);
controls.update();
},
(progress) => {
// Loading progress
},
(error) => {
console.error("Error loading model:", error);
loadError = "Failed to load model";
isLoading = false;
},
);
}
function animate() {
animationId = requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}
function handleResize() {
if (!container || !camera || !renderer) return;
const width = container.clientWidth;
const height = container.clientHeight;
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height);
}
function toggleAutoRotate() {
autoRotate = !autoRotate;
if (controls) {
controls.autoRotate = autoRotate;
}
}
function toggleFullscreen() {
isFullscreen = !isFullscreen;
setTimeout(handleResize, 100);
}
function resetCamera() {
if (!camera || !controls) return;
camera.position.set(0, 1, 3);
controls.target.set(0, 0, 0);
controls.update();
}
function toggleWireframe() {
wireframe = !wireframe;
if (model) {
model.traverse((child) => {
if (child instanceof THREE.Mesh && child.material) {
if (Array.isArray(child.material)) {
child.material.forEach(
(m) => (m.wireframe = wireframe),
);
} else {
child.material.wireframe = wireframe;
}
}
});
}
}
function toggleGrid() {
showGrid = !showGrid;
if (gridHelper) {
gridHelper.visible = showGrid;
}
}
function toggleAxes() {
showAxes = !showAxes;
if (axesHelper) {
axesHelper.visible = showAxes;
}
}
function adjustRotationSpeed(delta: number) {
rotationSpeed = Math.max(0, Math.min(10, rotationSpeed + delta));
if (controls) {
controls.autoRotateSpeed = rotationSpeed;
}
}
function toggleGround() {
showGround = !showGround;
if (ground) {
ground.visible = showGround;
}
}
function adjustLighting(delta: number) {
lightIntensity = Math.max(0.2, Math.min(2, lightIntensity + delta));
if (scene) {
scene.traverse((child) => {
if (child instanceof THREE.DirectionalLight) {
child.intensity =
child.userData.baseIntensity * lightIntensity;
} else if (child instanceof THREE.AmbientLight) {
child.intensity = 0.4 * lightIntensity;
}
});
}
}
function zoomIn() {
if (!camera || !controls) return;
const direction = new THREE.Vector3();
camera.getWorldDirection(direction);
camera.position.addScaledVector(direction, 0.5);
controls.update();
}
function zoomOut() {
if (!camera || !controls) return;
const direction = new THREE.Vector3();
camera.getWorldDirection(direction);
camera.position.addScaledVector(direction, -0.5);
controls.update();
}
function rotateCamera(direction: "left" | "right" | "up" | "down") {
if (!controls) return;
const rotateSpeed = 0.1;
const spherical = new THREE.Spherical();
const offset = new THREE.Vector3();
// Get current camera position relative to target
offset.copy(camera.position).sub(controls.target);
spherical.setFromVector3(offset);
switch (direction) {
case "left":
spherical.theta += rotateSpeed;
break;
case "right":
spherical.theta -= rotateSpeed;
break;
case "up":
spherical.phi = Math.max(0.1, spherical.phi - rotateSpeed);
break;
case "down":
spherical.phi = Math.min(
Math.PI - 0.1,
spherical.phi + rotateSpeed,
);
break;
}
offset.setFromSpherical(spherical);
camera.position.copy(controls.target).add(offset);
controls.update();
}
function handleKeydown(event: KeyboardEvent) {
// Only handle if this component is focused or in fullscreen
if (!container?.contains(document.activeElement) && !isFullscreen)
return;
switch (event.key) {
case "ArrowLeft":
event.preventDefault();
rotateCamera("left");
break;
case "ArrowRight":
event.preventDefault();
rotateCamera("right");
break;
case "ArrowUp":
event.preventDefault();
rotateCamera("up");
break;
case "ArrowDown":
event.preventDefault();
rotateCamera("down");
break;
case "Escape":
if (isFullscreen) {
isFullscreen = false;
setTimeout(handleResize, 100);
}
break;
}
}
function toggleControlsPanel() {
showControls = !showControls;
}
onMount(() => {
if (container) {
try {
initScene();
animate();
} catch (err) {
console.error("WebGL unavailable:", err);
loadError = "3D viewer unavailable (WebGL not supported)";
isLoading = false;
return;
}
window.addEventListener("resize", handleResize);
window.addEventListener("keydown", handleKeydown);
}
});
onDestroy(() => {
if (animationId) {
cancelAnimationFrame(animationId);
}
if (renderer) {
renderer.dispose();
}
window.removeEventListener("resize", handleResize);
window.removeEventListener("keydown", handleKeydown);
});
</script>
<div
class="model-viewer {className}"
class:fullscreen={isFullscreen}
style="
--viewer-bg: {$themeColors.terminal};
--viewer-border: {$themeColors.border};
--viewer-text: {$themeColors.text};
--viewer-muted: {$themeColors.textMuted};
--viewer-primary: {$themeColors.primary};
--viewer-accent: {$themeColors.accent};
"
>
<!-- Header -->
<div class="viewer-header">
<div class="header-left">
<Icon icon="mdi:cube-outline" width="16" />
<span class="model-name">{modelName}</span>
</div>
<div class="header-controls">
<button
class="control-btn"
title={autoRotate ? "Stop rotation" : "Auto rotate"}
onclick={toggleAutoRotate}
class:active={autoRotate}
>
<Icon
icon={autoRotate
? "mdi:rotate-3d"
: "mdi:rotate-3d-variant"}
width="16"
/>
</button>
<button
class="control-btn"
title="Reset camera"
onclick={resetCamera}
>
<Icon icon="mdi:camera-flip" width="16" />
</button>
<button
class="control-btn"
title="More controls"
onclick={toggleControlsPanel}
class:active={showControls}
>
<Icon icon="mdi:tune" width="16" />
</button>
<button
class="control-btn"
title={isFullscreen ? "Exit fullscreen" : "Fullscreen"}
onclick={toggleFullscreen}
>
<Icon
icon={isFullscreen
? "mdi:fullscreen-exit"
: "mdi:fullscreen"}
width="16"
/>
</button>
</div>
</div>
<!-- Extended controls panel -->
{#if showControls}
<div class="controls-panel">
<div class="control-group">
<span class="control-label">View</span>
<div class="control-buttons">
<button
class="control-btn small"
title="Zoom in"
onclick={zoomIn}
>
<Icon icon="mdi:magnify-plus" width="14" />
</button>
<button
class="control-btn small"
title="Zoom out"
onclick={zoomOut}
>
<Icon icon="mdi:magnify-minus" width="14" />
</button>
</div>
</div>
<div class="control-group">
<span class="control-label">Lighting</span>
<div class="control-buttons">
<button
class="control-btn small"
title="Decrease brightness"
onclick={() => adjustLighting(-0.2)}
>
<Icon icon="mdi:brightness-5" width="14" />
</button>
<span class="control-value"
>{Math.round(lightIntensity * 100)}%</span
>
<button
class="control-btn small"
title="Increase brightness"
onclick={() => adjustLighting(0.2)}
>
<Icon icon="mdi:brightness-7" width="14" />
</button>
</div>
</div>
<div class="control-group">
<span class="control-label">Display</span>
<div class="control-buttons">
<button
class="control-btn small"
title={wireframe ? "Solid view" : "Wireframe view"}
onclick={toggleWireframe}
class:active={wireframe}
>
<Icon
icon={wireframe ? "mdi:cube" : "mdi:cube-outline"}
width="14"
/>
</button>
<button
class="control-btn small"
title={showGround ? "Hide ground" : "Show ground"}
onclick={toggleGround}
class:active={showGround}
>
<Icon icon="mdi:checkerboard" width="14" />
</button>
<button
class="control-btn small"
title={showGrid ? "Hide grid" : "Show grid"}
onclick={toggleGrid}
class:active={showGrid}
>
<Icon icon="mdi:grid" width="14" />
</button>
<button
class="control-btn small"
title={showAxes ? "Hide axes" : "Show axes"}
onclick={toggleAxes}
class:active={showAxes}
>
<Icon icon="mdi:axis-arrow" width="14" />
</button>
</div>
</div>
<div class="control-group">
<span class="control-label">Rotation</span>
<div class="control-buttons">
<button
class="control-btn small"
title="Slower rotation"
onclick={() => adjustRotationSpeed(-0.5)}
disabled={!autoRotate}
>
<Icon icon="mdi:minus" width="14" />
</button>
<span class="control-value">{rotationSpeed.toFixed(1)}</span
>
<button
class="control-btn small"
title="Faster rotation"
onclick={() => adjustRotationSpeed(0.5)}
disabled={!autoRotate}
>
<Icon icon="mdi:plus" width="14" />
</button>
</div>
</div>
</div>
{/if}
<!-- Canvas container -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
class="canvas-container"
bind:this={container}
tabindex="0"
role="application"
aria-label="3D model viewer - use arrow keys to rotate"
>
{#if isLoading}
<div class="loading-overlay">
<Icon icon="mdi:loading" width="32" class="spin" />
<span>Loading model...</span>
</div>
{/if}
{#if loadError}
<div class="error-overlay">
<Icon icon="mdi:alert-circle" width="32" />
<span>{loadError}</span>
</div>
{/if}
</div>
<!-- Footer hints -->
<div class="viewer-footer">
<span class="hint">
<Icon icon="mdi:mouse" width="14" />
Drag to rotate
</span>
<span class="hint">
<Icon icon="mdi:magnify-plus-minus" width="14" />
Scroll to zoom
</span>
<span class="hint">
<Icon icon="mdi:arrow-all" width="14" />
Arrow keys
</span>
</div>
</div>
-144
View File
@@ -1,144 +0,0 @@
<script lang="ts">
import { mode, colorTheme, toggleMode, setColorTheme, themeOptions, themeColors, type ColorTheme } from '$lib/stores/theme';
import { fly, fade } from 'svelte/transition';
import { user, navigation } from '$lib/config';
import Icon from '@iconify/svelte';
import '$lib/assets/css/navbar.css';
let themeDropdownOpen = $state(false);
let navExpanded = $state(false);
function handleThemeSelect(theme: ColorTheme) {
setColorTheme(theme);
themeDropdownOpen = false;
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
themeDropdownOpen = false;
}
}
function getThemeIcon(theme: ColorTheme): string {
switch (theme) {
case 'arch': return 'mdi:arch';
case 'catppuccin': return 'solar:cat-bold';
default: return 'mdi:palette';
}
}
</script>
<svelte:window on:keydown={handleKeydown} />
<nav
class="navbar"
style="
--nav-bg: {$themeColors.backgroundLight};
--nav-border: {$themeColors.border};
--nav-text: {$themeColors.text};
--nav-primary: {$themeColors.primary};
--nav-accent: {$themeColors.accent};
--nav-muted: {$themeColors.textMuted};
"
>
<div class="nav-container">
<!-- Logo / Brand -->
<a href="/" class="nav-brand">
<span class="terminal-prompt">
<span class="user">{user.username}@{user.hostname}</span>
<span class="separator">:</span>
<span class="path">~</span>
<span class="symbol">$</span>
</span>
<span class="cursor"></span>
</a>
<!-- Mobile Menu Toggle -->
<button
class="mobile-toggle"
onclick={() => navExpanded = !navExpanded}
aria-label="Toggle navigation"
>
<span class="hamburger" class:open={navExpanded}></span>
</button>
<!-- Navigation Links -->
<div class="nav-links" class:expanded={navExpanded}>
{#each navigation as nav}
<a href={nav.path} class="nav-link" onclick={() => navExpanded = false}>
<span class="link-prefix">./</span>{nav.name}
</a>
{/each}
</div>
<!-- Theme Controls -->
<div class="nav-controls">
<!-- Theme Selector Dropdown -->
<div class="theme-selector">
<button
class="theme-button"
onclick={() => themeDropdownOpen = !themeDropdownOpen}
aria-expanded={themeDropdownOpen}
aria-haspopup="true"
>
<span class="theme-icon">
<Icon icon={getThemeIcon($colorTheme)} width="18" />
</span>
<span class="theme-label">{$colorTheme}</span>
<Icon icon="mdi:chevron-down" width="16" class="dropdown-arrow {themeDropdownOpen ? 'open' : ''}" />
</button>
{#if themeDropdownOpen}
<div
class="theme-dropdown"
transition:fly={{ y: -10, duration: 200 }}
>
{#each themeOptions as option}
<button
class="theme-option"
class:active={$colorTheme === option.value}
onclick={() => handleThemeSelect(option.value)}
>
<span class="option-icon">
<Icon icon={getThemeIcon(option.value)} width="18" />
</span>
<span class="option-label">{option.label}</span>
{#if $colorTheme === option.value}
<Icon icon="mdi:check" width="16" class="check-mark" />
{/if}
</button>
{/each}
</div>
{/if}
</div>
<!-- Dark/Light Mode Toggle -->
<button
class="mode-toggle"
onclick={toggleMode}
aria-label="Toggle dark/light mode"
title={$mode === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
>
{#if $mode === 'dark'}
<div transition:fade={{ duration: 150 }}>
<Icon icon="mdi:white-balance-sunny" width="20" />
</div>
{:else}
<div transition:fade={{ duration: 150 }}>
<Icon icon="mdi:moon-waning-crescent" width="20" />
</div>
{/if}
</button>
</div>
</div>
<!-- Backdrop for dropdown -->
{#if themeDropdownOpen}
<button
class="backdrop"
transition:fade={{ duration: 150 }}
onclick={() => themeDropdownOpen = false}
aria-label="Close dropdown"
></button>
{/if}
</nav>
-532
View File
@@ -1,532 +0,0 @@
<script lang="ts">
import { mode, getThemeIcon, colorTheme, toggleMode, setColorTheme, themeOptions, themeColors, type ColorTheme } from '$lib/stores/theme';
import { page } from '$app/stores';
import { fly, fade, slide } from 'svelte/transition';
import { user, navigation } from '$lib/config';
import Icon from '@iconify/svelte';
import { onMount, onDestroy } from 'svelte';
import '$lib/assets/css/navbar-waybar.css';
// State
let themeDropdownOpen = $state(false);
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);
// Time update interval
let timeInterval: ReturnType<typeof setInterval>;
onMount(() => {
// 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 = 1 * 3600 * 1000; // 1 hour
const cycleMs = dischargeMs + chargeMs; // 6 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);
});
onDestroy(() => {
if (timeInterval) clearInterval(timeInterval);
});
// Format time
function formatTime(date: Date): string {
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: false
});
}
function formatDate(date: Date): string {
return date.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric'
});
}
// Get current workspace/page index
function getWorkspaceIndex(path: string): number {
const idx = navigation.findIndex(n => n.path === path);
return idx >= 0 ? idx + 1 : 1;
}
// Battery icon based on level
function getBatteryIcon(level: number, charging: boolean): string {
if (charging) return 'mdi:battery-charging';
if (level > 90) return 'mdi:battery';
if (level > 70) return 'mdi:battery-80';
if (level > 50) return 'mdi:battery-60';
if (level > 30) return 'mdi:battery-40';
if (level > 10) return 'mdi:battery-20';
return 'mdi:battery-alert';
}
function handleThemeSelect(theme: ColorTheme) {
setColorTheme(theme);
themeDropdownOpen = false;
}
function handleKeydown(event: KeyboardEvent) {
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 '';
}
}
function closeMobileMenu() {
mobileMenuOpen = false;
}
</script>
<svelte:window on:keydown={handleKeydown} />
<nav
class="waybar"
style="
--bar-bg: {$themeColors.background};
--bar-bg-module: {$themeColors.backgroundLight};
--bar-border: {$themeColors.border};
--bar-text: {$themeColors.text};
--bar-primary: {$themeColors.primary};
--bar-accent: {$themeColors.accent};
--bar-muted: {$themeColors.textMuted};
--bar-success: {$themeColors.colorMap.success};
--bar-warning: {$themeColors.colorMap.warning};
--bar-error: {$themeColors.colorMap.error};
"
>
<!-- Left modules -->
<div class="bar-left">
<!-- Arch logo / Launcher -->
<!-- <a href="/" class="module launcher" title="Home">
<img src="/favicon.png" alt="Blob Icon" width="16" />
</a> -->
<!-- Mobile menu toggle -->
<button
class="mobile-menu-toggle"
onclick={() => mobileMenuOpen = !mobileMenuOpen}
aria-expanded={mobileMenuOpen}
aria-label="Toggle navigation menu"
>
<Icon icon={mobileMenuOpen ? 'mdi:close' : 'mdi:menu'} width="20" />
</button>
<!-- Workspaces (desktop) -->
<div class="module workspaces desktop-only">
{#each navigation as nav}
{@const isActive = currentPath === nav.path || (nav.path !== '/' && currentPath.startsWith(nav.path))}
{#if nav.external}
<a
href={nav.path}
class="workspace external"
target="_blank"
rel="noopener noreferrer"
title={nav.name}
>
<Icon icon="mdi:open-in-new" width="12" />
<span class="ws-name">{nav.name}</span>
</a>
{:else}
<a
href={nav.path}
class="workspace"
class:active={isActive}
title={nav.name}
>
<span class="ws-name">{nav.name}</span>
</a>
{/if}
{/each}
</div>
<!-- Current window title (desktop) -->
<div class="module window-title desktop-only">
<span class="title-icon">
{#if currentPath === '/'}
<Icon icon="mdi:home" width="14" />
{:else if currentPath === '/portfolio'}
<Icon icon="mdi:folder-multiple" width="14" />
{:else if currentPath === '/models'}
<Icon icon="mdi:cube-outline" width="14" />
{:else if currentPath === '/projects'}
<Icon icon="mdi:trophy" width="14" />
{:else}
<Icon icon="mdi:file" width="14" />
{/if}
</span>
<span class="title-text">
{navigation.find(n => n.path === currentPath)?.name || 'page'}
</span>
</div>
</div>
<!-- Center modules -->
<div class="bar-center">
<div class="module clock">
<Icon icon="mdi:clock-outline" width="14" />
<span class="time">{formatTime(currentTime)}</span>
<span class="date">{formatDate(currentTime)}</span>
</div>
</div>
<!-- Right modules -->
<div class="bar-right">
<!-- (No system modules — minimal Waybar look) -->
<!-- Discord status with popover -->
<div class="module discord-status">
<button
class="discord-status-btn"
onclick={toggleDiscordPopover}
title={discordDisplayName ? `${discordDisplayName} — ${discordStatus}` : `Discord: ${discordStatus}`}
aria-expanded={discordPopoverOpen}
>
<span
class="status-dot"
class:online={discordStatus === 'online'}
class:idle={discordStatus === 'idle'}
class:dnd={discordStatus === 'dnd'}
class:offline={discordStatus === 'offline'}
></span>
{#if discordDisplayName}
<span class="status-text">{discordDisplayName}</span>
{/if}
</button>
{#if discordPopoverOpen}
<div
class="discord-popover"
transition:fly={{ y: -10, duration: 150 }}
>
<!-- Header with avatar and name -->
<div class="popover-header">
<div class="popover-avatar">
{#if discordAvatarUrl}
<img src={discordAvatarUrl} alt="" class="avatar-img" />
{:else}
<Icon icon="mdi:account-circle" width="48" />
{/if}
<span
class="popover-status-dot"
class:online={discordStatus === 'online'}
class:idle={discordStatus === 'idle'}
class:dnd={discordStatus === 'dnd'}
class:offline={discordStatus === 'offline'}
></span>
</div>
<div class="popover-user-info">
<div class="popover-name-row">
<span class="popover-username">{discordDisplayName ?? 'Unknown'}</span>
{#if discordGuildTag}
<span class="popover-clan-tag">
<img src={guildTagBadgeImage} alt={discordGuildTag ?? 'Guild'} class="popover-clan-badge" />
{discordGuildTag}
</span>
{/if}
</div>
<span class="popover-status-label">{discordStatus}</span>
</div>
<button class="popover-close" onclick={() => discordPopoverOpen = false} aria-label="Close">
<Icon icon="mdi:close" width="16" />
</button>
</div>
<!-- Divider -->
<div class="popover-divider"></div>
<!-- Activities -->
{#if discordActivities.length > 0}
<div class="popover-activities">
{#each discordActivities as activity}
<div class="activity-card">
<div class="activity-type-label">{getActivityTypeLabel(activity.type)}</div>
<div class="activity-content">
{#if activity.assets?.large_image || activity.app_icon}
<div class="activity-images">
<img
src={activity.assets?.large_image ?? activity.app_icon}
alt=""
class="activity-large-image"
/>
{#if activity.assets?.small_image}
<img
src={activity.assets.small_image}
alt=""
class="activity-small-image"
/>
{/if}
</div>
{/if}
<div class="activity-info">
<span class="activity-name">{activity.name}</span>
{#if activity.details}
<span class="activity-details">{activity.details}</span>
{/if}
</div>
</div>
</div>
{/each}
</div>
{:else}
<div class="popover-no-activity">
<Icon icon="mdi:sleep" width="24" />
<span>No current activity</span>
</div>
{/if}
</div>
{/if}
</div>
<!-- Battery module (EST-based simulated battery cycle) -->
<div class="module battery" title="Battery">
<button
class="battery-btn"
aria-label={`Battery ${batteryLevel}% ${batteryCharging ? 'charging' : 'discharging'}`}
class:low={batteryLevel < 20 && !batteryCharging}
class:charging={batteryCharging}
>
<Icon icon={getBatteryIcon(batteryLevel, batteryCharging)} width="16" />
<span class="battery-level" class:battery-alert={batteryLevel < 20 && !batteryCharging} class:charging={batteryCharging}>{batteryLevel}%</span>
</button>
</div>
<!-- Theme selector -->
<div class="module theme-selector">
<button
class="theme-trigger"
onclick={() => themeDropdownOpen = !themeDropdownOpen}
aria-expanded={themeDropdownOpen}
title="Theme: {$colorTheme}"
>
<Icon icon={getThemeIcon($colorTheme)} width="16" />
</button>
{#if themeDropdownOpen}
<div
class="theme-dropdown"
transition:fly={{ y: -10, duration: 150 }}
>
<div class="dropdown-header">Theme</div>
{#each themeOptions as option, i}
<button
class="theme-option"
class:active={$colorTheme === option.value}
onclick={() => handleThemeSelect(option.value)}
>
<Icon icon={getThemeIcon(option.value)} width="16" />
<span class="theme-label">
{option.label} <span class="theme-number">[{i+1}]</span>
</span>
{#if $colorTheme === option.value}
<Icon icon="mdi:check" width="14" class="check" />
{/if}
</button>
{/each}
<div class="dropdown-divider"></div>
<div class="dropdown-header">Mode</div>
<button class="theme-option" onclick={toggleMode}>
<Icon icon={$mode === 'dark' ? 'mdi:weather-sunny' : 'mdi:weather-night'} width="16" />
<span>{$mode === 'dark' ? 'Light Mode' : 'Dark Mode'} [T]</span>
</button>
</div>
{/if}
</div>
</div>
<!-- Backdrop for dropdowns -->
{#if themeDropdownOpen || discordPopoverOpen}
<button
class="backdrop"
transition:fade={{ duration: 100 }}
onclick={() => { themeDropdownOpen = false; discordPopoverOpen = false; }}
aria-label="Close"
></button>
{/if}
</nav>
<!-- Mobile menu backdrop (separate, behind the menu) -->
{#if mobileMenuOpen}
<button
class="mobile-backdrop"
transition:fade={{ duration: 100 }}
onclick={() => { mobileMenuOpen = false; }}
aria-label="Close menu"
style="
position: fixed;
inset: 0;
top: var(--navbar-height);
background: rgba(0, 0, 0, 0.5);
z-index: 997;
border: none;
cursor: default;
"
></button>
{/if}
<!-- Mobile menu dropdown -->
{#if mobileMenuOpen}
<div
class="mobile-menu"
transition:slide={{ duration: 200 }}
style="
--bar-bg: {$themeColors.background};
--bar-bg-module: {$themeColors.backgroundLight};
--bar-border: {$themeColors.border};
--bar-text: {$themeColors.text};
--bar-primary: {$themeColors.primary};
--bar-accent: {$themeColors.accent};
--bar-muted: {$themeColors.textMuted};
"
>
<div class="mobile-nav-links">
{#each navigation as nav}
{@const isActive = currentPath === nav.path || (nav.path !== '/' && currentPath.startsWith(nav.path))}
{#if nav.external}
<a
href={nav.path}
class="mobile-nav-link external"
target="_blank"
rel="noopener noreferrer"
onclick={closeMobileMenu}
>
<Icon icon="mdi:open-in-new" width="16" />
<span>{nav.name}</span>
</a>
{:else}
<a
href={nav.path}
class="mobile-nav-link"
class:active={isActive}
onclick={closeMobileMenu}
>
{#if nav.path === '/'}
<Icon icon="mdi:home" width="16" />
{:else if nav.path === '/portfolio'}
<Icon icon="mdi:folder-multiple" width="16" />
{:else if nav.path === '/models'}
<Icon icon="mdi:cube-outline" width="16" />
{:else if nav.path === '/projects'}
<Icon icon="mdi:trophy" width="16" />
{:else}
<Icon icon="mdi:file" width="16" />
{/if}
<span>{nav.name}</span>
</a>
{/if}
{/each}
</div>
<div class="mobile-menu-divider"></div>
<div class="mobile-theme-section">
<div class="mobile-section-header">Theme</div>
<div class="mobile-theme-options">
{#each themeOptions as option, i}
<button
class="mobile-theme-btn"
class:active={$colorTheme === option.value}
onclick={() => { handleThemeSelect(option.value); closeMobileMenu(); }}
>
<Icon icon={getThemeIcon(option.value)} width="18" />
<span class="theme-label">{option.label} <span class="theme-number">[{i+1}]</span></span>
</button>
{/each}
</div>
<button class="mobile-mode-btn" onclick={() => { toggleMode(); closeMobileMenu(); }}>
<Icon icon={$mode === 'dark' ? 'mdi:weather-sunny' : 'mdi:weather-night'} width="18" />
<span>{$mode === 'dark' ? 'Switch to Light' : 'Switch to Dark'}</span>
</button>
</div>
</div>
{/if}
-111
View File
@@ -1,111 +0,0 @@
<script lang="ts">
import { T, useTask } from '@threlte/core';
import { onMount } from 'svelte';
import * as THREE from 'three';
import { themeColors, mode } from '$lib/stores/theme';
interface Props {
particleCount?: number;
}
let { particleCount = 500 }: Props = $props();
let particlesGeometry: THREE.BufferGeometry;
let particlesMaterial: THREE.PointsMaterial;
let particles = $state<THREE.Points | null>(null);
let positions: Float32Array;
let velocities: Float32Array;
let time = 0;
// Reactive theme values - use accent or a darker color for light mode visibility
const currentMode = $derived($mode);
const particleColor = $derived(
currentMode === 'dark'
? $themeColors.primary
: $themeColors.accent || $themeColors.primary
);
// Create particles
function createParticles() {
positions = new Float32Array(particleCount * 3);
velocities = new Float32Array(particleCount * 3);
for (let i = 0; i < particleCount; i++) {
const i3 = i * 3;
// Spread particles in a larger area
positions[i3] = (Math.random() - 0.5) * 20;
positions[i3 + 1] = (Math.random() - 0.5) * 20;
positions[i3 + 2] = (Math.random() - 0.5) * 20;
// Random velocities
velocities[i3] = (Math.random() - 0.5) * 0.01;
velocities[i3 + 1] = (Math.random() - 0.5) * 0.01;
velocities[i3 + 2] = (Math.random() - 0.5) * 0.01;
}
particlesGeometry = new THREE.BufferGeometry();
particlesGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
particlesMaterial = new THREE.PointsMaterial({
size: 0.05,
color: new THREE.Color(particleColor),
sizeAttenuation: true,
transparent: true,
opacity: currentMode === 'dark' ? 0.6 : 0.8,
blending: currentMode === 'dark' ? THREE.AdditiveBlending : THREE.NormalBlending,
depthWrite: false
});
particles = new THREE.Points(particlesGeometry, particlesMaterial);
}
onMount(() => {
createParticles();
});
// Update particle color and opacity based on theme
$effect(() => {
if (particlesMaterial) {
particlesMaterial.color.set(particleColor);
particlesMaterial.opacity = currentMode === 'dark' ? 0.6 : 0.8;
particlesMaterial.blending = currentMode === 'dark' ? THREE.AdditiveBlending : THREE.NormalBlending;
particlesMaterial.needsUpdate = true;
}
});
// Animation loop
useTask((delta) => {
if (!particles || !particlesGeometry) return;
time += delta;
const positionAttribute = particlesGeometry.getAttribute('position') as THREE.BufferAttribute;
const positionArray = positionAttribute.array as Float32Array;
for (let i = 0; i < particleCount; i++) {
const i3 = i * 3;
// Add some wave motion
positionArray[i3] += velocities[i3] + Math.sin(time + i * 0.1) * 0.001;
positionArray[i3 + 1] += velocities[i3 + 1] + Math.cos(time + i * 0.1) * 0.001;
positionArray[i3 + 2] += velocities[i3 + 2];
// Wrap around boundaries
if (positionArray[i3] > 10) positionArray[i3] = -10;
if (positionArray[i3] < -10) positionArray[i3] = 10;
if (positionArray[i3 + 1] > 10) positionArray[i3 + 1] = -10;
if (positionArray[i3 + 1] < -10) positionArray[i3 + 1] = 10;
if (positionArray[i3 + 2] > 10) positionArray[i3 + 2] = -10;
if (positionArray[i3 + 2] < -10) positionArray[i3 + 2] = 10;
}
positionAttribute.needsUpdate = true;
// Rotate the entire particle system slowly
particles.rotation.y += delta * 0.05;
particles.rotation.x += delta * 0.02;
});
</script>
{#if particles}
<T is={particles} />
{/if}
-167
View File
@@ -1,167 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { themeColors } from '$lib/stores/theme';
import '$lib/assets/css/terminal.css';
interface Props {
lines?: TerminalLine[];
typeSpeed?: number;
startDelay?: number;
showCursor?: boolean;
autoType?: boolean;
class?: string;
}
interface TerminalLine {
type: 'command' | 'output' | 'prompt' | 'error' | 'success' | 'info';
content: string;
delay?: number;
}
let {
lines = [],
typeSpeed = 30,
startDelay = 500,
showCursor = true,
autoType = true,
class: className = ''
}: Props = $props();
let displayedLines = $state<{ line: TerminalLine; text: string; complete: boolean }[]>([]);
let currentLineIndex = $state(0);
let currentCharIndex = $state(0);
let isTyping = $state(false);
let terminalElement: HTMLDivElement;
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function typeText() {
if (!autoType || lines.length === 0) {
displayedLines = lines.map(line => ({ line, text: line.content, complete: true }));
return;
}
isTyping = true;
await sleep(startDelay);
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
displayedLines = [...displayedLines, { line, text: '', complete: false }];
currentLineIndex = i;
if (line.delay) {
await sleep(line.delay);
}
// Type out the line character by character
for (let j = 0; j <= line.content.length; j++) {
currentCharIndex = j;
displayedLines[i] = {
line,
text: line.content.slice(0, j),
complete: j === line.content.length
};
// Scroll to bottom
if (terminalElement) {
terminalElement.scrollTop = terminalElement.scrollHeight;
}
if (j < line.content.length) {
await sleep(line.type === 'output' ? typeSpeed / 3 : typeSpeed);
}
}
displayedLines[i].complete = true;
// Pause between lines
if (i < lines.length - 1) {
await sleep(150);
}
}
isTyping = false;
}
onMount(() => {
typeText();
});
function getLinePrefix(type: TerminalLine['type']): string {
switch (type) {
case 'command':
case 'prompt':
return '';
case 'error':
return '✗ ';
case 'success':
return '✓ ';
case 'info':
return 'ℹ ';
default:
return '';
}
}
</script>
<div
class="terminal {className}"
style="
--terminal-bg: {$themeColors.terminal};
--terminal-text: {$themeColors.text};
--terminal-muted: {$themeColors.textMuted};
--terminal-border: {$themeColors.border};
--terminal-prompt: {$themeColors.terminalPrompt};
--terminal-user: {$themeColors.terminalUser};
--terminal-path: {$themeColors.terminalPath};
--terminal-primary: {$themeColors.primary};
--terminal-secondary: {$themeColors.secondary};
--terminal-accent: {$themeColors.accent};
--terminal-bg-light: {$themeColors.backgroundLight};
"
bind:this={terminalElement}
>
<!-- Terminal Header -->
<div class="terminal-header">
<div class="terminal-buttons">
<span class="btn close"></span>
<span class="btn minimize"></span>
<span class="btn maximize"></span>
</div>
<div class="terminal-title">
<span class="terminal-icon">🐧</span>
<span>arch@terminal: ~</span>
</div>
<div class="terminal-spacer"></div>
</div>
<!-- Terminal Body -->
<div class="terminal-body">
{#each displayedLines as { line, text, complete }, i}
<div class="terminal-line {line.type}">
{#if line.type === 'command' || line.type === 'prompt'}
<span class="prompt">
<span class="user">user@arch</span><span class="separator">:</span><span class="path">~</span><span class="symbol">$</span>
</span>
{/if}
<span class="content">
{getLinePrefix(line.type)}{text}
</span>
{#if showCursor && i === currentLineIndex && !complete && isTyping}
<span class="cursor"></span>
{/if}
</div>
{/each}
{#if showCursor && !isTyping && displayedLines.length > 0}
<div class="terminal-line prompt">
<span class="prompt">
<span class="user">user@arch</span><span class="separator">:</span><span class="path">~</span><span class="symbol">$</span>
</span>
<span class="cursor"></span>
</div>
{/if}
</div>
</div>
-221
View File
@@ -1,221 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { themeColors } from '$lib/stores/theme';
import { user, terminalSettings } from '$lib/config';
import { calculateTypeSpeed } from '$lib';
import '$lib/assets/css/terminal-page.css';
export type LineType = 'command' | 'output' | 'prompt' | 'error' | 'success' | 'info' | 'image' | 'blank' | 'header';
export interface TerminalLine {
type: LineType;
content: string;
delay?: number;
image?: string; // URL for image type
imageAlt?: string;
imageWidth?: number; // max width in pixels
}
interface Props {
lines?: TerminalLine[];
title?: string;
showHeader?: boolean;
class?: string;
onComplete?: () => void;
}
let {
lines = [],
title = 'terminal',
showHeader = true,
class: className = '',
onComplete
}: Props = $props();
let displayedLines = $state<{ line: TerminalLine; text: string; complete: boolean; showImage: boolean }[]>([]);
let currentLineIndex = $state(0);
let isTyping = $state(false);
let isComplete = $state(false);
let terminalElement: HTMLDivElement;
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function typeText() {
if (lines.length === 0) return;
isTyping = true;
await sleep(terminalSettings.startDelay);
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
displayedLines = [...displayedLines, { line, text: '', complete: false, showImage: false }];
currentLineIndex = i;
if (line.delay) {
await sleep(line.delay);
}
// Handle different line types
if (line.type === 'image') {
// For images, show immediately after a brief pause
await sleep(100);
displayedLines[i] = { line, text: line.content, complete: true, showImage: true };
} else if (line.type === 'blank') {
// Blank lines are instant
displayedLines[i] = { line, text: '', complete: true, showImage: false };
} else if (line.type === 'header') {
// Headers type out faster
const typeSpeed = 5;
for (let j = 0; j <= line.content.length; j++) {
displayedLines[i] = {
line,
text: line.content.slice(0, j),
complete: j === line.content.length,
showImage: false
};
if (j < line.content.length) await sleep(typeSpeed);
}
} else {
// Calculate typing speed based on content length
const typeSpeed = calculateTypeSpeed(line.content.length);
// Type out the line character by character
for (let j = 0; j <= line.content.length; j++) {
displayedLines[i] = {
line,
text: line.content.slice(0, j),
complete: j === line.content.length,
showImage: false
};
// Scroll to bottom
if (terminalElement) {
terminalElement.scrollTop = terminalElement.scrollHeight;
}
if (j < line.content.length) {
await sleep(typeSpeed);
}
}
}
displayedLines[i].complete = true;
// Scroll to bottom after each line
if (terminalElement) {
terminalElement.scrollTop = terminalElement.scrollHeight;
}
// Pause between lines
if (i < lines.length - 1) {
await sleep(terminalSettings.lineDelay);
}
}
isTyping = false;
isComplete = true;
onComplete?.();
}
onMount(() => {
typeText();
});
function getLinePrefix(type: LineType): string {
switch (type) {
case 'error':
return '✗ ';
case 'success':
return '✓ ';
case 'info':
return 'ℹ ';
default:
return '';
}
}
function getPromptString(): string {
return `${user.username}@${user.hostname}`;
}
</script>
<div
class="terminal-page {className}"
style="
--terminal-bg: {$themeColors.terminal};
--terminal-text: {$themeColors.text};
--terminal-muted: {$themeColors.textMuted};
--terminal-border: {$themeColors.border};
--terminal-prompt: {$themeColors.terminalPrompt};
--terminal-user: {$themeColors.terminalUser};
--terminal-path: {$themeColors.terminalPath};
--terminal-primary: {$themeColors.primary};
--terminal-secondary: {$themeColors.secondary};
--terminal-accent: {$themeColors.accent};
--terminal-bg-light: {$themeColors.backgroundLight};
"
bind:this={terminalElement}
>
{#if showHeader}
<!-- Terminal Header -->
<div class="terminal-header">
<div class="terminal-buttons">
<span class="btn close"></span>
<span class="btn minimize"></span>
<span class="btn maximize"></span>
</div>
<div class="terminal-title">
<span class="terminal-icon">🐧</span>
<span>{getPromptString()}: {title}</span>
</div>
<div class="terminal-spacer"></div>
</div>
{/if}
<!-- Terminal Body -->
<div class="terminal-body">
{#each displayedLines as { line, text, complete, showImage }, i}
<div class="terminal-line {line.type}" class:complete>
{#if line.type === 'command' || line.type === 'prompt'}
<span class="prompt">
<span class="user">{user.username}@{user.hostname}</span><span class="separator">:</span><span class="path">~</span><span class="symbol">$</span>
</span>
{/if}
{#if line.type === 'image' && showImage}
<div class="terminal-image">
<img
src={line.image}
alt={line.imageAlt || 'Terminal image'}
style="max-width: {line.imageWidth || 300}px"
/>
{#if line.content}
<span class="image-caption">{line.content}</span>
{/if}
</div>
{:else if line.type === 'header'}
<span class="content header-text">{text}</span>
{:else if line.type !== 'blank'}
<span class="content">
{getLinePrefix(line.type)}{text}
</span>
{/if}
{#if terminalSettings.showCursor && i === currentLineIndex && !complete && isTyping && line.type !== 'image' && line.type !== 'blank'}
<span class="cursor"></span>
{/if}
</div>
{/each}
{#if terminalSettings.showCursor && !isTyping && isComplete}
<div class="terminal-line prompt">
<span class="prompt">
<span class="user">{user.username}@{user.hostname}</span><span class="separator">:</span><span class="path">~</span><span class="symbol">$</span>
</span>
<span class="cursor"></span>
</div>
{/if}
</div>
</div>
-358
View File
@@ -1,358 +0,0 @@
<script lang="ts">
import { onMount } from "svelte";
import { get } from "svelte/store";
import { goto } from "$app/navigation";
import {
themeColors,
mode,
colorTheme,
toggleMode,
setColorTheme,
themeOptions,
} from "$lib/stores/theme";
import {
terminalSettings,
user,
skills,
navigation,
type SpeedPreset,
speedPresets,
} from "$lib/config";
import { runCommand, type CommandContext } from "./tui/terminal-commands";
import TuiHeader from "./tui/TuiHeader.svelte";
import TuiBody from "./tui/TuiBody.svelte";
import TuiFooter from "./tui/TuiFooter.svelte";
import "$lib/assets/css/terminal-tui.css";
// Import extracted modules
import { parseLine, createTerminalAPI } from "./tui/terminal-api";
import {
runTypingAnimation,
skipTypingAnimation,
} from "./tui/terminal-typing";
import {
createKeyboardHandler,
handleNavigation,
scrollToHash,
} from "./tui/terminal-keyboard";
import type {
TerminalLine,
ParsedLine,
DisplayedLine,
TerminalAPI,
} from "./tui/types";
interface Props {
lines?: TerminalLine[];
title?: string;
class?: string;
onComplete?: () => void;
interactive?: boolean;
speed?: SpeedPreset | number;
autoscroll?: boolean;
terminal?: TerminalAPI;
enableCommands?: boolean;
}
let {
lines = $bindable([]),
title = "terminal",
class: className = "",
onComplete,
interactive = true,
speed = "normal",
autoscroll = true,
terminal = $bindable(),
enableCommands = true,
}: Props = $props();
// Calculate speed multiplier from preset or number
const speedMultiplier = $derived(
typeof speed === "number" ? speed : (speedPresets[speed] ?? 1),
);
// Get colorMap from current theme
const colorMap = $derived($themeColors.colorMap);
// Pre-parse all lines upfront (segments + plain text)
const parsedLines = $derived<ParsedLine[]>(
lines.map((line) => parseLine(line, colorMap)),
);
let displayedLines = $state<DisplayedLine[]>([]);
let currentLineIndex = $state(0);
let isTyping = $state(false);
let isComplete = $state(false);
let selectedIndex = $state(-1);
let skipRequested = $state(false);
let terminalElement: HTMLDivElement;
let bodyElement = $state<HTMLDivElement>();
// Track colorMap identity to detect theme/mode changes
let lastColorMapId = $state("");
// When colorMap changes (theme/mode toggle), update displayedLines with new parsed segments
$effect(() => {
// Create a simple identity string from a few colorMap values
const colorMapId = `${colorMap.red}-${colorMap.text}-${colorMap.primary}`;
// Only update if colorMap actually changed and animation is complete
if (
colorMapId !== lastColorMapId &&
isComplete &&
displayedLines.length > 0
) {
// Store current showImage states before updating
const showImageStates = displayedLines.map((d) => d.showImage);
// Update with new parsed content
displayedLines = parsedLines.map((parsed, i) => ({
parsed,
charIndex: parsed.plainText.length,
complete: true,
showImage: showImageStates[i] ?? parsed.line.type === "image",
}));
}
lastColorMapId = colorMapId;
});
// Helper to check if a line or its children contain buttons
function hasButtons(line: TerminalLine): boolean {
if (line.type === "button") return true;
if (line.type === "group" && line.children) {
return line.children.some((child) => hasButtons(child));
}
return false;
}
// Get all interactive button indices (including buttons nested in groups)
const buttonIndices = $derived(
displayedLines
.map((item, i) => (hasButtons(item.parsed.line) ? i : -1))
.filter((i) => i !== -1),
);
// ========================================================================
// TYPING ANIMATION
// ========================================================================
function typeText() {
runTypingAnimation({
parsedLines,
speedMultiplier,
terminalSettings,
buttonIndices,
interactive,
autoscroll,
getBodyElement: () => bodyElement,
getState: () => ({
displayedLines,
currentLineIndex,
isTyping,
isComplete,
skipRequested,
selectedIndex,
}),
setState: (updates) => {
if (updates.displayedLines !== undefined)
displayedLines = updates.displayedLines;
if (updates.currentLineIndex !== undefined)
currentLineIndex = updates.currentLineIndex;
if (updates.isTyping !== undefined) isTyping = updates.isTyping;
if (updates.isComplete !== undefined)
isComplete = updates.isComplete;
if (updates.skipRequested !== undefined)
skipRequested = updates.skipRequested;
if (updates.selectedIndex !== undefined)
selectedIndex = updates.selectedIndex;
},
onComplete,
scrollToHash: () => scrollToHash(bodyElement),
});
}
function skipAnimation() {
skipTypingAnimation(
parsedLines,
buttonIndices,
interactive,
() => ({
displayedLines,
currentLineIndex,
isTyping,
isComplete,
skipRequested,
selectedIndex,
}),
(updates) => {
if (updates.displayedLines !== undefined)
displayedLines = updates.displayedLines;
if (updates.currentLineIndex !== undefined)
currentLineIndex = updates.currentLineIndex;
if (updates.isTyping !== undefined) isTyping = updates.isTyping;
if (updates.isComplete !== undefined)
isComplete = updates.isComplete;
if (updates.skipRequested !== undefined)
skipRequested = updates.skipRequested;
if (updates.selectedIndex !== undefined)
selectedIndex = updates.selectedIndex;
},
() => bodyElement,
autoscroll,
onComplete,
);
}
// ========================================================================
// TERMINAL API
// ========================================================================
terminal = createTerminalAPI({
getState: () => ({
lines,
displayedLines,
isTyping,
isComplete,
currentLineIndex,
selectedIndex,
skipRequested,
}),
setState: (updates) => {
if (updates.lines !== undefined) lines = updates.lines;
if (updates.displayedLines !== undefined)
displayedLines = updates.displayedLines;
if (updates.isTyping !== undefined) isTyping = updates.isTyping;
if (updates.isComplete !== undefined)
isComplete = updates.isComplete;
if (updates.currentLineIndex !== undefined)
currentLineIndex = updates.currentLineIndex;
if (updates.selectedIndex !== undefined)
selectedIndex = updates.selectedIndex;
if (updates.skipRequested !== undefined)
skipRequested = updates.skipRequested;
},
getColorMap: () => colorMap,
getBodyElement: () => bodyElement,
typeText,
});
// ========================================================================
// COMMAND HANDLING
// ========================================================================
const commandContext: CommandContext = {
terminal: terminal!,
navigate: (path, external) => {
if (external) window.open(path, "_blank", "noopener,noreferrer");
else goto(path);
},
toggleMode,
setColorTheme: (name) => setColorTheme(name as any),
getMode: () => get(mode),
getColorTheme: () => get(colorTheme),
themeNames: themeOptions.map((option) => option.value),
user,
skills,
navigation,
};
function handleCommand(input: string) {
runCommand(input, commandContext);
}
// ========================================================================
// KEYBOARD HANDLING
// ========================================================================
const handleKeydown = createKeyboardHandler({
getIsTyping: () => isTyping,
getIsComplete: () => isComplete,
getInteractive: () => interactive,
getButtonIndices: () => buttonIndices,
getSelectedIndex: () => selectedIndex,
setSelectedIndex: (index) => {
selectedIndex = index;
},
getDisplayedLines: () => displayedLines,
getBodyElement: () => bodyElement,
skipAnimation,
scrollMargin: terminalSettings.scrollMargin,
});
function handleButtonClick(index: number, line?: TerminalLine) {
if (line) {
// If line is provided (e.g. from nested group), use it directly
// We don't update selectedIndex because it might not map to a top-level line
handleNavigation(line as any);
} else {
// Fallback to top-level index lookup
selectedIndex = index;
handleNavigation(displayedLines[index]?.parsed.line as any);
}
}
function handleLinkClick(index: number) {
handleNavigation(displayedLines[index]?.parsed.line as any);
}
onMount(() => {
typeText();
});
</script>
<svelte:window on:keydown={handleKeydown} />
<div
class="tui-terminal {className}"
style="
--terminal-bg: {$themeColors.terminal};
--terminal-text: {$themeColors.text};
--terminal-muted: {$themeColors.textMuted};
--terminal-border: {$themeColors.border};
--terminal-prompt: {$themeColors.terminalPrompt};
--terminal-user: {$themeColors.terminalUser};
--terminal-path: {$themeColors.terminalPath};
--terminal-primary: {$themeColors.primary};
--terminal-secondary: {$themeColors.secondary};
--terminal-accent: {$themeColors.accent};
--terminal-bg-light: {$themeColors.backgroundLight};
"
bind:this={terminalElement}
role="region"
aria-label="Terminal interface"
>
<!-- Hyprland-style border glow -->
<div class="tui-border-glow"></div>
<!-- TUI Content -->
<div class="tui-content">
<TuiHeader
{title}
{interactive}
hasButtons={buttonIndices.length > 0}
/>
<!-- Main terminal area -->
<TuiBody
bind:ref={bodyElement}
{displayedLines}
{currentLineIndex}
{isTyping}
{selectedIndex}
onButtonClick={handleButtonClick}
onHoverButton={(i) => (selectedIndex = i)}
onLinkClick={handleLinkClick}
{terminalSettings}
interactiveInput={enableCommands && interactive}
onCommand={handleCommand}
/>
<TuiFooter
{isTyping}
linesCount={displayedLines.length}
{skipAnimation}
/>
</div>
</div>
+137
View File
@@ -0,0 +1,137 @@
<script lang="ts">
let { html }: { html: string } = $props();
let contentElement = $state<HTMLDivElement>();
function copyCode(code: string, button: HTMLButtonElement) {
navigator.clipboard.writeText(code).then(() => {
button.textContent = 'copied';
setTimeout(() => (button.textContent = 'copy'), 1500);
});
}
$effect(() => {
html;
contentElement?.querySelectorAll('pre').forEach((block) => {
const code = block.querySelector('code');
if (!code || block.querySelector('.copy-button')) return;
const button = document.createElement('button');
button.type = 'button';
button.className = 'copy-button';
button.textContent = 'copy';
button.addEventListener('click', () => copyCode(code.textContent ?? '', button));
block.appendChild(button);
});
});
</script>
<div class="post-content" bind:this={contentElement}>
{@html html}
</div>
<style>
.post-content {
line-height: 1.75;
}
.post-content :global(h1),
.post-content :global(h2),
.post-content :global(h3) {
margin: 1.75rem 0 0.75rem;
color: var(--accent);
}
.post-content :global(h2::before) {
content: '## ';
color: var(--muted);
}
.post-content :global(p) {
margin: 0 0 1rem;
}
.post-content :global(a) {
color: var(--blue);
text-decoration: underline;
}
.post-content :global(code) {
padding: 0.1rem 0.35rem;
background: var(--lighter-background);
font-size: 0.9em;
}
.post-content :global(pre) {
position: relative;
margin: 0 0 1rem;
padding: 1rem;
overflow-x: auto;
background: var(--dark-background);
border: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent);
}
.post-content :global(pre code) {
padding: 0;
background: none;
}
.post-content :global(.copy-button) {
position: absolute;
top: 0.4rem;
right: 0.4rem;
padding: 0.1rem 0.5rem;
background: var(--background);
border: 1px solid color-mix(in srgb, var(--foreground) 25%, transparent);
color: var(--muted);
font-size: 0.75rem;
}
.post-content :global(.copy-button:hover) {
border-color: var(--accent);
color: var(--foreground);
}
.post-content :global(.tok-keyword) {
color: var(--magenta);
}
.post-content :global(.tok-string) {
color: var(--green);
}
.post-content :global(.tok-comment) {
color: var(--muted);
font-style: italic;
}
.post-content :global(.tok-number) {
color: var(--orange);
}
.post-content :global(blockquote) {
margin: 0 0 1rem;
padding: 0.25rem 0 0.25rem 1rem;
border-left: 3px solid var(--accent);
color: var(--muted);
}
.post-content :global(ul),
.post-content :global(ol) {
margin: 0 0 1rem;
padding-left: 1.5rem;
}
.post-content :global(ul) {
list-style: square;
}
.post-content :global(img) {
max-width: 100%;
}
.post-content :global(hr) {
margin: 1.75rem 0;
border: none;
border-top: 1px dashed color-mix(in srgb, var(--foreground) 30%, transparent);
}
</style>
+162
View File
@@ -0,0 +1,162 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { dwindleLayout, fullscreenLayout, tileStyle, type TileRect } from '#lib/desktop/tiling';
import { pageWindowId, windowManager } from '#lib/desktop/windows.svelte';
import { closePage } from '#lib/desktop/actions';
import Terminal from '#lib/components/terminal/Terminal.svelte';
import DesktopIcons from './DesktopIcons.svelte';
import Window from './Window.svelte';
let {
pageOpen,
pageTitle,
pageIcon,
pageKey,
children
}: { pageOpen: boolean; pageTitle: string; pageIcon: string; pageKey: string; children: Snippet } =
$props();
const gapInPixels = 10;
const narrowWidth = 768;
let areaWidth = $state(1280);
let areaHeight = $state(720);
const isNarrow = $derived(areaWidth < narrowWidth);
const openWindowIds = $derived([...(pageOpen ? [pageWindowId] : []), ...windowManager.terminals.map((terminal) => terminal.id)]);
const layout = $derived.by(() => {
const rects = new Map<string, TileRect>();
const soloId = windowManager.maximizedId ?? (isNarrow ? windowManager.focusedId : null);
if (soloId && openWindowIds.includes(soloId)) {
rects.set(soloId, fullscreenLayout());
return rects;
}
dwindleLayout(openWindowIds.length, areaWidth, areaHeight).forEach((rect, index) => rects.set(openWindowIds[index], rect));
return rects;
});
$effect(() => {
if (openWindowIds.length && !openWindowIds.includes(windowManager.focusedId)) {
windowManager.focus(openWindowIds.at(-1)!);
}
});
function styleFor(id: string): string {
const rect = layout.get(id);
return rect ? tileStyle(rect, gapInPixels) : 'display: none';
}
</script>
<div class="desktop" class:narrow={isNarrow}>
{#if isNarrow && openWindowIds.length > 1}
<nav class="window-tabs" aria-label="Open windows">
{#each openWindowIds as id (id)}
<button class:active={windowManager.focusedId === id} onclick={() => windowManager.focus(id)}>
{id === pageWindowId ? pageTitle : id}
</button>
{/each}
</nav>
{/if}
<div class="icons-layer">
<DesktopIcons />
</div>
<div class="tiling-area" bind:clientWidth={areaWidth} bind:clientHeight={areaHeight}>
{#if pageOpen}
<Window
title="kitty"
path={pageTitle}
icon={pageIcon}
focused={windowManager.focusedId === pageWindowId}
tileStyle={styleFor(pageWindowId)}
scrollKey={pageKey}
onfocus={() => windowManager.focus(pageWindowId)}
onclose={closePage}
>
{@render children()}
</Window>
{/if}
{#each windowManager.terminals as terminal (terminal.id)}
<Window
title="kitty"
path="~"
focused={windowManager.focusedId === terminal.id}
tileStyle={styleFor(terminal.id)}
onfocus={() => windowManager.focus(terminal.id)}
onclose={() => windowManager.closeTerminal(terminal.id)}
>
<Terminal
startupCommand={terminal.startupCommand}
focused={windowManager.focusedId === terminal.id}
onexit={() => windowManager.closeTerminal(terminal.id)}
/>
</Window>
{/each}
</div>
</div>
<style>
.desktop {
position: relative;
display: flex;
flex: 1;
min-height: 0;
padding: calc(var(--gaps-out) - 4px);
}
.icons-layer {
position: absolute;
inset: calc(var(--gaps-out) - 4px);
padding: 4px;
}
.tiling-area {
position: relative;
flex: 1;
min-width: 0;
pointer-events: none;
}
.tiling-area > :global(.window) {
pointer-events: auto;
}
.desktop.narrow {
flex-direction: column;
}
.window-tabs {
position: relative;
z-index: 1;
display: flex;
gap: 4px;
padding: 0 4px 6px;
overflow-x: auto;
}
.window-tabs button {
flex-shrink: 0;
padding: 0.15rem 0.6rem;
background: var(--background);
border: 1px solid var(--border-inactive);
font-size: 14px;
}
.window-tabs button.active {
border-color: var(--border-active-solid);
color: var(--accent);
}
@media (max-width: 767px) {
.icons-layer :global(.desktop-icons) {
grid-auto-flow: row;
grid-template-columns: repeat(auto-fill, 112px);
grid-template-rows: none;
grid-auto-rows: 104px;
justify-content: center;
}
}
</style>
@@ -0,0 +1,84 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { desktop } from '#lib/content';
import { runDesktopAction } from '#lib/desktop/actions';
</script>
<nav class="desktop-icons" aria-label="Desktop">
{#each desktop.icons as item (item.label)}
<button class="desktop-icon" title={item.label} onclick={() => runDesktopAction(item.open)}>
<span class="icon-box">
<Icon icon={item.icon} width="36" />
</span>
<span class="icon-label">{item.label}</span>
</button>
{/each}
</nav>
<style>
.desktop-icons {
display: grid;
grid-auto-flow: column;
grid-template-rows: repeat(auto-fill, 104px);
grid-auto-columns: 112px;
gap: 6px;
height: 100%;
padding: 4px;
align-content: start;
}
.desktop-icon {
display: flex;
flex-direction: column;
align-items: center;
gap: 7px;
padding: 6px 4px;
background: none;
border: none;
color: var(--foreground);
}
.desktop-icon:focus-visible {
outline: none;
}
.icon-box {
display: grid;
place-items: center;
width: 56px;
height: 56px;
background: var(--background);
border: 2px solid var(--accent);
color: var(--accent);
transition:
background 0.12s,
color 0.12s,
transform 0.12s;
}
.desktop-icon:hover .icon-box,
.desktop-icon:focus-visible .icon-box {
background: var(--accent);
color: var(--background);
}
.desktop-icon:active .icon-box {
transform: scale(0.94);
}
.icon-label {
max-width: 100%;
padding: 1px 6px;
background: color-mix(in srgb, var(--background) 88%, transparent);
font-size: 13px;
line-height: 1.35;
text-align: center;
word-break: break-word;
}
.desktop-icon:hover .icon-label,
.desktop-icon:focus-visible .icon-label {
background: var(--accent);
color: var(--background);
}
</style>
+175
View File
@@ -0,0 +1,175 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { desktop, type DesktopAction } from '#lib/content';
import { allSitePages } from '#lib/site/pages';
import { runDesktopAction } from '#lib/desktop/actions';
import { windowManager } from '#lib/desktop/windows.svelte';
interface LauncherEntry {
label: string;
detail: string;
icon: string;
open: DesktopAction;
}
const pageEntries: LauncherEntry[] = allSitePages.map((page) => ({
label: page.title,
detail: page.path,
icon: page.icon,
open: { page: page.path }
}));
const iconEntries: LauncherEntry[] = desktop.icons
.filter((item) => !('page' in item.open))
.map((item) => ({
label: item.label,
detail: 'terminal' in item.open ? `kitty ${item.open.terminal}`.trim() : 'url' in item.open ? item.open.url : '',
icon: item.icon,
open: item.open
}));
const entries = [...pageEntries, ...iconEntries];
let query = $state('');
let selectedIndex = $state(0);
let searchElement = $state<HTMLInputElement>();
const filteredEntries = $derived(
entries.filter((entry) => `${entry.label} ${entry.detail}`.toLowerCase().includes(query.toLowerCase()))
);
$effect(() => {
query;
selectedIndex = 0;
});
$effect(() => {
searchElement?.focus();
});
function close() {
windowManager.launcherOpen = false;
}
function launch(entry: LauncherEntry | undefined) {
if (entry) runDesktopAction(entry.open);
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') close();
else if (event.key === 'ArrowDown') {
event.preventDefault();
selectedIndex = Math.min(filteredEntries.length - 1, selectedIndex + 1);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
selectedIndex = Math.max(0, selectedIndex - 1);
} else if (event.key === 'Enter') launch(filteredEntries[selectedIndex]);
}
</script>
<button class="backdrop" aria-label="Close launcher" onclick={close}></button>
<div class="launcher" role="dialog" aria-label="Launcher">
<div class="launcher-surface">
<label class="search">
<Icon icon="mdi:magnify" width="20" />
<input bind:this={searchElement} bind:value={query} onkeydown={handleKeydown} placeholder="Search apps and pages" />
</label>
<ul class="results">
{#each filteredEntries as entry, index (entry.label + entry.detail)}
<li>
<button class="result" class:selected={index === selectedIndex} onclick={() => launch(entry)} onmouseenter={() => (selectedIndex = index)}>
<Icon icon={entry.icon} width="20" />
<span class="result-label">{entry.label}</span>
<span class="result-detail">{entry.detail}</span>
</button>
</li>
{:else}
<li class="empty">No matches</li>
{/each}
</ul>
</div>
</div>
<style>
.backdrop {
position: fixed;
inset: 0;
z-index: 60;
background: color-mix(in srgb, var(--dark-background) 40%, transparent);
border: none;
cursor: default;
}
.launcher {
position: fixed;
top: 18vh;
left: 50%;
z-index: 70;
width: min(640px, calc(100vw - 24px));
padding: var(--border-size);
background: var(--border-active);
transform: translateX(-50%);
}
.launcher-surface {
background: var(--background);
}
.search {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.8rem;
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
color: var(--accent);
}
.search input {
flex: 1;
padding: 0;
background: none;
border: none;
outline: none;
box-shadow: none;
color: var(--foreground);
font: inherit;
}
.results {
max-height: 50vh;
margin: 0;
padding: 0.3rem;
overflow-y: auto;
list-style: none;
}
.result {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
padding: 0.4rem 0.5rem;
background: none;
border: none;
text-align: left;
}
.result.selected {
background: color-mix(in srgb, var(--foreground) 15%, transparent);
color: var(--accent);
}
.result-label {
flex: 1;
}
.result-detail,
.empty {
color: var(--muted);
font-size: 0.8rem;
}
.empty {
padding: 0.5rem;
}
</style>
+148
View File
@@ -0,0 +1,148 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Icon from '@iconify/svelte';
let {
title,
path = '',
icon = 'ph:terminal-window-duotone',
focused,
tileStyle,
scrollKey = '',
onfocus,
onclose,
children
}: {
title: string;
path?: string;
icon?: string;
focused: boolean;
tileStyle: string;
scrollKey?: string;
onfocus: () => void;
onclose: () => void;
children: Snippet;
} = $props();
let contentElement = $state<HTMLDivElement>();
$effect(() => {
scrollKey;
if (contentElement) contentElement.scrollTop = 0;
});
</script>
<section
class="window"
class:focused
style={tileStyle}
aria-label="{title} {path}"
onpointerdown={onfocus}
onfocusin={onfocus}
>
<div class="window-surface">
<header class="title-bar">
<span class="title">
<span class="app-name"><Icon {icon} width="15" />{title}</span>
{#if path}
<span class="path">{path}</span>
{/if}
</span>
<button class="close-button" title="Close (Alt+Q)" aria-label="Close {title} {path}" onclick={onclose}>
<Icon icon="mdi:close" width="15" />
</button>
</header>
<div class="window-content" bind:this={contentElement}>
{@render children()}
</div>
</div>
</section>
<style>
.window {
position: absolute;
padding: var(--border-size);
background: var(--border-inactive);
animation: window-pop-in 0.2s cubic-bezier(0.23, 1, 0.32, 1);
transition:
left 0.25s cubic-bezier(0.23, 1, 0.32, 1),
top 0.25s cubic-bezier(0.23, 1, 0.32, 1),
width 0.25s cubic-bezier(0.23, 1, 0.32, 1),
height 0.25s cubic-bezier(0.23, 1, 0.32, 1);
}
.window.focused {
background: var(--border-active);
}
.window-surface {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
background: var(--background);
}
.title-bar {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
height: 28px;
padding: 0 0.3rem 0 0.8rem;
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
color: var(--muted);
font-size: 13px;
user-select: none;
}
.title {
display: flex;
gap: 0.6rem;
min-width: 0;
overflow: hidden;
white-space: nowrap;
}
.app-name {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.focused .app-name {
color: var(--accent);
}
.path {
overflow: hidden;
text-overflow: ellipsis;
}
.focused .path {
color: var(--foreground);
}
.close-button {
display: grid;
flex-shrink: 0;
place-items: center;
width: 22px;
height: 22px;
padding: 0;
background: none;
border: none;
color: var(--muted);
}
.close-button:hover {
color: var(--red);
}
.window-content {
flex: 1;
min-height: 0;
overflow: auto;
}
</style>
@@ -0,0 +1,149 @@
<script lang="ts">
import { onMount } from 'svelte';
import Icon from '@iconify/svelte';
import type { ModelScene } from '#lib/models/model-scene';
let { modelUrl, modelName }: { modelUrl: string; modelName: string } = $props();
let container = $state<HTMLDivElement>();
let modelScene = $state<ModelScene | null>(null);
let status = $state<'loading' | 'ready' | 'error' | 'unsupported'>('loading');
let autoRotate = $state(true);
let wireframe = $state(false);
let brightness = $state(0.6);
onMount(() => {
let disposed = false;
let resizeObserver: ResizeObserver | undefined;
import('#lib/models/model-scene')
.then(({ ModelScene }) => {
if (disposed || !container) return;
modelScene = new ModelScene(container);
resizeObserver = new ResizeObserver(() => modelScene?.resize());
resizeObserver.observe(container);
})
.catch(() => (status = 'unsupported'));
return () => {
disposed = true;
resizeObserver?.disconnect();
modelScene?.dispose();
};
});
$effect(() => {
if (!modelScene) return;
status = 'loading';
wireframe = false;
modelScene
.load(modelUrl)
.then(() => (status = 'ready'))
.catch(() => (status = 'error'));
});
$effect(() => modelScene?.setAutoRotate(autoRotate));
$effect(() => modelScene?.setWireframe(wireframe));
$effect(() => modelScene?.setBrightness(brightness));
</script>
<div class="viewer">
<div class="toolbar">
<span class="model-name">{modelName}</span>
<div class="controls">
<button class:active={autoRotate} title="Auto rotate" onclick={() => (autoRotate = !autoRotate)}>
<Icon icon="mdi:rotate-3d-variant" width="16" />
</button>
<button class:active={wireframe} title="Wireframe" onclick={() => (wireframe = !wireframe)}>
<Icon icon="mdi:cube-outline" width="16" />
</button>
<button title="Darker" onclick={() => (brightness = Math.max(0, brightness - 0.2))}>
<Icon icon="mdi:brightness-5" width="16" />
</button>
<button title="Brighter" onclick={() => (brightness = Math.min(3, brightness + 0.2))}>
<Icon icon="mdi:brightness-7" width="16" />
</button>
<button title="Reset camera" onclick={() => modelScene?.resetCamera()}>
<Icon icon="mdi:camera-retake" width="16" />
</button>
</div>
</div>
<div class="canvas-area" bind:this={container} tabindex="0" role="application" aria-label="3D view of {modelName}">
{#if status !== 'ready'}
<div class="status muted">
{#if status === 'loading'}
loading model...
{:else if status === 'error'}
failed to load model
{:else}
WebGL is not available in this browser
{/if}
</div>
{/if}
</div>
<div class="hints muted">drag to rotate · scroll to zoom · arrow keys after clicking</div>
</div>
<style>
.viewer {
display: flex;
flex-direction: column;
border: 1px solid color-mix(in srgb, var(--foreground) 22%, transparent);
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.3rem 0.6rem;
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 22%, transparent);
}
.model-name {
color: var(--accent);
}
.controls {
display: flex;
gap: 0.25rem;
}
.controls button {
display: grid;
place-items: center;
width: 28px;
height: 28px;
background: none;
border: 1px solid transparent;
}
.controls button:hover {
border-color: color-mix(in srgb, var(--foreground) 30%, transparent);
}
.controls button.active {
color: var(--accent);
}
.canvas-area {
position: relative;
height: 380px;
background: radial-gradient(circle, var(--lighter-background), var(--dark-background));
}
.canvas-area :global(canvas) {
display: block;
}
.status {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.hints {
padding: 0.3rem 0.6rem;
font-size: 0.75rem;
}
</style>
@@ -0,0 +1,184 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { Project, ProjectCategory } from '#lib/content';
import LinkButton from '#lib/components/ui/LinkButton.svelte';
import Tag from '#lib/components/ui/Tag.svelte';
let { project, category }: { project: Project; category: ProjectCategory } = $props();
const linkButtons = $derived(
[
{ href: project.links.demo, label: 'View Demo', icon: 'mdi:open-in-new' },
{ href: project.links.package, label: 'View Package', icon: 'mdi:package-variant' },
{ href: project.links.code, label: 'View Code', icon: 'mdi:github' },
{ href: project.links.devpost, label: 'Devpost', icon: 'simple-icons:devpost' },
{ href: project.links.article, label: 'Article', icon: 'mdi:newspaper-variant-outline' }
].filter((button) => button.href)
);
</script>
<article class="project-entry" class:has-image={!!project.image}>
<h2 class="project-title">{project.name}</h2>
{#if project.image}
<img class="project-image" src={project.image} alt={project.name} loading="lazy" />
{/if}
<div class="project-body">
<div class="badges">
<span class="badge category"><Icon icon={category.icon} width="15" /> {category.title}</span>
{#if project.featured}
<span class="badge featured">Featured</span>
{/if}
{#if project.version}
<span class="badge version">v{project.version}</span>
{/if}
</div>
{#if project.event}
<div class="event">
{project.event.name}{#if project.event.year}<span class="muted"> ({project.event.year})</span>{/if}
</div>
{#if project.event.university}
<div class="muted">{project.event.university}{project.event.location ? `, ${project.event.location}` : ''}</div>
{/if}
{/if}
{#if project.organization}
<div class="event">{project.organization}</div>
{/if}
{#if project.period}
<div class="muted">{project.period}</div>
{/if}
{#if project.description}
<p>{project.description}</p>
{/if}
{#each project.awards ?? [] as award (award.track)}
<div class="award"><Icon icon="mdi:trophy" width="16" /> {award.place} - {award.track}</div>
{/each}
{#if project.tags.length}
<div class="tags">
{#each project.tags as tag, index (tag)}
<Tag label={tag} colorIndex={index} />
{/each}
</div>
{/if}
{#if project.demoMayBeOffline}
<div class="warning"><Icon icon="mdi:alert" width="16" /> Demo may be unavailable</div>
{/if}
{#if linkButtons.length}
<div class="buttons">
{#each linkButtons as button (button.label)}
<LinkButton href={button.href!} icon={button.icon}>{button.label}</LinkButton>
{/each}
</div>
{/if}
</div>
</article>
<style>
.project-entry {
display: grid;
grid-template-columns: 1fr;
gap: 0.5rem 1.25rem;
padding: 1rem 0 1.5rem;
border-top: 1px dashed color-mix(in srgb, var(--foreground) 25%, transparent);
}
.project-entry.has-image {
grid-template-columns: minmax(180px, 320px) 1fr;
}
.project-title {
grid-column: 1 / -1;
margin: 0;
color: var(--accent);
font-size: 1.1rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.project-image {
width: 100%;
max-height: 220px;
object-fit: contain;
background: var(--dark-background);
border: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent);
}
.project-body {
display: flex;
flex-direction: column;
gap: 0.4rem;
min-width: 0;
}
.badges,
.tags,
.buttons {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.buttons {
margin-top: 0.4rem;
}
.badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0 0.45rem;
color: var(--background);
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
}
.category {
background: var(--green);
}
.featured {
background: var(--orange);
}
.version {
background: var(--blue);
}
.event {
color: var(--blue);
}
p {
margin: 0;
}
.award {
display: flex;
align-items: center;
gap: 0.35rem;
color: var(--yellow);
}
.warning {
display: flex;
align-items: center;
gap: 0.35rem;
color: var(--orange);
font-size: 0.85rem;
}
@container (max-width: 700px) {
.project-entry.has-image {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,39 @@
<script lang="ts">
import { page } from '$app/state';
import { projectCategories } from '#lib/content';
const chipColors = ['magenta', 'orange', 'green', 'cyan'];
</script>
<nav class="project-nav">
<a href="/projects" class:active={page.url.pathname === '/projects'} style="--chip-color: var(--accent)">~/projects</a>
{#each projectCategories as category, index (category.id)}
<a
href="/projects/{category.id}"
class:active={page.url.pathname === `/projects/${category.id}`}
style="--chip-color: var(--{chipColors[index % chipColors.length]})">{category.title}</a
>
{/each}
</nav>
<style>
.project-nav {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1.25rem;
}
a {
padding: 0.05rem 0.55rem;
border: 1px solid var(--chip-color);
color: var(--chip-color);
font-weight: 700;
}
a:hover,
a.active {
background: var(--chip-color);
color: var(--background);
}
</style>
+84
View File
@@ -0,0 +1,84 @@
<script lang="ts">
import { windowManager } from '#lib/desktop/windows.svelte';
import ChaiIcon from './ChaiIcon.svelte';
import Clock from './Clock.svelte';
import Workspaces from './Workspaces.svelte';
import ThemeMenu from './ThemeMenu.svelte';
import DiscordStatus from './DiscordStatus.svelte';
import Battery from './Battery.svelte';
</script>
<header class="bar">
<div class="bar-section left">
<button class="tray-button" title="Apps (Alt+Space)" onclick={() => (windowManager.launcherOpen = true)}>
<ChaiIcon size={18} />
</button>
<Workspaces />
</div>
<button class="clock-button" title="Open a terminal (Alt+Enter)" onclick={() => windowManager.openTerminal()}>
<Clock />
</button>
<div class="bar-section right">
<DiscordStatus />
<Battery />
<ThemeMenu />
</div>
</header>
<style>
.bar {
position: relative;
z-index: 30;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
height: var(--bar-height);
padding: 0 8px;
background: var(--background);
color: var(--foreground);
font-size: 14px;
}
.bar-section {
display: flex;
align-items: center;
}
.right {
justify-content: flex-end;
}
.clock-button {
height: var(--bar-height);
padding: 0 0.5rem;
background: none;
border: none;
}
.clock-button:hover {
color: var(--accent);
}
.bar :global(.tray-button) {
display: grid;
place-items: center;
width: 32px;
height: var(--bar-height);
padding: 0;
background: none;
border: none;
color: var(--foreground);
}
.bar :global(.tray-button:hover) {
color: var(--accent);
}
@media (max-width: 640px) {
.bar {
font-size: 12px;
}
}
</style>
+50
View File
@@ -0,0 +1,50 @@
<script lang="ts">
import { onMount } from 'svelte';
import Icon from '@iconify/svelte';
import { batteryAt, batteryIcon, type BatteryState } from '#lib/desktop/battery';
let battery = $state<BatteryState | null>(null);
const isLow = $derived(!!battery && battery.level < 20 && !battery.charging);
onMount(() => {
battery = batteryAt(new Date());
const timer = setInterval(() => (battery = batteryAt(new Date())), 10000);
return () => clearInterval(timer);
});
</script>
{#if battery}
<span
class="battery"
class:low={isLow}
class:charging={battery.charging}
title="Battery {battery.level}% {battery.charging ? '(charging)' : ''}"
>
<Icon icon={batteryIcon(battery)} width="18" />
<span class="level">{battery.level}%</span>
</span>
{/if}
<style>
.battery {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0 0.4rem;
}
.charging {
color: var(--green);
}
.low {
color: var(--red);
}
@media (max-width: 640px) {
.level {
display: none;
}
}
</style>
+11
View File
@@ -0,0 +1,11 @@
<script lang="ts">
let { size = 14 }: { size?: number } = $props();
</script>
<svg viewBox="0 0 24 24" width={size} height={size} fill="currentColor" aria-hidden="true">
<rect x="8.6" y="1.6" width="1.3" height="4.6" rx="0.65" transform="rotate(-12 9.25 3.9)" />
<rect x="12.4" y="2.4" width="1.3" height="4.6" rx="0.65" transform="rotate(12 13.05 4.7)" />
<path d="M17.2 11 a3.4 3.4 0 0 1 0 6 v-1.7 a1.7 1.7 0 0 0 0 -2.6 z" />
<path d="M4 9 h14 l-2.5 9.2 a1.6 1.6 0 0 1 -1.55 1.2 h-5.9 a1.6 1.6 0 0 1 -1.55 -1.2 z" />
<rect x="3" y="20.4" width="18" height="2" rx="1" />
</svg>
+42
View File
@@ -0,0 +1,42 @@
<script lang="ts">
import { onMount } from 'svelte';
let now = $state<Date | null>(null);
onMount(() => {
now = new Date();
const timer = setInterval(() => (now = new Date()), 15000);
return () => clearInterval(timer);
});
function formatClock(date: Date): string {
const weekday = date.toLocaleDateString('en-US', { weekday: 'long' });
const day = date.toLocaleDateString('en-US', { day: '2-digit' });
const month = date.toLocaleDateString('en-US', { month: 'long' });
const time = date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false });
return `${weekday} ${day} ${month} ${time}`;
}
</script>
<span class="clock full">{now ? formatClock(now) : ''}</span>
<span class="clock short">{now ? formatClock(now).split(' ').slice(1).join(' ') : ''}</span>
<style>
.clock {
white-space: nowrap;
}
.short {
display: none;
}
@media (max-width: 640px) {
.full {
display: none;
}
.short {
display: inline;
}
}
</style>
@@ -0,0 +1,294 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { activityLabels, type DiscordPresence } from '#lib/discord/types';
let { presence, onclose }: { presence: DiscordPresence; onclose: () => void } = $props();
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') onclose();
}
</script>
<svelte:window onkeydown={handleKeydown} />
<button class="backdrop" aria-label="Close Discord status" onclick={onclose}></button>
<div class="popover" role="dialog" aria-label="Discord status">
<div class="popover-surface">
<header class="profile">
<div class="avatar">
{#if presence.avatarUrl}
<img src={presence.avatarUrl} alt="" />
{:else}
<Icon icon="mdi:account-circle" width="52" />
{/if}
<span class="avatar-dot {presence.status}"></span>
</div>
<div class="identity">
<div class="name-row">
<span class="name">{presence.displayName}</span>
{#if presence.guildTag}
<span class="guild-tag">
{#if presence.guildTagBadgeUrl}
<img src={presence.guildTagBadgeUrl} alt="" />
{/if}
{presence.guildTag}
</span>
{/if}
</div>
<span class="status-label">{presence.status}</span>
</div>
<button class="close" aria-label="Close" onclick={onclose}>
<Icon icon="mdi:close" width="18" />
</button>
</header>
{#if presence.activities.length}
<div class="activities">
{#each presence.activities as activity, index (index)}
<div class="activity">
<div class="activity-label">{activityLabels[activity.type] ?? ''}</div>
<div class="activity-body">
{#if activity.largeImage}
<div class="activity-images">
<img class="large-image" src={activity.largeImage} alt="" />
{#if activity.smallImage}
<img class="small-image" src={activity.smallImage} alt="" />
{/if}
</div>
{/if}
<div class="activity-text">
<span class="activity-name">{activity.name}</span>
{#if activity.details}
<span class="activity-details">{activity.details}</span>
{/if}
</div>
</div>
</div>
{/each}
</div>
{:else}
<div class="no-activity">
<Icon icon="mdi:sleep" width="28" />
<span>No current activity</span>
</div>
{/if}
</div>
</div>
<style>
.backdrop {
position: fixed;
inset: 0;
z-index: 40;
background: none;
border: none;
cursor: default;
}
.popover {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 50;
width: 340px;
padding: var(--border-size);
background: var(--border-active);
animation: window-pop-in 0.15s ease-out;
}
.popover-surface {
background: var(--background);
}
.profile {
display: flex;
align-items: center;
gap: 0.9rem;
padding: 1rem;
background: color-mix(in srgb, var(--accent) 8%, var(--background));
}
.avatar {
position: relative;
flex-shrink: 0;
width: 52px;
height: 52px;
color: var(--muted);
}
.avatar img {
width: 52px;
height: 52px;
border-radius: 50%;
object-fit: cover;
}
.avatar-dot {
position: absolute;
right: 0;
bottom: 0;
width: 15px;
height: 15px;
border: 3px solid color-mix(in srgb, var(--accent) 8%, var(--background));
border-radius: 50%;
background: var(--muted);
}
.avatar-dot.online {
background: var(--green);
}
.avatar-dot.idle {
background: var(--yellow);
}
.avatar-dot.dnd {
background: var(--red);
}
.identity {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
}
.name-row {
display: flex;
align-items: center;
gap: 0.6rem;
min-width: 0;
}
.name {
overflow: hidden;
font-size: 1.1rem;
font-weight: 700;
white-space: nowrap;
text-overflow: ellipsis;
}
.guild-tag {
display: inline-flex;
flex-shrink: 0;
align-items: center;
gap: 0.35rem;
padding: 0.1rem 0.5rem;
background: color-mix(in srgb, var(--accent) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
color: var(--accent);
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.guild-tag img {
width: 16px;
height: 16px;
}
.status-label {
color: var(--muted);
}
.close {
align-self: flex-start;
padding: 0.15rem;
background: none;
border: none;
}
.close:hover {
color: var(--accent);
}
.activities {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
}
.activity {
padding: 0.75rem;
background: var(--lighter-background);
}
.activity-label {
margin-bottom: 0.5rem;
color: var(--muted);
font-size: 0.75rem;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.activity-body {
display: flex;
gap: 0.75rem;
}
.activity-images {
position: relative;
flex-shrink: 0;
}
.large-image {
width: 64px;
height: 64px;
object-fit: cover;
}
.small-image {
position: absolute;
right: -5px;
bottom: -5px;
width: 26px;
height: 26px;
border: 3px solid var(--lighter-background);
border-radius: 50%;
}
.activity-text {
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.2rem;
min-width: 0;
}
.activity-name,
.activity-details {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.activity-name {
font-weight: 700;
}
.activity-details {
color: var(--muted);
font-size: 0.85rem;
}
.no-activity {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1.5rem;
color: var(--muted);
}
@media (max-width: 640px) {
.popover {
position: fixed;
top: calc(var(--bar-height) + 6px);
right: 8px;
left: 8px;
width: auto;
}
}
</style>
@@ -0,0 +1,97 @@
<script lang="ts">
import { onMount } from 'svelte';
import { profile } from '#lib/content';
import { offlinePresence, type DiscordPresence } from '#lib/discord/types';
import DiscordPopover from './DiscordPopover.svelte';
const refreshInterval = 30000;
let presence = $state<DiscordPresence>(offlinePresence);
let open = $state(false);
const displayName = $derived(presence.displayName ?? profile.displayName);
async function refresh() {
try {
const response = await fetch('/api/discord');
if (response.ok) presence = await response.json();
} catch {
presence = offlinePresence;
}
}
onMount(() => {
refresh();
const timer = setInterval(refresh, refreshInterval);
return () => clearInterval(timer);
});
</script>
<div class="discord-status">
<button
class="status-button"
title="{displayName} - {presence.status}"
aria-expanded={open}
onclick={() => (open = !open)}
>
<span class="status-dot {presence.status}"></span>
<span class="status-name">{displayName}</span>
</button>
{#if open}
<DiscordPopover presence={{ ...presence, displayName }} onclose={() => (open = false)} />
{/if}
</div>
<style>
.discord-status {
position: relative;
margin-right: 4px;
}
.status-button {
display: inline-flex;
align-items: center;
gap: 0.45rem;
height: calc(var(--bar-height) - 8px);
padding: 0 0.6rem;
background: var(--lighter-background);
border: 1px solid color-mix(in srgb, var(--foreground) 22%, transparent);
}
.status-button:hover {
border-color: var(--accent);
}
.status-name {
max-width: 9rem;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--muted);
}
.status-dot.online {
background: var(--green);
}
.status-dot.idle {
background: var(--yellow);
}
.status-dot.dnd {
background: var(--red);
}
@media (max-width: 640px) {
.status-name {
display: none;
}
}
</style>
+58
View File
@@ -0,0 +1,58 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let { title, onclose, children }: { title: string; onclose: () => void; children: Snippet } = $props();
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') onclose();
}
</script>
<svelte:window onkeydown={handleKeydown} />
<button class="backdrop" aria-label="Close {title}" onclick={onclose}></button>
<div class="popup" role="dialog" aria-label={title}>
<div class="popup-title">{title}</div>
<div class="popup-body">
{@render children()}
</div>
</div>
<style>
.backdrop {
position: fixed;
inset: 0;
z-index: 40;
background: none;
border: none;
cursor: default;
}
.popup {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 50;
width: 320px;
max-height: 70vh;
overflow-y: auto;
padding: var(--border-size);
background: var(--border-active);
animation: window-pop-in 0.15s ease-out;
}
.popup-title {
padding: 0.4rem 0.6rem;
background: var(--background);
color: var(--accent);
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
}
.popup-body {
display: flex;
flex-direction: column;
gap: 2px;
padding: 0.4rem;
background: var(--background);
}
</style>
+79
View File
@@ -0,0 +1,79 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { themes } from '#lib/theme/themes';
import { themeState } from '#lib/theme/theme.svelte';
import PopupCard from './PopupCard.svelte';
let open = $state(false);
</script>
<div class="theme-menu">
<button class="tray-button" title="Theme" onclick={() => (open = !open)}>
<Icon icon="mdi:palette-outline" width="18" />
</button>
{#if open}
<PopupCard title="Themes" onclose={() => (open = false)}>
{#each themes as theme (theme.id)}
<button
class="theme-option"
class:selected={theme.id === themeState.currentId}
onclick={() => themeState.set(theme.id)}
>
<span class="swatches">
{#each [theme.colors.background, theme.colors.accent, theme.colors.red, theme.colors.green, theme.colors.foreground] as swatch, index (index)}
<span class="swatch" style="background: {swatch}"></span>
{/each}
</span>
<span class="theme-name">{theme.name}</span>
<span class="theme-mode">{theme.mode}</span>
</button>
{/each}
</PopupCard>
{/if}
</div>
<style>
.theme-menu {
position: relative;
}
.theme-option {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
padding: 0.35rem 0.5rem;
background: none;
border: 1px solid transparent;
text-align: left;
}
.theme-option:hover {
border-color: color-mix(in srgb, var(--foreground) 25%, transparent);
background: color-mix(in srgb, var(--foreground) 8%, transparent);
}
.theme-option.selected {
background: color-mix(in srgb, var(--foreground) 18%, transparent);
}
.swatches {
display: flex;
border: 1px solid color-mix(in srgb, var(--foreground) 30%, transparent);
}
.swatch {
width: 12px;
height: 18px;
}
.theme-name {
flex: 1;
}
.theme-mode {
color: var(--muted);
font-size: 0.75rem;
}
</style>
@@ -0,0 +1,81 @@
<script lang="ts">
import { page } from '$app/state';
import { desktop } from '#lib/content';
import { activeWorkspace, openPage } from '#lib/desktop/actions';
const workspaceSlots = [1, 2, 3, 4, 5, 6, 7, 8, 9];
const currentWorkspace = $derived(activeWorkspace(page.url.pathname));
function workspaceFor(number: number) {
return desktop.workspaces.find((workspace) => workspace.number === number);
}
</script>
<div class="workspaces">
{#each workspaceSlots as number (number)}
{@const workspace = workspaceFor(number)}
{@const isFocused = currentWorkspace?.number === number}
<button
class="workspace"
class:occupied={!!workspace}
title={workspace?.name}
disabled={!workspace}
onclick={() => workspace && openPage(workspace.path)}
>
{#if isFocused}
<span class="focused-marker"></span>
{:else}
{number}
{/if}
</button>
{/each}
</div>
<style>
.workspaces {
display: flex;
align-items: center;
margin-left: 4px;
}
.workspace {
display: grid;
place-items: center;
width: 26px;
height: var(--bar-height);
padding: 0;
background: none;
border: none;
opacity: 0.5;
}
.workspace:disabled {
cursor: default;
}
.workspace.occupied {
opacity: 1;
}
.workspace.occupied:hover {
color: var(--accent);
}
@media (max-width: 640px) {
.workspace:not(.occupied) {
display: none;
}
.workspace {
width: 20px;
}
}
.focused-marker {
width: 12px;
height: 12px;
border-radius: 2px;
background: currentColor;
}
</style>
+85
View File
@@ -0,0 +1,85 @@
<script lang="ts">
import { profile } from '#lib/content';
import { themeState } from '#lib/theme/theme.svelte';
const launchDate = new Date('2020-01-01T00:00:00Z');
const uptimeDays = Math.floor((Date.now() - launchDate.getTime()) / 86400000);
const paletteColors = ['red', 'yellow', 'orange', 'green', 'cyan', 'blue', 'magenta', 'accent'];
const rows = $derived([
['OS', 'Arch Linux x86_64'],
['Host', profile.hostname],
['WM', 'Hyprland (web)'],
['Shell', 'blob-shell'],
['Terminal', 'kitty'],
['Theme', themeState.current.name],
['Name', profile.displayName],
['Title', profile.title],
['Uptime', `${uptimeDays} days`],
['School', profile.education.school],
['Languages', (profile.skills.Languages ?? []).slice(0, 5).join(', ')]
]);
</script>
<div class="fetch">
<img class="avatar" src={profile.avatar} alt={profile.displayName} />
<div class="info">
<div class="heading">
<span class="user">{profile.username}</span><span class="at">@</span><span class="host">{profile.hostname}</span>
</div>
<div class="separator">{'-'.repeat(profile.username.length + profile.hostname.length + 1)}</div>
{#each rows as [label, value] (label)}
<div class="row"><span class="label">{label}</span>: {value}</div>
{/each}
<div class="palette">
{#each paletteColors as color (color)}
<span class="block" style="background: var(--{color})"></span>
{/each}
</div>
</div>
</div>
<style>
.fetch {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 1.5rem;
margin: 0.5rem 0 1rem;
}
.avatar {
width: min(240px, 40%);
image-rendering: pixelated;
}
.info {
line-height: 1.5;
}
.user,
.host {
color: var(--accent);
font-weight: 700;
}
.at,
.separator {
color: var(--muted);
}
.label {
color: var(--blue);
font-weight: 700;
}
.palette {
display: flex;
margin-top: 0.75rem;
}
.block {
width: 1.5rem;
height: 1rem;
}
</style>
@@ -0,0 +1,52 @@
<script lang="ts">
import type { Span } from '#lib/terminal/output';
import { isExternalUrl, openUrl } from '#lib/desktop/actions';
let { spans }: { spans: Span[] } = $props();
function toneStyle(item: Span): string {
return item.tone ? `color: var(--${item.tone})` : '';
}
</script>
<div class="output-line">
{#each spans as item, index (index)}
{#if item.href}
<a
href={item.href}
class:bold={item.bold}
style={toneStyle(item)}
target={isExternalUrl(item.href) ? '_blank' : undefined}
rel="noopener noreferrer"
onclick={(event) => {
if (isExternalUrl(item.href!)) return;
event.preventDefault();
openUrl(item.href!);
}}>{item.text}</a
>
{:else}
<span class:bold={item.bold} style={toneStyle(item)}>{item.text}</span>
{/if}
{/each}
</div>
<style>
.output-line {
min-height: 1.5em;
white-space: pre-wrap;
word-break: break-word;
}
.bold {
font-weight: 700;
}
a {
text-decoration: underline;
text-underline-offset: 3px;
}
a:hover {
background: var(--selection);
}
</style>
+188
View File
@@ -0,0 +1,188 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { profile } from '#lib/content';
import { openPage, openUrl } from '#lib/desktop/actions';
import { themeState } from '#lib/theme/theme.svelte';
import { runCommand, type CommandContext } from '#lib/terminal/commands';
import { complete } from '#lib/terminal/completion';
import { line, span, type OutputBlock } from '#lib/terminal/output';
import OutputLine from './OutputLine.svelte';
import Fetch from './Fetch.svelte';
let { startupCommand = '', focused, onexit }: { startupCommand?: string; focused: boolean; onexit: () => void } =
$props();
const typingDelay = 35;
let blocks = $state<OutputBlock[]>([]);
let input = $state('');
let isTyping = $state(false);
let history: string[] = [];
let historyIndex = 0;
let inputElement = $state<HTMLInputElement>();
let screenElement = $state<HTMLDivElement>();
const context: CommandContext = {
print: (...newBlocks) => blocks.push(...newBlocks),
clear: () => (blocks = []),
exit: onexit,
openPage,
openUrl,
setTheme: (themeId) => themeState.set(themeId),
currentThemeId: () => themeState.currentId
};
async function execute(command: string) {
blocks.push({ kind: 'prompt', command });
if (command.trim()) history.push(command);
historyIndex = history.length;
runCommand(command, context);
await tick();
screenElement?.scrollTo({ top: screenElement.scrollHeight });
}
async function typeOut(command: string) {
isTyping = true;
for (const character of command) {
input += character;
await new Promise((resolve) => setTimeout(resolve, typingDelay));
}
input = '';
isTyping = false;
await execute(command);
}
async function showCompletion() {
const result = complete(input);
input = result.input;
if (result.options.length === 0) return;
blocks.push({ kind: 'prompt', command: input }, line(span(result.options.join(' '), 'cyan')));
await tick();
screenElement?.scrollTo({ top: screenElement.scrollHeight });
}
function recallHistory(step: number) {
if (history.length === 0) return;
historyIndex = Math.min(history.length, Math.max(0, historyIndex + step));
input = history[historyIndex] ?? '';
}
function handleKeydown(event: KeyboardEvent) {
if (isTyping) {
event.preventDefault();
return;
}
if (event.key === 'Enter') {
const command = input;
input = '';
execute(command);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
recallHistory(-1);
} else if (event.key === 'ArrowDown') {
event.preventDefault();
recallHistory(1);
} else if (event.key === 'Tab') {
event.preventDefault();
showCompletion();
} else if (event.key === 'l' && event.ctrlKey) {
event.preventDefault();
context.clear();
}
}
function focusInput() {
if (!window.getSelection()?.toString()) inputElement?.focus({ preventScroll: true });
}
$effect(() => {
if (focused) inputElement?.focus({ preventScroll: true });
});
onMount(() => {
if (startupCommand) typeOut(startupCommand);
});
</script>
<div class="terminal" bind:this={screenElement} onclick={focusInput} role="presentation">
{#each blocks as block, index (index)}
{#if block.kind === 'prompt'}
<div class="prompt-line">
<span class="prompt">{profile.username}@{profile.hostname}</span>
<span class="path">~</span>
<span class="symbol">$</span>
<span>{block.command}</span>
</div>
{:else if block.kind === 'fetch'}
<Fetch />
{:else}
<OutputLine spans={block.spans} />
{/if}
{/each}
<label class="prompt-line input-line">
<span class="prompt">{profile.username}@{profile.hostname}</span>
<span class="path">~</span>
<span class="symbol">$</span>
<span class="input-wrapper">
<input
bind:this={inputElement}
bind:value={input}
onkeydown={handleKeydown}
spellcheck="false"
autocomplete="off"
autocapitalize="off"
aria-label="Terminal input"
/>
</span>
</label>
</div>
<style>
.terminal {
height: 100%;
overflow-y: auto;
padding: 0.75rem 1.1rem 1.5rem;
line-height: 1.5;
cursor: text;
}
.prompt-line {
display: flex;
gap: 0.5ch;
white-space: pre-wrap;
word-break: break-word;
}
.prompt {
color: var(--green);
font-weight: 700;
}
.path {
color: var(--blue);
font-weight: 700;
}
.symbol {
color: var(--accent);
}
.input-wrapper {
flex: 1;
display: flex;
}
input {
flex: 1;
min-width: 0;
padding: 0;
background: none;
border: none;
outline: none;
box-shadow: none;
color: var(--foreground);
font: inherit;
caret-color: var(--foreground);
}
</style>
-186
View File
@@ -1,186 +0,0 @@
<script lang="ts">
import { untrack } from "svelte";
import Icon from "@iconify/svelte";
import { getButtonStyle, parseColorText, getSegmentStyle } from "./utils";
import { themeColors } from "$lib/stores/theme";
import type { AccordionLine } from "./types";
import TuiLine from "./TuiLine.svelte";
import "$lib/assets/css/tui-accordion.css";
interface Props {
line: AccordionLine;
inline?: boolean;
}
let { line, inline = false }: Props = $props();
let openItems = $state(
new Set(untrack(() => line.accordionOpen) ? [0] : []),
);
function toggleItem(index: number) {
const newSet = new Set(openItems);
if (newSet.has(index)) {
newSet.delete(index);
} else {
newSet.add(index);
}
openItems = newSet;
}
const items = $derived(
line.accordionItems || [{ title: line.content, content: "" }],
);
const isSingleItemMode = $derived(
!line.accordionItems && line.children && line.children.length > 0,
);
</script>
<div
class="tui-accordion"
class:inline
style="--accordion-accent: {getButtonStyle(line.style)}"
>
{#each items as item, i}
{@const contentSegments = parseColorText(
item.content,
$themeColors.colorMap,
)}
<div class="accordion-item" class:open={openItems.has(i)}>
<button class="accordion-header" onclick={() => toggleItem(i)}>
<Icon
icon={openItems.has(i)
? "mdi:chevron-down"
: "mdi:chevron-right"}
width="16"
class="accordion-icon"
/>
<span class="accordion-title">{item.title}</span>
</button>
{#if openItems.has(i)}
<div class="accordion-content">
{#each contentSegments as segment}
{#if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}
>{segment.text}</span
>
{:else}
{segment.text}
{/if}
{/each}
{#if isSingleItemMode && line.children}
<div
class="accordion-children"
class:flex={line.display === "flex"}
class:grid={line.display === "grid"}
>
{#each line.children as child, k}
<TuiLine
line={child}
index={k}
segments={parseColorText(child.content, $themeColors.colorMap)}
complete={true}
showImage={true}
selectedIndex={-1}
inline={false}
/>
{/each}
</div>
{/if}
</div>
{/if}
</div>
{/each}
</div>
<style>
.tui-accordion {
margin: 0.5rem 0;
border: 1px solid var(--terminal-border);
border-radius: 6px;
overflow: hidden;
}
.accordion-item {
border-bottom: 1px solid var(--terminal-border);
}
.accordion-item:last-child {
border-bottom: none;
}
.accordion-header {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.6rem 0.75rem;
background: transparent;
border: none;
color: var(--terminal-text);
font-family: inherit;
font-size: 0.85rem;
font-weight: 500;
text-align: left;
cursor: pointer;
transition: background 0.15s ease;
}
.accordion-header:hover {
background: rgba(255, 255, 255, 0.03);
}
.accordion-item.open .accordion-header {
background: rgba(0, 0, 0, 0.1);
border-bottom: 1px solid var(--terminal-border);
}
:global(.accordion-icon) {
color: var(--accordion-accent);
transition: transform 0.2s ease;
}
.accordion-title {
flex: 1;
}
.accordion-content {
padding: 0.75rem;
color: var(--terminal-muted);
font-size: 0.85rem;
line-height: 1.6;
white-space: pre-wrap;
animation: slideDown 0.2s ease-out;
}
.accordion-children {
margin-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.accordion-children.flex {
flex-direction: row;
flex-wrap: wrap;
gap: 1rem;
}
.accordion-children.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
-313
View File
@@ -1,313 +0,0 @@
<script lang="ts">
import { getSegmentsUpToChar } from "./utils";
import { user } from "$lib/config";
import TuiLine from "./TuiLine.svelte";
import type { DisplayedLine, TerminalLine } from "./types";
import "$lib/assets/css/tui-body.css";
interface Props {
displayedLines: DisplayedLine[];
currentLineIndex?: number;
isTyping?: boolean;
selectedIndex?: number;
ref?: HTMLDivElement | undefined;
onButtonClick: (idx: number, line?: TerminalLine) => void;
onHoverButton: (idx: number) => void;
onLinkClick: (idx: number) => void;
terminalSettings: { showCursor: boolean };
interactiveInput?: boolean;
onCommand?: (command: string) => void;
}
let {
displayedLines = [],
currentLineIndex = 0,
isTyping = false,
selectedIndex = -1,
ref = $bindable(undefined),
onButtonClick,
onHoverButton,
onLinkClick,
terminalSettings,
interactiveInput = false,
onCommand,
}: Props = $props();
let inputEl = $state<HTMLInputElement>();
let commandValue = $state("");
let history = $state<string[]>([]);
let historyIndex = $state(-1);
let inputFocused = $state(false);
let caretPos = $state(0);
const beforeText = $derived(commandValue.slice(0, caretPos));
const cursorChar = $derived(commandValue.slice(caretPos, caretPos + 1) || " ");
const afterRest = $derived(commandValue.slice(caretPos + 1));
const afterFull = $derived(commandValue.slice(caretPos));
function syncCaret() {
caretPos = inputEl?.selectionStart ?? commandValue.length;
}
function recall(value: string) {
commandValue = value;
caretPos = value.length;
}
function submitCommand() {
const value = commandValue;
commandValue = "";
caretPos = 0;
if (value.trim()) history = [...history, value];
historyIndex = -1;
onCommand?.(value);
queueMicrotask(() => inputEl?.focus());
}
function handleInputKeydown(event: KeyboardEvent) {
if (event.key === "Enter") {
event.preventDefault();
event.stopPropagation();
submitCommand();
} else if (event.key === "ArrowUp") {
event.preventDefault();
event.stopPropagation();
if (history.length === 0) return;
const nextIndex =
historyIndex === -1
? history.length - 1
: Math.max(0, historyIndex - 1);
historyIndex = nextIndex;
recall(history[nextIndex]);
} else if (event.key === "ArrowDown") {
event.preventDefault();
event.stopPropagation();
if (historyIndex === -1) return;
const nextIndex = historyIndex + 1;
if (nextIndex >= history.length) {
historyIndex = -1;
recall("");
} else {
historyIndex = nextIndex;
recall(history[nextIndex]);
}
} else {
event.stopPropagation();
}
}
function focusCommandInput(event: MouseEvent) {
if (!interactiveInput) return;
const target = event.target as HTMLElement;
if (target.closest("a, button, input, textarea, .tui-button, .tui-link"))
return;
inputEl?.focus();
}
// Group consecutive inline items together
type ProcessedGroup =
| { kind: "single"; index: number; displayed: DisplayedLine }
| {
kind: "inline";
items: Array<{ index: number; displayed: DisplayedLine }>;
};
const processedGroups = $derived.by(() => {
const groups: ProcessedGroup[] = [];
let i = 0;
while (i < displayedLines.length) {
const displayed = displayedLines[i];
const isInline =
displayed.parsed.line.inline ||
displayed.parsed.line.display === "inline";
if (isInline) {
const inlineItems: Array<{
index: number;
displayed: DisplayedLine;
}> = [];
// Collect consecutive inline items
while (i < displayedLines.length) {
const nextLine = displayedLines[i].parsed.line;
const nextIsInline =
nextLine.inline || nextLine.display === "inline";
if (!nextIsInline) break;
inlineItems.push({
index: i,
displayed: displayedLines[i],
});
i++;
}
groups.push({ kind: "inline", items: inlineItems });
} else {
groups.push({ kind: "single", index: i, displayed });
i++;
}
}
return groups;
});
</script>
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="tui-body" bind:this={ref} onclick={focusCommandInput}>
{#each processedGroups as group, gi (gi)}
{#if group.kind === "inline"}
<div class="tui-inline-group">
{#each group.items as item (item.index)}
<TuiLine
line={item.displayed.parsed.line}
index={item.index}
segments={getSegmentsUpToChar(
item.displayed.parsed.segments,
item.displayed.charIndex,
)}
complete={item.displayed.complete}
showImage={item.displayed.showImage}
{selectedIndex}
inline={true}
showCursor={terminalSettings.showCursor &&
item.index === currentLineIndex &&
!item.displayed.complete &&
isTyping &&
item.displayed.parsed.line.type !== "image"}
{onButtonClick}
{onHoverButton}
{onLinkClick}
/>
{/each}
</div>
{:else}
<TuiLine
line={group.displayed.parsed.line}
index={group.index}
segments={getSegmentsUpToChar(
group.displayed.parsed.segments,
group.displayed.charIndex,
)}
complete={group.displayed.complete}
showImage={group.displayed.showImage}
{selectedIndex}
inline={false}
showCursor={terminalSettings.showCursor &&
group.index === currentLineIndex &&
!group.displayed.complete &&
isTyping &&
group.displayed.parsed.line.type !== "image"}
{onButtonClick}
{onHoverButton}
{onLinkClick}
/>
{/if}
{/each}
{#if interactiveInput}
{#if !isTyping}
<div class="tui-line prompt tui-command-line">
<span class="prompt">
<span class="user">{user.username}</span><span class="at"
>@</span
><span class="host">{user.hostname}</span>
<span class="separator">:</span><span class="path">~</span><span
class="symbol">$</span
>
</span>
<span class="tui-input-wrap">
<input
class="tui-command-input"
bind:this={inputEl}
bind:value={commandValue}
onkeydown={handleInputKeydown}
onkeyup={syncCaret}
oninput={syncCaret}
onclick={syncCaret}
onfocus={() => {
inputFocused = true;
syncCaret();
}}
onblur={() => (inputFocused = false)}
spellcheck="false"
autocomplete="off"
autocapitalize="off"
autocorrect="off"
aria-label="Terminal command input"
/><span class="tui-input-mirror" aria-hidden="true">{beforeText}{#if inputFocused}<span class="cursor-block blink">{cursorChar}</span>{afterRest}{:else}{afterFull}{/if}</span>
</span>
</div>
{/if}
{:else if terminalSettings.showCursor && !isTyping && displayedLines.length > 0}
<div class="tui-line prompt">
<span class="prompt">
<span class="user">{user.username}</span><span class="at"
>@</span
><span class="host">{user.hostname}</span>
<span class="separator">:</span><span class="path">~</span><span
class="symbol">$</span
>
</span>
<span class="cursor blink"></span>
</div>
{/if}
</div>
<style>
.tui-command-line {
display: flex;
align-items: center;
}
.tui-input-wrap {
position: relative;
flex: 1;
min-width: 0;
margin-left: 0.4rem;
display: flex;
align-items: center;
}
.tui-command-input {
width: 100%;
margin: 0;
padding: 0;
background: transparent;
border: none;
outline: none;
box-shadow: none;
color: transparent;
font: inherit;
line-height: inherit;
caret-color: transparent;
}
.tui-command-input:focus,
.tui-command-input:focus-visible {
border: none;
outline: none;
box-shadow: none;
}
.tui-input-mirror {
position: absolute;
inset: 0;
display: flex;
align-items: center;
white-space: pre;
overflow: hidden;
pointer-events: none;
color: var(--terminal-text);
}
.cursor-block {
display: inline-block;
height: 1.2em;
line-height: 1.2em;
color: var(--terminal-bg);
background: var(--terminal-accent, var(--terminal-primary));
}
.cursor-block.blink {
animation: cursorBlink 1s step-end infinite;
}
</style>
-74
View File
@@ -1,74 +0,0 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { getButtonStyle, parseColorText, getSegmentStyle } from "./utils";
import { themeColors } from "$lib/stores/theme";
import type { ButtonLine } from "./types";
import "$lib/assets/css/tui-button.css";
interface Props {
line: ButtonLine;
index: number;
selected: boolean;
onClick: (idx: number, line?: ButtonLine) => void;
onHover: (idx: number) => void;
inline?: boolean;
}
let {
line,
index,
selected,
onClick,
onHover,
inline = false,
}: Props = $props();
// Determine if this is an external link
const isExternal = $derived(
line.external ||
(line.href &&
(line.href.startsWith("http://") ||
line.href.startsWith("https://"))),
);
// Parse color formatting in content using theme colorMap
const segments = $derived(
parseColorText(line.content, $themeColors.colorMap),
);
</script>
<button
class="tui-button"
class:selected
class:inline
class:no-border={line.border === false}
class:flex-expand={line.flex}
style="--btn-color: {getButtonStyle(line.style)}"
onclick={() => onClick(index, line)}
onmouseenter={() => onHover(index)}
data-href={line.href || ""}
data-external={isExternal ? "true" : "false"}
>
<span class="btn-indicator">{selected ? "▶" : " "}</span>
{#if line.icon}
<Icon icon={line.icon} width="16" />
{/if}
<span class="btn-text" style:text-align={line.textStyle || "left"}>
{#each segments as segment}
{#if segment.icon}
<Icon icon={segment.icon} width={segment.iconSize ?? 14} class="inline-icon" />
{:else if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}>{segment.text}</span>
{:else}
{segment.text}
{/if}
{/each}
</span>
{#if line.href}
{#if isExternal}
<Icon icon="mdi:open-in-new" width="14" class="btn-arrow" />
{:else}
<Icon icon="mdi:chevron-right" width="16" class="btn-arrow" />
{/if}
{/if}
</button>
-291
View File
@@ -1,291 +0,0 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import {
getButtonStyle,
parseColorText,
getSegmentStyle,
parseDimension,
} from "./utils";
import { themeColors } from "$lib/stores/theme";
import type { CardLine, TerminalLine } from "./types";
import TuiLine from "./TuiLine.svelte";
import "$lib/assets/css/tui-card.css";
interface Props {
line: CardLine;
inline?: boolean;
onButtonClick?: (idx: number, line?: any) => void;
onHoverButton?: (idx: number) => void;
onLinkClick?: (idx: number) => void;
}
let {
line,
inline = false,
onButtonClick = () => {},
onHoverButton = () => {},
onLinkClick = () => {},
}: Props = $props();
const segments = $derived(parseColorText(line.content, $themeColors.colorMap));
const titleSegments = $derived(
line.cardTitle ? parseColorText(line.cardTitle, $themeColors.colorMap) : [],
);
const footerSegments = $derived(
line.cardFooter ? parseColorText(line.cardFooter, $themeColors.colorMap) : [],
);
const cardStyle = $derived(
[
`--card-color: ${getButtonStyle(line.style)}`,
line.cardWidth ? `width: ${parseDimension(line.cardWidth)}` : "",
line.cardHeight ? `height: ${parseDimension(line.cardHeight)}` : "",
line.cardFloat === "start" ? "margin-right: auto" : "",
line.cardFloat === "center" ? "margin-inline: auto" : "",
line.cardFloat === "end" ? "margin-left: auto" : "",
]
.filter(Boolean)
.join("; "),
);
const processedChildren = $derived.by(() => {
if (!line.children) return [];
const groups: Array<
| { kind: "single"; index: number; line: TerminalLine }
| {
kind: "inline";
items: Array<{ index: number; line: TerminalLine }>;
}
> = [];
let i = 0;
while (i < line.children.length) {
const child = line.children[i];
const isInline = child.inline || child.display === "inline";
if (isInline) {
const inlineItems: Array<{
index: number;
line: TerminalLine;
}> = [];
while (i < line.children.length) {
const nextChild = line.children[i];
const nextIsInline =
nextChild.inline || nextChild.display === "inline";
if (!nextIsInline) break;
inlineItems.push({ index: i, line: nextChild });
i++;
}
groups.push({ kind: "inline", items: inlineItems });
} else {
groups.push({ kind: "single", index: i, line: child });
i++;
}
}
return groups;
});
</script>
<div class="tui-card" class:inline style={cardStyle}>
{#if line.cardTitle}
<div class="card-header">
{#if line.icon}
<Icon icon={line.icon} width="16" class="card-icon" />
{/if}
<span class="card-title">
{#if line.link}
<a
href={line.link}
target="_blank"
rel="noopener noreferrer"
class="card-title-link"
>
{#each titleSegments as segment}
{#if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}>{segment.text}</span>
{:else}
{segment.text}
{/if}
{/each}
</a>
{:else}
{#each titleSegments as segment}
{#if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}>{segment.text}</span>
{:else}
{segment.text}
{/if}
{/each}
{/if}
</span>
</div>
{/if}
<div class="card-body">
{#if line.image}
<img src={line.image} alt={line.imageAlt || ""} class="card-image" />
{/if}
<div class="card-content">
{#each segments as segment}
{#if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}>{segment.text}</span>
{:else}
{segment.text}
{/if}
{/each}
</div>
{#if line.children && line.children.length > 0}
<div
class="card-children"
class:flex={line.display === "flex"}
class:grid={line.display === "grid"}
>
{#each processedChildren as group}
{#if group.kind === "inline"}
<div class="tui-inline-group">
{#each group.items as item}
<TuiLine
line={item.line}
index={item.index}
segments={parseColorText(item.line.content, $themeColors.colorMap)}
complete={true}
showImage={true}
selectedIndex={-1}
inline={true}
{onButtonClick}
{onHoverButton}
{onLinkClick}
/>
{/each}
</div>
{:else}
<TuiLine
line={group.line}
index={group.index}
segments={parseColorText(group.line.content, $themeColors.colorMap)}
complete={true}
showImage={true}
selectedIndex={-1}
inline={false}
{onButtonClick}
{onHoverButton}
{onLinkClick}
/>
{/if}
{/each}
</div>
{/if}
</div>
{#if line.cardFooter}
<div class="card-footer">
{#each footerSegments as segment}
{#if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}>{segment.text}</span>
{:else}
{segment.text}
{/if}
{/each}
</div>
{/if}
</div>
<style>
.tui-card {
border: 1px solid var(--terminal-border);
border-radius: 6px;
background: color-mix(
in srgb,
var(--terminal-bg) 80%,
var(--card-accent) 5%
);
margin: 0.5rem 0;
overflow: hidden;
transition: border-color 0.2s ease;
}
.tui-card:hover {
border-color: var(--card-accent);
}
.card-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.75rem;
border-bottom: 1px solid var(--terminal-border);
background: rgba(0, 0, 0, 0.1);
}
:global(.card-icon) {
color: var(--card-accent);
}
.card-title {
font-weight: 600;
color: var(--terminal-text);
font-size: 0.85rem;
}
.card-title-link {
color: var(--card-accent);
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 2px;
transition: all 0.15s ease;
}
.card-title-link:hover {
text-decoration-style: solid;
filter: brightness(1.2);
}
.card-body {
padding: 0.75rem;
}
.card-image {
width: 100%;
max-height: 200px;
object-fit: cover;
border-radius: 4px;
margin-bottom: 0.5rem;
}
.card-content {
color: var(--terminal-muted);
font-size: 0.85rem;
line-height: 1.5;
white-space: pre-wrap;
}
.card-children {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.25rem;
}
.card-children.flex {
flex-direction: row;
flex-wrap: wrap;
gap: 1rem;
}
.card-children.grid {
display: grid;
grid-template-columns: repeat(
auto-fill,
minmax(max(200px, calc((100% - 5rem) / 6)), 1fr)
);
gap: 1rem;
}
.card-footer {
padding: 0.5rem 0.75rem;
border-top: 1px solid var(--terminal-border);
font-size: 0.75rem;
color: var(--terminal-muted);
background: rgba(0, 0, 0, 0.05);
}
</style>
-367
View File
@@ -1,367 +0,0 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { themeColors } from "$lib/stores/theme";
import type { CardGridLine } from "./types";
import "$lib/assets/css/tui-card-grid.css";
interface Props {
line: CardGridLine;
inline?: boolean;
}
let { line, inline = false }: Props = $props();
const cards = $derived(line.cards || []);
</script>
<div
class="tui-card-grid"
class:inline
style="--card-warning: {$themeColors.colorMap.warning};"
>
{#each cards as card}
<article class="tui-card" class:featured={card.featured}>
{#if card.image}
<div class="card-image">
<img src={card.image} alt={card.title} loading="lazy" />
</div>
{:else if card.featured}
<div class="card-header-badge">
<span class="featured-badge">★ Featured</span>
</div>
{/if}
<div class="card-body">
<h3 class="card-title">{card.title}</h3>
{#if card.hackathonName}
<div class="card-meta">
<span class="meta-icon">🏛️</span>
<span class="hackathon-name">{card.hackathonName}</span>
{#if card.year}<span class="year">({card.year})</span
>{/if}
</div>
{/if}
{#if card.university}
<div class="card-location">
<span class="meta-icon">📍</span>
{card.university}{card.location
? `, ${card.location}`
: ""}
</div>
{/if}
{#if card.awards && card.awards.length > 0}
<div class="awards">
{#each card.awards as award}
<div class="award">
<span class="award-icon">🏆</span>
<span class="award-text"
>{award.place} — {award.track}</span
>
</div>
{/each}
</div>
{/if}
<p class="card-desc">{card.description}</p>
{#if card.tags && card.tags.length > 0}
<div class="tags">
{#each card.tags as tag}
<span class="tag">{tag}</span>
{/each}
</div>
{/if}
{#if card.liveWarning}
<div class="warning">Demo may be unavailable</div>
{/if}
<div class="card-actions">
{#if card.link}
<a
href={card.link}
target="_blank"
rel="noopener noreferrer"
class="action-btn primary"
>
<Icon icon="mdi:open-in-new" width="12" />
<span>Demo</span>
</a>
{/if}
{#if card.repo}
<a
href={card.repo}
target="_blank"
rel="noopener noreferrer"
class="action-btn"
>
<Icon icon="mdi:github" width="12" />
<span>Code</span>
</a>
{/if}
{#if card.devpost}
<a
href={card.devpost}
target="_blank"
rel="noopener noreferrer"
class="action-btn"
>
<Icon icon="mdi:rocket-launch" width="12" />
<span>Devpost</span>
</a>
{/if}
</div>
</div>
</article>
{/each}
</div>
<style>
.tui-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
padding: 0.5rem 0;
width: 100%;
}
.tui-card {
background: var(--terminal-bg);
border: 1px solid var(--terminal-border);
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
transition: all 0.2s ease;
font-family: "JetBrains Mono", monospace;
}
.tui-card:hover {
border-color: var(--terminal-primary);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
.tui-card.featured {
border-color: var(--terminal-accent);
}
.card-image {
position: relative;
width: 100%;
height: 140px;
overflow: hidden;
background: var(--terminal-bg-light);
}
.card-image img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.tui-card:hover .card-image img {
transform: scale(1.05);
}
.card-header-badge {
padding: 0.5rem 0.75rem 0;
}
.featured-badge {
position: absolute;
top: 0.5rem;
right: 0.5rem;
background: rgba(0, 0, 0, 0.75);
color: var(--terminal-accent);
padding: 0.15rem 0.4rem;
border-radius: 3px;
font-size: 0.65rem;
font-weight: 600;
backdrop-filter: blur(4px);
}
.card-header-badge .featured-badge {
position: static;
display: inline-block;
}
.card-body {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
flex: 1;
}
.card-title {
margin: 0;
font-size: 0.9rem;
font-weight: 600;
color: var(--terminal-text);
line-height: 1.3;
}
.card-meta {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.7rem;
color: var(--terminal-primary);
}
.meta-icon {
font-size: 0.75rem;
}
.hackathon-name {
font-weight: 500;
}
.year {
color: var(--terminal-muted);
}
.card-location {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.65rem;
color: var(--terminal-muted);
}
.awards {
display: flex;
flex-direction: column;
gap: 0.2rem;
margin: 0.25rem 0;
}
.award {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.7rem;
}
.award-icon {
font-size: 0.75rem;
}
.award-text {
color: #a6e3a1;
font-weight: 500;
}
.card-desc {
margin: 0;
font-size: 0.7rem;
color: var(--terminal-muted);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: auto;
}
.tag {
background: color-mix(
in srgb,
var(--terminal-primary) 15%,
transparent
);
color: var(--terminal-primary);
padding: 0.1rem 0.35rem;
border-radius: 3px;
font-size: 0.55rem;
font-weight: 500;
text-transform: lowercase;
}
.warning {
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 {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--terminal-border);
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: transparent;
border: 1px solid var(--terminal-border);
border-radius: 4px;
color: var(--terminal-text);
font-size: 0.65rem;
font-weight: 500;
text-decoration: none;
transition: all 0.15s ease;
font-family: inherit;
}
.action-btn:hover {
background: var(--terminal-primary);
border-color: var(--terminal-primary);
color: var(--terminal-bg);
}
.action-btn.primary {
background: var(--terminal-primary);
border-color: var(--terminal-primary);
color: var(--terminal-bg);
}
.action-btn.primary:hover {
background: var(--terminal-accent);
border-color: var(--terminal-accent);
}
@media (max-width: 600px) {
.tui-card-grid {
grid-template-columns: 1fr;
}
}
</style>
-151
View File
@@ -1,151 +0,0 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { getButtonStyle, parseColorText, getSegmentStyle } from "./utils";
import { themeColors } from "$lib/stores/theme";
import type { CheckboxLine } from "./types";
import "$lib/assets/css/tui-checkbox.css";
interface Props {
line: CheckboxLine;
inline?: boolean;
checked?: boolean;
onchange?: (checked: boolean) => void;
}
let {
line,
inline = false,
checked = $bindable(false),
onchange,
}: Props = $props();
const labelSegments = $derived(
line.content ? parseColorText(line.content, $themeColors.colorMap) : [],
);
const isDisabled = $derived(line.inputDisabled || false);
const indeterminate = $derived(line.checkboxIndeterminate || false);
function handleChange() {
if (isDisabled) return;
checked = !checked;
onchange?.(checked);
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
handleChange();
}
}
function getCheckboxSymbol(
checked: boolean,
indeterminate: boolean,
): string {
if (indeterminate) return "[-]";
return checked ? "[✓]" : "[ ]";
}
</script>
<div
class="tui-checkbox"
class:inline
class:checked
class:disabled={isDisabled}
style="--checkbox-color: {getButtonStyle(line.style)}"
role="checkbox"
aria-checked={indeterminate ? "mixed" : checked}
aria-disabled={isDisabled}
tabindex={isDisabled ? -1 : 0}
onclick={handleChange}
onkeydown={handleKeydown}
>
<span class="checkbox-box" class:indeterminate>
{getCheckboxSymbol(checked, indeterminate)}
</span>
{#if line.icon}
<Icon icon={line.icon} width="14" class="checkbox-icon" />
{/if}
{#if line.content}
<span class="checkbox-label">
{#each labelSegments as segment}
{#if segment.icon}
<Icon icon={segment.icon} width={segment.iconSize ?? 14} class="inline-icon" />
{:else if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}>{segment.text}</span>
{:else}
{segment.text}
{/if}
{/each}
</span>
{/if}
</div>
<style>
.tui-checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0.35rem 0;
padding: 0.35rem 0.5rem;
font-size: 0.9rem;
cursor: pointer;
border-radius: 4px;
transition: all 0.15s ease;
user-select: none;
}
.tui-checkbox.inline {
display: inline-flex;
margin: 0 0.75rem 0 0;
}
.tui-checkbox:hover:not(.disabled) {
background: color-mix(in srgb, var(--checkbox-color) 10%, transparent);
}
.tui-checkbox:focus-visible {
outline: 1px solid var(--checkbox-color);
outline-offset: 2px;
}
.tui-checkbox.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.checkbox-box {
font-family: inherit;
font-weight: bold;
color: var(--terminal-muted);
transition: color 0.15s ease;
}
.tui-checkbox.checked .checkbox-box,
.checkbox-box.indeterminate {
color: var(--checkbox-color);
}
:global(.checkbox-icon) {
color: var(--checkbox-color);
}
.checkbox-label {
color: var(--terminal-text);
}
.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;
}
}
</style>
-36
View File
@@ -1,36 +0,0 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import "$lib/assets/css/tui-footer.css";
interface Props {
isTyping: boolean;
linesCount: number;
skipAnimation: () => void;
}
let { isTyping, linesCount, skipAnimation }: Props = $props();
</script>
<div class="tui-statusbar bottom">
<span class="status-left">
<Icon icon="mdi:console" width="14" />
<span>TUI</span>
</span>
<span class="status-center">
{#if isTyping}
<span class="typing-indicator">Loading...</span>
{:else}
Ready
{/if}
</span>
<span class="status-right">
{#if isTyping}
<button class="skip-btn" onclick={skipAnimation}>
<Icon icon="mdi:skip-forward" width="12" />
<span>Skip (Y)</span>
</button>
{:else}
<span class="line-count">{linesCount} lines</span>
{/if}
</span>
</div>
-198
View File
@@ -1,198 +0,0 @@
<script lang="ts">
import { parseColorText, getPlainText } from "./utils";
import { themeColors } from "$lib/stores/theme";
import TuiLine from "./TuiLine.svelte";
import type { GroupLine, TerminalLine } from "./types";
interface Props {
line: GroupLine;
inline?: boolean;
onButtonClick?: (idx: number, line?: TerminalLine) => void;
onHoverButton?: (idx: number) => void;
onLinkClick?: (idx: number) => void;
}
let {
line,
inline = false,
onButtonClick = () => {},
onHoverButton = () => {},
onLinkClick = () => {},
}: Props = $props();
// Get colorMap from current theme
const colorMap = $derived($themeColors.colorMap);
// Parse children with current colorMap
const parsedChildren = $derived(
(line.children || []).map((child) => {
const segments = parseColorText(child.content, colorMap);
return { line: child, segments, plainText: getPlainText(segments) };
}),
);
// Group consecutive inline elements
const processedChildren = $derived.by(() => {
const groups: Array<
| { kind: "single"; index: number; line: TerminalLine }
| {
kind: "inline";
items: Array<{ index: number; line: TerminalLine }>;
}
> = [];
let i = 0;
while (i < parsedChildren.length) {
const child = parsedChildren[i];
const isInline = child.line.inline || child.line.display === "inline";
if (isInline) {
const inlineItems: Array<{
index: number;
line: TerminalLine;
}> = [];
while (i < parsedChildren.length) {
const nextChild = parsedChildren[i];
const nextIsInline =
nextChild.line.inline || nextChild.line.display === "inline";
if (!nextIsInline) break;
inlineItems.push({
index: i,
line: nextChild.line,
});
i++;
}
groups.push({ kind: "inline", items: inlineItems });
} else {
groups.push({ kind: "single", index: i, line: child.line });
i++;
}
}
return groups;
});
// Style for the group container
const groupStyle = $derived(() => {
const styles: string[] = [];
// Only apply flex styles if not grid or block
if (line.display !== "grid" && line.display !== "block") {
if (line.groupDirection === "column")
styles.push("flex-direction: column");
if (line.groupAlign) {
const alignMap: Record<string, string> = {
start: "flex-start",
center: "center",
end: "flex-end",
stretch: "stretch",
};
styles.push(
`align-items: ${alignMap[line.groupAlign] || "flex-start"}`,
);
}
}
if (line.groupGap) styles.push(`gap: ${line.groupGap}`);
return styles.join("; ");
});
</script>
<div
class="tui-group"
class:inline
class:grid={line.display === "grid"}
class:block={line.display === "block"}
class:expand={line.groupExpand}
style={groupStyle()}
id={line.id}
>
{#each processedChildren as group}
{#if group.kind === "inline"}
<div class="tui-inline-group">
{#each group.items as item}
<TuiLine
line={item.line}
index={item.index}
segments={parseColorText(item.line.content, colorMap)}
complete={true}
showImage={item.line.type === "image"}
selectedIndex={-1}
inline={true}
{onButtonClick}
{onHoverButton}
{onLinkClick}
/>
{/each}
</div>
{:else}
<TuiLine
line={group.line}
index={group.index}
segments={parseColorText(group.line.content, colorMap)}
complete={true}
showImage={group.line.type === "image"}
selectedIndex={-1}
inline={false}
{onButtonClick}
{onHoverButton}
{onLinkClick}
/>
{/if}
{/each}
</div>
<style>
.tui-group {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 0.75rem;
animation: lineSlideIn 0.15s ease-out;
}
.tui-group.inline {
display: inline-flex;
}
.tui-group.grid {
display: grid;
grid-template-columns: repeat(
auto-fill,
minmax(max(200px, calc((100% - 3.75rem) / 5)), 1fr)
);
width: 100%;
}
.tui-group.block {
display: block;
width: 100%;
}
.tui-group.expand > :global(*),
.tui-group.expand > :global(*) > :global(*) {
flex: 1;
}
.tui-group.expand :global(.tui-button) {
flex: 1;
width: auto;
}
@keyframes lineSlideIn {
from {
opacity: 0;
transform: translateX(-5px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@media (max-width: 768px) {
/* .tui-group.inline removed to allow inline flow on mobile */
.tui-group.grid {
grid-template-columns: 1fr;
}
}
</style>
-36
View File
@@ -1,36 +0,0 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { user } from "$lib/config";
import {
colorTheme,
getThemeIcon,
type ColorTheme,
} from "$lib/stores/theme";
import "$lib/assets/css/tui-header.css";
interface Props {
title?: string;
interactive?: boolean;
hasButtons?: boolean;
}
let {
title = "terminal",
interactive = true,
hasButtons = false,
}: Props = $props();
</script>
<div class="tui-statusbar top">
<span class="status-left">
<Icon icon={getThemeIcon($colorTheme)} width="14" />
<span class="user-host">{user.username}@{user.hostname}</span>
</span>
<span class="status-center">{title}</span>
<span class="status-right">
{#if interactive && hasButtons}
<span class="hint">↑↓ navigate</span>
<span class="hint">⏎ select</span>
{/if}
</span>
</div>
-218
View File
@@ -1,218 +0,0 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { getButtonStyle, parseColorText, getSegmentStyle } from "./utils";
import { themeColors } from "$lib/stores/theme";
import type { InputLine } from "./types";
import "$lib/assets/css/tui-input.css";
interface Props {
line: InputLine;
inline?: boolean;
value?: string;
oninput?: (value: string) => void;
onchange?: (value: string) => void;
onfocus?: () => void;
onblur?: () => void;
}
let {
line,
inline = false,
value = $bindable(""),
oninput,
onchange,
onfocus,
onblur,
}: Props = $props();
const labelSegments = $derived(
line.content ? parseColorText(line.content, $themeColors.colorMap) : [],
);
const placeholder = $derived(line.inputPlaceholder || "");
const inputType = $derived(line.inputType || "text");
const isDisabled = $derived(line.inputDisabled || false);
const hasError = $derived(line.inputError);
const errorMessage = $derived(line.inputErrorMessage || "");
const prefix = $derived(line.inputPrefix || "");
const suffix = $derived(line.inputSuffix || "");
function handleInput(e: Event) {
const target = e.target as HTMLInputElement;
value = target.value;
oninput?.(value);
}
function handleChange(e: Event) {
const target = e.target as HTMLInputElement;
onchange?.(target.value);
}
function handleFocus() {
onfocus?.();
}
function handleBlur() {
onblur?.();
}
</script>
<div
class="tui-input"
class:inline
class:error={hasError}
class:disabled={isDisabled}
style="--input-color: {getButtonStyle(line.style)}"
>
{#if line.content}
<label class="input-label">
{#if line.icon}
<Icon icon={line.icon} width="14" class="label-icon" />
{/if}
{#each labelSegments as segment}
{#if segment.icon}
<Icon icon={segment.icon} width={segment.iconSize ?? 14} class="inline-icon" />
{:else if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}>{segment.text}</span>
{:else}
{segment.text}
{/if}
{/each}
</label>
{/if}
<div class="input-wrapper">
<span class="input-prompt">❯</span>
{#if prefix}
<span class="input-affix prefix">{prefix}</span>
{/if}
<input
type={inputType}
{placeholder}
bind:value
disabled={isDisabled}
oninput={handleInput}
onchange={handleChange}
onfocus={handleFocus}
onblur={handleBlur}
/>
{#if suffix}
<span class="input-affix suffix">{suffix}</span>
{/if}
</div>
{#if hasError && errorMessage}
<div class="input-error">
<Icon icon="mdi:alert-circle" width="12" />
<span>{errorMessage}</span>
</div>
{/if}
</div>
<style>
.tui-input {
margin: 0.5rem 0;
font-size: 0.9rem;
}
.tui-input.inline {
display: inline-flex;
flex-direction: column;
margin: 0 0.5rem 0 0;
}
.input-label {
display: flex;
align-items: center;
gap: 0.35rem;
margin-bottom: 0.35rem;
color: var(--terminal-muted);
font-size: 0.85rem;
}
:global(.label-icon) {
color: var(--input-color);
}
.input-wrapper {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.5rem 0.75rem;
background: color-mix(in srgb, var(--terminal-bg) 80%, black);
border: 1px solid var(--terminal-muted);
border-radius: 4px;
transition: all 0.15s ease;
}
.input-wrapper:focus-within {
border-color: var(--input-color);
box-shadow: 0 0 0 1px
color-mix(in srgb, var(--input-color) 30%, transparent);
}
.tui-input.error .input-wrapper {
border-color: #f38ba8;
}
.tui-input.disabled .input-wrapper {
opacity: 0.5;
cursor: not-allowed;
}
.input-prompt {
color: var(--input-color);
font-weight: bold;
user-select: none;
}
.input-affix {
color: var(--terminal-muted);
font-size: 0.85rem;
}
.input-affix.prefix {
margin-right: 0.25rem;
}
.input-affix.suffix {
margin-left: 0.25rem;
}
input {
flex: 1;
min-width: 0;
background: transparent;
border: none;
color: var(--terminal-text);
font-family: inherit;
font-size: inherit;
outline: none;
}
input::placeholder {
color: var(--terminal-muted);
opacity: 0.6;
}
input:disabled {
cursor: not-allowed;
}
.input-error {
display: flex;
align-items: center;
gap: 0.35rem;
margin-top: 0.35rem;
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;
}
}
</style>
-251
View File
@@ -1,251 +0,0 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { getSegmentStyle, getLinePrefix } from "./utils";
import { handleNavigation } from "./terminal-keyboard";
import { user } from "$lib/config";
import TuiButton from "./TuiButton.svelte";
import TuiLink from "./TuiLink.svelte";
import TuiCard from "./TuiCard.svelte";
import TuiProgress from "./TuiProgress.svelte";
import TuiAccordion from "./TuiAccordion.svelte";
import TuiTable from "./TuiTable.svelte";
import TuiTooltip from "./TuiTooltip.svelte";
import TuiCardGrid from "./TuiCardGrid.svelte";
import TuiInput from "./TuiInput.svelte";
import TuiTextarea from "./TuiTextarea.svelte";
import TuiCheckbox from "./TuiCheckbox.svelte";
import TuiRadio from "./TuiRadio.svelte";
import TuiSelect from "./TuiSelect.svelte";
import TuiToggle from "./TuiToggle.svelte";
import TuiGroup from "./TuiGroup.svelte";
import type { TerminalLine, TextSegment } from "./types";
interface Props {
line: TerminalLine;
index: number;
segments: TextSegment[];
complete?: boolean;
showImage?: boolean;
selectedIndex?: number;
inline?: boolean;
showCursor?: boolean;
onButtonClick?: (idx: number, line?: TerminalLine) => void;
onHoverButton?: (idx: number) => void;
onLinkClick?: (idx: number) => void;
}
let {
line,
index,
segments,
complete = true,
showImage = false,
selectedIndex = -1,
inline = false,
showCursor = false,
onButtonClick = () => {},
onHoverButton = () => {},
onLinkClick = () => {},
}: Props = $props();
// Component types that have their own wrapper
const componentTypes = new Set([
"button",
"card",
"cardgrid",
"progress",
"accordion",
"table",
"input",
"textarea",
"checkbox",
"radio",
"select",
"toggle",
"group",
]);
// Types that need special handling
const isComponent = $derived(componentTypes.has(line.type));
const isBlank = $derived(line.type === "blank");
const isDivider = $derived(line.type === "divider");
const isImage = $derived(line.type === "image");
const isPrompt = $derived(
line.type === "command" || line.type === "prompt",
);
const isHeader = $derived(line.type === "header");
const isLink = $derived(line.type === "link");
const isTooltip = $derived(line.type === "tooltip");
const blankHeight = $derived(
line.type === "blank" && line.height !== undefined
? typeof line.height === "number"
? `${line.height}px`
: line.height
: undefined,
);
</script>
<div class:mobile-hidden={line.mobile === false} style="display: contents">
{#if isBlank}
{#if inline}<span
class="inline-blank"
style={blankHeight ? `width: ${blankHeight}` : undefined}
></span>{:else}<div
class="tui-line blank"
style={blankHeight
? `height: ${blankHeight}; min-height: ${blankHeight};`
: undefined}
></div>{/if}
{:else if isDivider}
<div class="tui-divider" id={line.id}>
<span class="divider-line"></span>
{#if line.content}<span class="divider-text">{line.content}</span>{/if}
<span class="divider-line"></span>
</div>
{:else if line.type === "button"}
<TuiButton
{line}
{index}
selected={selectedIndex === index}
onClick={onButtonClick}
onHover={onHoverButton}
{inline}
/>
{:else if line.type === "link"}
{#if inline}
<TuiLink {line} onClick={() => handleNavigation(line)} />
{:else}
<div class="tui-line link">
<TuiLink {line} onClick={() => onLinkClick(index)} />
</div>
{/if}
{:else if line.type === "tooltip"}
{#if inline}<TuiTooltip {line} />{:else}<div class="tui-line">
<TuiTooltip {line} />
</div>{/if}
{:else if line.type === "card"}
<TuiCard {line} {inline} {onButtonClick} {onHoverButton} {onLinkClick} />
{:else if line.type === "cardgrid"}
<TuiCardGrid {line} {inline} />
{:else if line.type === "progress"}
<TuiProgress {line} {inline} />
{:else if line.type === "accordion"}
<TuiAccordion {line} {inline} />
{:else if line.type === "table"}
<TuiTable {line} {inline} />
{:else if line.type === "input"}
<TuiInput {line} {inline} />
{:else if line.type === "textarea"}
<TuiTextarea {line} {inline} />
{:else if line.type === "checkbox"}
<TuiCheckbox {line} {inline} />
{:else if line.type === "radio"}
<TuiRadio {line} {inline} />
{:else if line.type === "select"}
<TuiSelect {line} {inline} />
{:else if line.type === "toggle"}
<TuiToggle {line} {inline} />
{:else if line.type === "group"}
<TuiGroup {line} {inline} {onButtonClick} {onHoverButton} {onLinkClick} />
{:else if line.type === "image" && showImage}
<div class="tui-image" class:inline-image={inline}>
<img
src={line.image}
alt={line.imageAlt || "Image"}
style="width: {line.imageWidth + "px" || "100%"}"
/>
{#if line.content}<span class="image-caption">{line.content}</span>{/if}
</div>
{:else if line.type === "header"}
{#if inline}
<span class="header-text inline-header">
<Icon icon="mdi:pound" width="20" class="header-icon" />
{#each segments as seg}{#if seg.icon}<Icon
icon={seg.icon}
width={seg.iconSize ?? 20}
class="inline-icon"
/>{:else if getSegmentStyle(seg)}<span
style={getSegmentStyle(seg)}>{seg.text}</span
>{:else}{seg.text}{/if}{/each}
</span>
{:else}
<div class="tui-line header" class:complete id={line.id}>
<span class="content header-text">
<Icon icon="mdi:pound" width="25" class="header-icon" />
{#each segments as seg}{#if seg.icon}<Icon
icon={seg.icon}
width={seg.iconSize ?? 25}
class="inline-icon"
/>{:else if getSegmentStyle(seg)}<span
style={getSegmentStyle(seg)}>{seg.text}</span
>{:else}{seg.text}{/if}{/each}
</span>
{#if showCursor}<span class="cursor"></span>{/if}
</div>
{/if}
{:else if line.type === "command" || line.type === "prompt"}
{#if inline}
<span class="inline-content {line.type}">
{getLinePrefix(line.type)}{#each segments as seg}{#if seg.icon}<Icon
icon={seg.icon}
width={seg.iconSize ?? 14}
class="inline-icon"
/>{:else if getSegmentStyle(seg)}<span
style={getSegmentStyle(seg)}>{seg.text}</span
>{:else}{seg.text}{/if}{/each}
</span>
{:else}
<div class="tui-line {line.type}" class:complete id={line.id}>
<span class="prompt">
<span class="user">{user.username}</span><span class="at"
>@</span
><span class="host">{user.hostname}</span>
<span class="separator">:</span><span class="path">~</span><span
class="symbol">$</span
>
</span>
<span class="content">
{getLinePrefix(
line.type,
)}{#each segments as seg}{#if seg.icon}<Icon
icon={seg.icon}
width={seg.iconSize ?? 14}
class="inline-icon"
/>{:else if getSegmentStyle(seg)}<span
style={getSegmentStyle(seg)}>{seg.text}</span
>{:else}{seg.text}{/if}{/each}
</span>
{#if showCursor}<span class="cursor"></span>{/if}
</div>
{/if}
{:else if !isImage}
{#if inline}
<span class="inline-content {line.type}">
{getLinePrefix(line.type)}{#each segments as seg}{#if seg.icon}<Icon
icon={seg.icon}
width={seg.iconSize ?? 14}
class="inline-icon"
/>{:else if getSegmentStyle(seg)}<span
style={getSegmentStyle(seg)}>{seg.text}</span
>{:else}{seg.text}{/if}{/each}
{#if showCursor}<span class="cursor"></span>{/if}
</span>
{:else}
<div class="tui-line {line.type}" class:complete id={line.id}>
<span class="content">
{getLinePrefix(
line.type,
)}{#each segments as seg}{#if seg.icon}<Icon
icon={seg.icon}
width={seg.iconSize ?? 14}
class="inline-icon"
/>{:else if getSegmentStyle(seg)}<span
style={getSegmentStyle(seg)}>{seg.text}</span
>{:else}{seg.text}{/if}{/each}
</span>
{#if showCursor}<span class="cursor"></span>{/if}
</div>
{/if}
{/if}
</div>
-95
View File
@@ -1,95 +0,0 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { getButtonStyle, parseColorText, getSegmentStyle } from "./utils";
import { themeColors } from "$lib/stores/theme";
import type { LinkLine } from "./types";
import "$lib/assets/css/tui-link.css";
interface Props {
line: LinkLine;
onClick: () => void;
}
let { line, onClick }: Props = $props();
// Determine if this is an external link
const isExternal = $derived(
line.external ||
(line.href &&
(line.href.startsWith("http://") ||
line.href.startsWith("https://"))),
);
// Parse color formatting in content using theme colorMap
const segments = $derived(
parseColorText(line.content, $themeColors.colorMap),
);
</script>
<span class="tui-link" style="--link-color: {getButtonStyle(line.style)}">
{#if line.icon}
<Icon icon={line.icon} width="14" class="link-icon" />
{/if}
<button class="link-text" onclick={onClick}>
{#each segments as segment}
{#if segment.icon}
<Icon icon={segment.icon} width={segment.iconSize ?? 14} class="inline-icon" />
{:else if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}>{segment.text}</span>
{:else}
{segment.text}
{/if}
{/each}
</button>
{#if isExternal}
<Icon icon="mdi:open-in-new" width="12" class="link-external" />
{/if}
</span>
<style>
.tui-link {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
:global(.link-icon) {
color: var(--link-color);
opacity: 0.8;
}
.link-text {
background: none;
border: none;
padding: 0;
margin: 0;
font-family: inherit;
font-size: inherit;
color: var(--link-color);
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 2px;
cursor: pointer;
transition: all 0.15s ease;
}
.link-text:hover {
text-decoration-style: solid;
filter: brightness(1.2);
}
:global(.link-external) {
color: var(--link-color);
opacity: 0.5;
}
.tui-link:hover :global(.link-external) {
opacity: 0.8;
}
:global(.inline-icon) {
display: inline-block;
vertical-align: middle;
margin: 0 0.15em;
}
</style>
-262
View File
@@ -1,262 +0,0 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { getButtonStyle, parseColorText, getSegmentStyle } from "./utils";
import { themeColors } from "$lib/stores/theme";
import type { ProgressLine } from "./types";
import "$lib/assets/css/tui-progress.css";
interface Props {
line: ProgressLine;
inline?: boolean;
}
let { line, inline = false }: Props = $props();
let containerWidth = $state(0);
const charWidth = 9.6; // Approximate width of a character in monospace font (adjust as needed)
const progress = $derived(Math.min(100, Math.max(0, line.progress ?? 0)));
const label = $derived(line.progressLabel || `${progress}%`);
const contentSegments = $derived(
parseColorText(line.content, $themeColors.colorMap),
);
const labelSegments = $derived(
parseColorText(label, $themeColors.colorMap),
);
// Calculate how many characters fit in the bar
const charCapacity = $derived(
Math.max(10, Math.floor((containerWidth - 30) / charWidth)),
);
</script>
<div
class="tui-progress"
class:inline
class:text-style={line.progressStyle === "text"}
style="--progress-color: {getButtonStyle(line.style)}"
bind:clientWidth={containerWidth}
>
{#if line.content}
<div class="progress-label">
{#each contentSegments as segment}
{#if segment.icon}
<Icon icon={segment.icon} width={segment.iconSize ?? 14} class="inline-icon" />
{:else if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}>{segment.text}</span>
{:else}
{segment.text}
{/if}
{/each}
</div>
{/if}
{#if line.progressStyle === "text"}
<div class="progress-text-bar">
<span class="bracket">[</span>
<span class="bar-content">
{#each Array(charCapacity) as _, i}
<span class="char"
>{i < Math.floor((progress / 100) * charCapacity)
? "#"
: "."}</span
>
{/each}
</span>
<span class="bracket">]</span>
</div>
{:else}
<div
class="progress-bar"
class:continuous={line.progressStyle === "continuous"}
>
<div class="progress-fill" style="width: {progress}%">
<span class="progress-glow"></span>
</div>
{#if line.progressStyle !== "continuous"}
<div class="progress-blocks">
{#each Array(20) as _, i}
<span
class="block"
class:filled={i < Math.floor(progress / 5)}
></span>
{/each}
</div>
{/if}
</div>
{/if}
<div class="progress-value">
{#each labelSegments as segment}
{#if segment.icon}
<Icon icon={segment.icon} width={segment.iconSize ?? 12} class="inline-icon" />
{:else if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}>{segment.text}</span>
{:else}
{segment.text}
{/if}
{/each}
</div>
</div>
<style>
.tui-progress {
margin: 0.5rem 0;
font-size: 0.85rem;
}
/* Inline progress styles */
.tui-progress.inline {
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin: 0;
min-width: 120px;
}
.tui-progress.inline .progress-label {
margin-bottom: 0;
white-space: nowrap;
}
.tui-progress.inline .progress-bar {
flex: 1;
min-width: 80px;
height: 0.8rem;
}
.tui-progress.inline .progress-value {
margin-top: 0;
white-space: nowrap;
}
.progress-label {
color: var(--terminal-text);
margin-bottom: 0.25rem;
}
.progress-bar {
position: relative;
height: 1.2rem;
background: var(--terminal-border);
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: linear-gradient(
90deg,
var(--progress-color),
color-mix(in srgb, var(--progress-color) 80%, white 20%)
);
border-radius: 3px;
transition: width 0.3s ease;
}
.progress-glow {
position: absolute;
right: 0;
top: 0;
height: 100%;
width: 20px;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.3)
);
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% {
opacity: 0;
}
50% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.progress-blocks {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
gap: 2px;
padding: 3px;
}
.block {
flex: 1;
background: rgba(0, 0, 0, 0.2);
border-radius: 1px;
transition: background 0.2s;
}
.block.filled {
background: transparent;
}
.progress-value {
text-align: right;
color: var(--progress-color);
font-size: 0.75rem;
margin-top: 0.25rem;
font-weight: 600;
}
:global(.inline-icon) {
display: inline-block;
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%;
}
}
/* Continuous Style */
.progress-bar.continuous .progress-blocks {
display: none;
}
/* Text Style */
.progress-text-bar {
font-family: monospace;
font-size: 1rem;
color: var(--terminal-dim);
white-space: nowrap;
}
.progress-text-bar .char {
color: var(--terminal-dim);
}
.tui-progress:not(.text-style) .progress-text-bar {
display: none;
}
.progress-text-bar .bracket {
color: var(--terminal-text);
font-weight: bold;
}
</style>
-206
View File
@@ -1,206 +0,0 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { getButtonStyle, parseColorText, getSegmentStyle } from "./utils";
import { themeColors } from "$lib/stores/theme";
import type { RadioLine } from "./types";
import "$lib/assets/css/tui-radio.css";
interface Props {
line: RadioLine;
inline?: boolean;
value?: string;
onchange?: (value: string) => void;
}
let {
line,
inline = false,
value = $bindable(""),
onchange,
}: Props = $props();
const labelSegments = $derived(
line.content ? parseColorText(line.content, $themeColors.colorMap) : [],
);
const options = $derived(line.radioOptions || []);
const isDisabled = $derived(line.inputDisabled || false);
const isHorizontal = $derived(line.radioHorizontal || false);
function handleSelect(optionValue: string) {
if (isDisabled) return;
value = optionValue;
onchange?.(value);
}
function handleKeydown(e: KeyboardEvent, optionValue: string) {
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
handleSelect(optionValue);
}
}
function getRadioSymbol(selected: boolean): string {
return selected ? "(●)" : "( )";
}
</script>
<div
class="tui-radio-group"
class:inline
class:disabled={isDisabled}
style="--radio-color: {getButtonStyle(line.style)}"
role="radiogroup"
>
{#if line.content}
<div class="radio-label">
{#if line.icon}
<Icon icon={line.icon} width="14" class="label-icon" />
{/if}
{#each labelSegments as segment}
{#if segment.icon}
<Icon icon={segment.icon} width={segment.iconSize ?? 14} class="inline-icon" />
{:else if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}>{segment.text}</span>
{:else}
{segment.text}
{/if}
{/each}
</div>
{/if}
<div class="radio-options" class:horizontal={isHorizontal}>
{#each options as option}
{@const isSelected = value === option.value}
{@const optionSegments = parseColorText(
option.label,
$themeColors.colorMap,
)}
<div
class="radio-option"
class:selected={isSelected}
class:option-disabled={option.disabled}
role="radio"
aria-checked={isSelected}
aria-disabled={isDisabled || option.disabled}
tabindex={isDisabled || option.disabled ? -1 : 0}
onclick={() => !option.disabled && handleSelect(option.value)}
onkeydown={(e) =>
!option.disabled && handleKeydown(e, option.value)}
>
<span class="radio-symbol">
{getRadioSymbol(isSelected)}
</span>
{#if option.icon}
<Icon icon={option.icon} width="14" class="option-icon" />
{/if}
<span class="option-label">
{#each optionSegments as segment}
{#if segment.icon}
<Icon
icon={segment.icon}
width={segment.iconSize ?? 14}
class="inline-icon"
/>
{:else if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}
>{segment.text}</span
>
{:else}
{segment.text}
{/if}
{/each}
</span>
</div>
{/each}
</div>
</div>
<style>
.tui-radio-group {
margin: 0.5rem 0;
font-size: 0.9rem;
}
.tui-radio-group.inline {
display: inline-flex;
flex-direction: column;
margin: 0 0.75rem 0 0;
}
.tui-radio-group.disabled {
opacity: 0.5;
}
.radio-label {
display: flex;
align-items: center;
gap: 0.35rem;
margin-bottom: 0.5rem;
color: var(--terminal-muted);
font-size: 0.85rem;
}
:global(.label-icon) {
color: var(--radio-color);
}
.radio-options {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.radio-options.horizontal {
flex-direction: row;
flex-wrap: wrap;
gap: 0.75rem;
}
.radio-option {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.5rem;
cursor: pointer;
border-radius: 4px;
transition: all 0.15s ease;
user-select: none;
}
.radio-option:hover:not(.option-disabled) {
background: color-mix(in srgb, var(--radio-color) 10%, transparent);
}
.radio-option:focus-visible {
outline: 1px solid var(--radio-color);
outline-offset: 2px;
}
.radio-option.option-disabled {
opacity: 0.5;
cursor: not-allowed;
}
.radio-symbol {
font-family: inherit;
font-weight: bold;
color: var(--terminal-muted);
transition: color 0.15s ease;
}
.radio-option.selected .radio-symbol {
color: var(--radio-color);
}
:global(.option-icon) {
color: var(--radio-color);
}
.option-label {
color: var(--terminal-text);
}
.radio-option.option-disabled .option-label {
color: var(--terminal-muted);
}
</style>
-434
View File
@@ -1,434 +0,0 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { getButtonStyle, parseColorText, getSegmentStyle } from "./utils";
import { themeColors } from "$lib/stores/theme";
import type { SelectLine } from "./types";
import "$lib/assets/css/tui-select.css";
interface Props {
line: SelectLine;
inline?: boolean;
value?: string;
onchange?: (value: string) => void;
onfocus?: () => void;
onblur?: () => void;
}
let {
line,
inline = false,
value = $bindable(""),
onchange,
onfocus,
onblur,
}: Props = $props();
const labelSegments = $derived(
line.content ? parseColorText(line.content, $themeColors.colorMap) : [],
);
const options = $derived(line.selectOptions || []);
const placeholder = $derived(
line.inputPlaceholder || "Select an option...",
);
const isDisabled = $derived(line.inputDisabled || false);
const hasError = $derived(line.inputError);
const errorMessage = $derived(line.inputErrorMessage || "");
const searchable = $derived(line.selectSearchable || false);
let isOpen = $state(false);
let searchQuery = $state("");
let highlightedIndex = $state(0);
let selectRef: HTMLDivElement | undefined = $state();
const filteredOptions = $derived(
searchable && searchQuery
? options.filter(
(opt) =>
opt.label
.toLowerCase()
.includes(searchQuery.toLowerCase()) ||
opt.value
.toLowerCase()
.includes(searchQuery.toLowerCase()),
)
: options,
);
const selectedOption = $derived(options.find((opt) => opt.value === value));
const displayValue = $derived(selectedOption?.label || "");
function handleToggle() {
if (isDisabled) return;
isOpen = !isOpen;
if (isOpen) {
highlightedIndex = 0;
searchQuery = "";
onfocus?.();
} else {
onblur?.();
}
}
function handleSelect(optionValue: string) {
if (isDisabled) return;
value = optionValue;
isOpen = false;
searchQuery = "";
onchange?.(value);
onblur?.();
}
function handleKeydown(e: KeyboardEvent) {
if (isDisabled) return;
switch (e.key) {
case "Enter":
case " ":
e.preventDefault();
if (isOpen && filteredOptions[highlightedIndex]) {
handleSelect(filteredOptions[highlightedIndex].value);
} else {
handleToggle();
}
break;
case "ArrowDown":
e.preventDefault();
if (!isOpen) {
isOpen = true;
} else {
highlightedIndex = Math.min(
highlightedIndex + 1,
filteredOptions.length - 1,
);
}
break;
case "ArrowUp":
e.preventDefault();
if (isOpen) {
highlightedIndex = Math.max(highlightedIndex - 1, 0);
}
break;
case "Escape":
isOpen = false;
searchQuery = "";
break;
case "Home":
if (isOpen) {
e.preventDefault();
highlightedIndex = 0;
}
break;
case "End":
if (isOpen) {
e.preventDefault();
highlightedIndex = filteredOptions.length - 1;
}
break;
}
}
function handleClickOutside(e: MouseEvent) {
if (selectRef && !selectRef.contains(e.target as Node)) {
isOpen = false;
searchQuery = "";
}
}
function handleSearchInput(e: Event) {
searchQuery = (e.target as HTMLInputElement).value;
highlightedIndex = 0;
}
</script>
<svelte:window onclick={handleClickOutside} />
<div
class="tui-select"
class:inline
class:open={isOpen}
class:error={hasError}
class:disabled={isDisabled}
style="--select-color: {getButtonStyle(line.style)}"
bind:this={selectRef}
>
{#if line.content}
<label class="select-label">
{#if line.icon}
<Icon icon={line.icon} width="14" class="label-icon" />
{/if}
{#each labelSegments as segment}
{#if segment.icon}
<Icon icon={segment.icon} width={segment.iconSize ?? 14} class="inline-icon" />
{:else if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}>{segment.text}</span>
{:else}
{segment.text}
{/if}
{/each}
</label>
{/if}
<div
class="select-trigger"
role="combobox"
aria-expanded={isOpen}
aria-controls="select-listbox"
aria-haspopup="listbox"
aria-disabled={isDisabled}
tabindex={isDisabled ? -1 : 0}
onclick={handleToggle}
onkeydown={handleKeydown}
>
<span class="select-prompt">❯</span>
<span class="select-value" class:placeholder={!selectedOption}>
{displayValue || placeholder}
</span>
<span class="select-arrow">{isOpen ? "▲" : "▼"}</span>
</div>
{#if isOpen}
<div class="select-dropdown" role="listbox" id="select-listbox">
{#if searchable}
<div class="select-search">
<Icon icon="mdi:magnify" width="14" />
<input
type="text"
placeholder="Search..."
value={searchQuery}
oninput={handleSearchInput}
onclick={(e) => e.stopPropagation()}
/>
</div>
{/if}
<div class="select-options">
{#each filteredOptions as option, i}
{@const optionSegments = parseColorText(option.label, $themeColors.colorMap)}
<div
class="select-option"
class:selected={value === option.value}
class:highlighted={i === highlightedIndex}
class:option-disabled={option.disabled}
role="option"
aria-selected={value === option.value}
aria-disabled={option.disabled}
tabindex={option.disabled ? -1 : 0}
onclick={() =>
!option.disabled && handleSelect(option.value)}
onkeydown={(e) =>
e.key === "Enter" &&
!option.disabled &&
handleSelect(option.value)}
onmouseenter={() => (highlightedIndex = i)}
>
{#if option.icon}
<Icon
icon={option.icon}
width="14"
class="option-icon"
/>
{/if}
<span class="option-label">
{#each optionSegments as segment}
{#if segment.icon}
<Icon
icon={segment.icon}
width={segment.iconSize ?? 14}
class="inline-icon"
/>
{:else if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}
>{segment.text}</span
>
{:else}
{segment.text}
{/if}
{/each}
</span>
{#if value === option.value}
<Icon
icon="mdi:check"
width="14"
class="check-icon"
/>
{/if}
</div>
{:else}
<div class="select-empty">No options found</div>
{/each}
</div>
</div>
{/if}
{#if hasError && errorMessage}
<div class="select-error">
<Icon icon="mdi:alert-circle" width="12" />
<span>{errorMessage}</span>
</div>
{/if}
</div>
<style>
.tui-select {
position: relative;
margin: 0.5rem 0;
font-size: 0.9rem;
}
.tui-select.inline {
display: inline-flex;
flex-direction: column;
margin: 0 0.5rem 0 0;
}
.select-label {
display: flex;
align-items: center;
gap: 0.35rem;
margin-bottom: 0.35rem;
color: var(--terminal-muted);
font-size: 0.85rem;
}
:global(.label-icon) {
color: var(--select-color);
}
.select-trigger {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.5rem 0.75rem;
background: color-mix(in srgb, var(--terminal-bg) 80%, black);
border: 1px solid var(--terminal-muted);
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
}
.tui-select.open .select-trigger,
.select-trigger:focus-visible {
border-color: var(--select-color);
box-shadow: 0 0 0 1px
color-mix(in srgb, var(--select-color) 30%, transparent);
}
.tui-select.error .select-trigger {
border-color: #f38ba8;
}
.tui-select.disabled .select-trigger {
opacity: 0.5;
cursor: not-allowed;
}
.select-prompt {
color: var(--select-color);
font-weight: bold;
}
.select-value {
flex: 1;
color: var(--terminal-text);
}
.select-value.placeholder {
color: var(--terminal-muted);
opacity: 0.6;
}
.select-arrow {
color: var(--terminal-muted);
font-size: 0.7rem;
}
.select-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 0.25rem;
background: var(--terminal-bg);
border: 1px solid var(--select-color);
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 100;
overflow: hidden;
}
.select-search {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--terminal-muted);
}
.select-search input {
flex: 1;
background: transparent;
border: none;
color: var(--terminal-text);
font-family: inherit;
font-size: inherit;
outline: none;
}
.select-search input::placeholder {
color: var(--terminal-muted);
}
.select-options {
max-height: 200px;
overflow-y: auto;
}
.select-option {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
cursor: pointer;
transition: background 0.1s ease;
}
.select-option.highlighted {
background: color-mix(in srgb, var(--select-color) 15%, transparent);
}
.select-option.selected {
color: var(--select-color);
}
.select-option.option-disabled {
opacity: 0.5;
cursor: not-allowed;
}
:global(.option-icon) {
color: var(--select-color);
}
.option-label {
flex: 1;
}
:global(.check-icon) {
color: var(--select-color);
}
.select-empty {
padding: 0.75rem;
text-align: center;
color: var(--terminal-muted);
font-style: italic;
}
.select-error {
display: flex;
align-items: center;
gap: 0.35rem;
margin-top: 0.35rem;
color: #f38ba8;
font-size: 0.8rem;
}
</style>
-112
View File
@@ -1,112 +0,0 @@
<script lang="ts">
import { getButtonStyle, parseColorText, getSegmentStyle } from "./utils";
import { themeColors } from "$lib/stores/theme";
import type { TableLine } from "./types";
import "$lib/assets/css/tui-table.css";
interface Props {
line: TableLine;
inline?: boolean;
}
let { line, inline = false }: Props = $props();
const headers = $derived(line.tableHeaders || []);
const rows = $derived(line.tableRows || []);
</script>
<div
class="tui-table-wrapper"
class:inline
style="--table-accent: {getButtonStyle(line.style)}"
>
{#if line.content}
<div class="table-title">{line.content}</div>
{/if}
<table class="tui-table">
{#if headers.length > 0}
<thead>
<tr>
{#each headers as header}
<th>{header}</th>
{/each}
</tr>
</thead>
{/if}
<tbody>
{#each rows as row, i}
<tr class:alt={i % 2 === 1}>
{#each row as cell}
{@const cellSegments = parseColorText(
cell,
$themeColors.colorMap,
)}
<td>
{#each cellSegments as segment}
{#if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}
>{segment.text}</span
>
{:else}
{segment.text}
{/if}
{/each}
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
<style>
.tui-table-wrapper {
margin: 0.5rem 0;
border: 1px solid var(--terminal-border);
border-radius: 6px;
overflow: hidden;
}
.table-title {
padding: 0.5rem 0.75rem;
font-weight: 600;
font-size: 0.85rem;
color: var(--terminal-text);
background: rgba(0, 0, 0, 0.1);
border-bottom: 1px solid var(--terminal-border);
}
.tui-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
th {
padding: 0.5rem 0.75rem;
text-align: left;
font-weight: 600;
color: var(--table-accent);
background: rgba(0, 0, 0, 0.15);
border-bottom: 1px solid var(--terminal-border);
white-space: nowrap;
}
td {
padding: 0.4rem 0.75rem;
color: var(--terminal-text);
border-bottom: 1px solid var(--terminal-border);
}
tr:last-child td {
border-bottom: none;
}
tr.alt {
background: rgba(0, 0, 0, 0.03);
}
tr:hover {
background: color-mix(in srgb, var(--table-accent) 5%, transparent);
}
</style>
-238
View File
@@ -1,238 +0,0 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { getButtonStyle, parseColorText, getSegmentStyle } from "./utils";
import { themeColors } from "$lib/stores/theme";
import type { TextareaLine } from "./types";
import "$lib/assets/css/tui-textarea.css";
interface Props {
line: TextareaLine;
inline?: boolean;
value?: string;
oninput?: (value: string) => void;
onchange?: (value: string) => void;
onfocus?: () => void;
onblur?: () => void;
}
let {
line,
inline = false,
value = $bindable(""),
oninput,
onchange,
onfocus,
onblur,
}: Props = $props();
const labelSegments = $derived(
line.content ? parseColorText(line.content, $themeColors.colorMap) : [],
);
const placeholder = $derived(line.inputPlaceholder || "");
const isDisabled = $derived(line.inputDisabled || false);
const hasError = $derived(line.inputError);
const errorMessage = $derived(line.inputErrorMessage || "");
const rows = $derived(line.textareaRows || 4);
const maxLength = $derived(line.textareaMaxLength);
let isFocused = $state(false);
let charCount = $state(value.length);
function handleInput(e: Event) {
const target = e.target as HTMLTextAreaElement;
value = target.value;
charCount = value.length;
oninput?.(value);
}
function handleChange(e: Event) {
const target = e.target as HTMLTextAreaElement;
onchange?.(target.value);
}
function handleFocus() {
isFocused = true;
onfocus?.();
}
function handleBlur() {
isFocused = false;
onblur?.();
}
</script>
<div
class="tui-textarea"
class:inline
class:focused={isFocused}
class:error={hasError}
class:disabled={isDisabled}
style="--input-color: {getButtonStyle(line.style)}"
>
{#if line.content}
<label class="textarea-label">
{#if line.icon}
<Icon icon={line.icon} width="14" class="label-icon" />
{/if}
{#each labelSegments as segment}
{#if segment.icon}
<Icon icon={segment.icon} width={segment.iconSize ?? 14} class="inline-icon" />
{:else if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}>{segment.text}</span>
{:else}
{segment.text}
{/if}
{/each}
</label>
{/if}
<div class="textarea-wrapper">
<div class="line-numbers">
{#each Array(Math.max(rows, value.split("\n").length)) as _, i}
<span class="line-num">{i + 1}</span>
{/each}
</div>
<textarea
{placeholder}
{rows}
maxlength={maxLength}
disabled={isDisabled}
bind:value
oninput={handleInput}
onchange={handleChange}
onfocus={handleFocus}
onblur={handleBlur}
></textarea>
</div>
<div class="textarea-footer">
{#if hasError && errorMessage}
<div class="textarea-error">
<Icon icon="mdi:alert-circle" width="12" />
<span>{errorMessage}</span>
</div>
{/if}
{#if maxLength}
<div class="char-count" class:warning={charCount > maxLength * 0.9}>
{charCount}/{maxLength}
</div>
{/if}
</div>
</div>
<style>
.tui-textarea {
margin: 0.5rem 0;
font-size: 0.9rem;
}
.tui-textarea.inline {
display: inline-flex;
flex-direction: column;
margin: 0 0.5rem 0 0;
}
.textarea-label {
display: flex;
align-items: center;
gap: 0.35rem;
margin-bottom: 0.35rem;
color: var(--terminal-muted);
font-size: 0.85rem;
}
:global(.label-icon) {
color: var(--input-color);
}
.textarea-wrapper {
display: flex;
background: color-mix(in srgb, var(--terminal-bg) 80%, black);
border: 1px solid var(--terminal-muted);
border-radius: 4px;
transition: all 0.15s ease;
overflow: hidden;
}
.tui-textarea.focused .textarea-wrapper {
border-color: var(--input-color);
box-shadow: 0 0 0 1px
color-mix(in srgb, var(--input-color) 30%, transparent);
}
.tui-textarea.error .textarea-wrapper {
border-color: #f38ba8;
}
.tui-textarea.disabled .textarea-wrapper {
opacity: 0.5;
cursor: not-allowed;
}
.line-numbers {
display: flex;
flex-direction: column;
padding: 0.5rem 0;
background: color-mix(in srgb, var(--terminal-bg) 60%, black);
border-right: 1px solid var(--terminal-muted);
user-select: none;
}
.line-num {
padding: 0 0.5rem;
color: var(--terminal-muted);
font-size: 0.8rem;
line-height: 1.5;
text-align: right;
min-width: 2rem;
}
textarea {
flex: 1;
min-width: 0;
padding: 0.5rem 0.75rem;
background: transparent;
border: none;
color: var(--terminal-text);
font-family: inherit;
font-size: inherit;
line-height: 1.5;
resize: vertical;
outline: none;
}
textarea::placeholder {
color: var(--terminal-muted);
opacity: 0.6;
}
textarea:disabled {
cursor: not-allowed;
resize: none;
}
.textarea-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.35rem;
}
.textarea-error {
display: flex;
align-items: center;
gap: 0.35rem;
color: #f38ba8;
font-size: 0.8rem;
}
.char-count {
margin-left: auto;
color: var(--terminal-muted);
font-size: 0.75rem;
}
.char-count.warning {
color: #f9e2af;
}
</style>
-192
View File
@@ -1,192 +0,0 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { getButtonStyle, parseColorText, getSegmentStyle } from "./utils";
import { themeColors } from "$lib/stores/theme";
import type { ToggleLine } from "./types";
import "$lib/assets/css/tui-toggle.css";
interface Props {
line: ToggleLine;
inline?: boolean;
checked?: boolean;
onchange?: (checked: boolean) => void;
}
let {
line,
inline = false,
checked = $bindable(false),
onchange,
}: Props = $props();
const labelSegments = $derived(
line.content ? parseColorText(line.content, $themeColors.colorMap) : [],
);
const isDisabled = $derived(line.inputDisabled || false);
const onLabel = $derived(line.toggleOnLabel || "ON");
const offLabel = $derived(line.toggleOffLabel || "OFF");
const showLabels = $derived(line.toggleShowLabels !== false);
function handleToggle() {
if (isDisabled) return;
checked = !checked;
onchange?.(checked);
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
handleToggle();
}
}
</script>
<div
class="tui-toggle"
class:inline
class:checked
class:disabled={isDisabled}
style="--toggle-color: {getButtonStyle(line.style)}"
role="switch"
aria-checked={checked}
aria-disabled={isDisabled}
tabindex={isDisabled ? -1 : 0}
onclick={handleToggle}
onkeydown={handleKeydown}
>
{#if line.icon}
<Icon icon={line.icon} width="14" class="toggle-icon" />
{/if}
{#if line.content}
<span class="toggle-label">
{#each labelSegments as segment}
{#if segment.icon}
<Icon icon={segment.icon} width={segment.iconSize ?? 14} class="inline-icon" />
{:else if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}>{segment.text}</span>
{:else}
{segment.text}
{/if}
{/each}
</span>
{/if}
<div class="toggle-track">
{#if showLabels}
<span class="toggle-off-label">{offLabel}</span>
{/if}
<span class="toggle-switch">
<span class="toggle-knob">{checked ? "●" : "○"}</span>
</span>
{#if showLabels}
<span class="toggle-on-label">{onLabel}</span>
{/if}
</div>
</div>
<style>
.tui-toggle {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0.35rem 0;
padding: 0.35rem 0.5rem;
font-size: 0.9rem;
cursor: pointer;
border-radius: 4px;
transition: all 0.15s ease;
user-select: none;
}
.tui-toggle.inline {
display: inline-flex;
margin: 0 0.75rem 0 0;
}
.tui-toggle:hover:not(.disabled) {
background: color-mix(in srgb, var(--toggle-color) 10%, transparent);
}
.tui-toggle:focus-visible {
outline: 1px solid var(--toggle-color);
outline-offset: 2px;
}
.tui-toggle.disabled {
opacity: 0.5;
cursor: not-allowed;
}
:global(.toggle-icon) {
color: var(--toggle-color);
}
.toggle-label {
color: var(--terminal-text);
}
.toggle-track {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: color-mix(in srgb, var(--terminal-bg) 80%, black);
border: 1px solid var(--terminal-muted);
border-radius: 4px;
font-size: 0.75rem;
transition: all 0.15s ease;
}
.tui-toggle.checked .toggle-track {
border-color: var(--toggle-color);
background: color-mix(in srgb, var(--toggle-color) 15%, transparent);
}
.toggle-off-label,
.toggle-on-label {
color: var(--terminal-muted);
font-weight: bold;
text-transform: uppercase;
min-width: 2rem;
text-align: center;
transition: color 0.15s ease;
}
.tui-toggle:not(.checked) .toggle-off-label {
color: var(--terminal-text);
}
.tui-toggle.checked .toggle-on-label {
color: var(--toggle-color);
}
.toggle-switch {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
position: relative;
}
.toggle-knob {
font-size: 1rem;
color: var(--terminal-muted);
transition: all 0.2s ease;
transform: translateX(-0.5rem);
}
.tui-toggle.checked .toggle-knob {
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;
}
}
</style>
-176
View File
@@ -1,176 +0,0 @@
<script lang="ts">
import { getButtonStyle, parseColorText, getSegmentStyle } from "./utils";
import { themeColors } from "$lib/stores/theme";
import type { TooltipLine } from "./types";
import "$lib/assets/css/tui-tooltip.css";
interface Props {
line: TooltipLine;
}
let { line }: Props = $props();
let showTooltip = $state(false);
let triggerEl: HTMLSpanElement | undefined = $state();
let tooltipStyle = $state("");
const contentSegments = $derived(parseColorText(line.content, $themeColors.colorMap));
const position = $derived(line.tooltipPosition || "top");
function updateTooltipPosition() {
if (!triggerEl) return;
const rect = triggerEl.getBoundingClientRect();
const scrollY = window.scrollY;
const scrollX = window.scrollX;
let top = 0;
let left = 0;
switch (position) {
case "top":
top = rect.top + scrollY - 8;
left = rect.left + scrollX + rect.width / 2;
tooltipStyle = `top: ${top}px; left: ${left}px; transform: translateX(-50%) translateY(-100%);`;
break;
case "bottom":
top = rect.bottom + scrollY + 8;
left = rect.left + scrollX + rect.width / 2;
tooltipStyle = `top: ${top}px; left: ${left}px; transform: translateX(-50%);`;
break;
case "left":
top = rect.top + scrollY + rect.height / 2;
left = rect.left + scrollX - 8;
tooltipStyle = `top: ${top}px; left: ${left}px; transform: translateX(-100%) translateY(-50%);`;
break;
case "right":
top = rect.top + scrollY + rect.height / 2;
left = rect.right + scrollX + 8;
tooltipStyle = `top: ${top}px; left: ${left}px; transform: translateY(-50%);`;
break;
}
}
function handleMouseEnter() {
updateTooltipPosition();
showTooltip = true;
}
function handleMouseLeave() {
showTooltip = false;
}
</script>
<span
class="tui-tooltip-trigger"
style="--tooltip-color: {getButtonStyle(line.style)}"
bind:this={triggerEl}
onmouseenter={handleMouseEnter}
onmouseleave={handleMouseLeave}
role="button"
tabindex="0"
onfocus={handleMouseEnter}
onblur={handleMouseLeave}
>
<span class="trigger-text">
{#each contentSegments as segment}
{#if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}>{segment.text}</span>
{:else}
{segment.text}
{/if}
{/each}
</span>
<span class="tooltip-indicator">(?)</span>
</span>
{#if showTooltip && line.tooltipText}
<span
class="tooltip {position}"
style="{tooltipStyle} --tooltip-color: {getButtonStyle(line.style)}"
>
{line.tooltipText}
<span class="tooltip-arrow"></span>
</span>
{/if}
<style>
.tui-tooltip-trigger {
position: relative;
display: inline-flex;
align-items: center;
gap: 0.25rem;
cursor: help;
}
.trigger-text {
border-bottom: 1px dotted var(--tooltip-color);
}
.tooltip-indicator {
font-size: 0.7rem;
color: var(--tooltip-color);
opacity: 0.7;
}
.tooltip {
position: fixed;
z-index: 99999;
padding: 0.5rem 0.75rem;
background: var(--terminal-bg);
border: 1px solid var(--tooltip-color);
border-radius: 4px;
font-size: 0.8rem;
color: var(--terminal-text);
white-space: nowrap;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
animation: tooltipFadeIn 0.15s ease-out;
pointer-events: none;
}
@keyframes tooltipFadeIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* Arrow styles - simplified for fixed positioning */
.tooltip-arrow {
position: absolute;
width: 8px;
height: 8px;
background: var(--terminal-bg);
border: 1px solid var(--tooltip-color);
border-top: none;
border-left: none;
}
.tooltip.top .tooltip-arrow {
bottom: -5px;
left: 50%;
transform: translateX(-50%) rotate(45deg);
}
.tooltip.bottom .tooltip-arrow {
top: -5px;
left: 50%;
transform: translateX(-50%) rotate(-135deg);
}
.tooltip.left .tooltip-arrow {
right: -5px;
top: 50%;
transform: translateY(-50%) rotate(-45deg);
}
.tooltip.right .tooltip-arrow {
left: -5px;
top: 50%;
transform: translateY(-50%) rotate(135deg);
}
</style>
-251
View File
@@ -1,251 +0,0 @@
/**
* Terminal API Factory
*
* Creates a reactive API for manipulating terminal content programmatically.
* Use this with TerminalTUI's bindable `terminal` prop.
*/
import type { TerminalLine, ParsedLine, DisplayedLine, TerminalAPI } from './types';
import { parseColorText, getPlainText } from './utils';
import type { ThemeColorMap } from './utils';
export interface TerminalState {
lines: TerminalLine[];
displayedLines: DisplayedLine[];
isTyping: boolean;
isComplete: boolean;
currentLineIndex: number;
selectedIndex: number;
skipRequested: boolean;
}
export interface TerminalAPIOptions {
getState: () => TerminalState;
setState: (updates: Partial<TerminalState>) => void;
getColorMap: () => ThemeColorMap;
getBodyElement: () => HTMLDivElement | undefined;
typeText: () => void;
}
/**
* Parse a single terminal line into segments
*/
export function parseLine(line: TerminalLine, colorMap: ThemeColorMap): ParsedLine {
const segments = parseColorText(line.content, colorMap);
return {
line,
segments,
plainText: getPlainText(segments)
};
}
/**
* Create a terminal API instance
*/
export function createTerminalAPI(options: TerminalAPIOptions): TerminalAPI {
const { getState, setState, getColorMap, getBodyElement, typeText } = options;
function refreshDisplayedLines() {
const state = getState();
const colorMap = getColorMap();
// Only update lines that are already displayed to avoid forcing untyped lines to appear
const displayedLines = state.displayedLines.map((displayed, i) => {
const line = state.lines[i];
if (!line) return displayed; // Should not happen if state is consistent
const parsed = parseLine(line, colorMap);
return {
parsed,
charIndex: displayed.complete ? parsed.plainText.length : displayed.charIndex,
complete: displayed.complete,
showImage: displayed.showImage ?? (line.type === 'image')
};
});
setState({ displayedLines, lines: [...state.lines] });
}
function apiScrollToBottom() {
const bodyElement = getBodyElement();
if (!bodyElement) return;
bodyElement.scrollTo({
top: bodyElement.scrollHeight,
behavior: 'smooth'
});
}
function apiScrollToLine(index: number) {
const bodyElement = getBodyElement();
if (!bodyElement) return;
const lineElements = bodyElement.querySelectorAll('.tui-line');
const target = lineElements[index] as HTMLElement | undefined;
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
return {
clear: () => {
setState({
lines: [],
displayedLines: [],
isComplete: true,
isTyping: false
});
},
write: (line: TerminalLine) => {
const state = getState();
const colorMap = getColorMap();
const parsed = parseLine(line, colorMap);
setState({
lines: [...state.lines, line],
displayedLines: [...state.displayedLines, {
parsed,
charIndex: parsed.plainText.length,
complete: true,
showImage: line.type === 'image'
}]
});
setTimeout(apiScrollToBottom, 10);
},
writeLines: (newLines: TerminalLine[]) => {
const state = getState();
const colorMap = getColorMap();
const newDisplayed = newLines.map(line => {
const parsed = parseLine(line, colorMap);
return {
parsed,
charIndex: parsed.plainText.length,
complete: true,
showImage: line.type === 'image'
};
});
setState({
lines: [...state.lines, ...newLines],
displayedLines: [...state.displayedLines, ...newDisplayed]
});
setTimeout(apiScrollToBottom, 10);
},
update: (index: number, updates: Partial<TerminalLine>) => {
const state = getState();
if (index < 0 || index >= state.lines.length) return;
// Cast to TerminalLine to allow partial updates across union types
state.lines[index] = { ...state.lines[index], ...updates } as TerminalLine;
refreshDisplayedLines();
},
updateContent: (index: number, content: string) => {
const state = getState();
if (index < 0 || index >= state.lines.length) return;
state.lines[index] = { ...state.lines[index], content };
refreshDisplayedLines();
},
insert: (index: number, line: TerminalLine) => {
const state = getState();
const clampedIndex = Math.max(0, Math.min(index, state.lines.length));
setState({
lines: [...state.lines.slice(0, clampedIndex), line, ...state.lines.slice(clampedIndex)]
});
refreshDisplayedLines();
},
remove: (index: number) => {
const state = getState();
if (index < 0 || index >= state.lines.length) return;
setState({
lines: [...state.lines.slice(0, index), ...state.lines.slice(index + 1)]
});
refreshDisplayedLines();
},
removeRange: (startIndex: number, count: number) => {
const state = getState();
if (startIndex < 0 || startIndex >= state.lines.length) return;
setState({
lines: [...state.lines.slice(0, startIndex), ...state.lines.slice(startIndex + count)]
});
refreshDisplayedLines();
},
setLines: (newLines: TerminalLine[]) => {
setState({ lines: [...newLines] });
refreshDisplayedLines();
},
getLineCount: () => getState().lines.length,
getLine: (index: number) => getState().lines[index],
getLines: () => [...getState().lines],
findById: (id: string) => getState().lines.findIndex(line => line.id === id),
updateById: (id: string, updates: Partial<TerminalLine>) => {
const state = getState();
const index = state.lines.findIndex(line => line.id === id);
if (index !== -1) {
// Cast to TerminalLine to allow partial updates across union types
state.lines[index] = { ...state.lines[index], ...updates } as TerminalLine;
refreshDisplayedLines();
}
},
removeById: (id: string) => {
const state = getState();
const index = state.lines.findIndex(line => line.id === id);
if (index !== -1) {
setState({
lines: [...state.lines.slice(0, index), ...state.lines.slice(index + 1)]
});
refreshDisplayedLines();
}
},
scrollToBottom: apiScrollToBottom,
scrollToLine: apiScrollToLine,
isAnimating: () => getState().isTyping,
skip: () => {
const state = getState();
if (!state.isTyping) return;
const colorMap = getColorMap();
const displayedLines = state.lines.map(line => {
const parsed = parseLine(line, colorMap);
return {
parsed,
charIndex: parsed.plainText.length,
complete: true,
showImage: line.type === 'image'
};
});
setState({
skipRequested: true,
displayedLines,
isTyping: false,
isComplete: true,
currentLineIndex: state.lines.length - 1
});
apiScrollToBottom();
},
restart: () => {
setState({
displayedLines: [],
currentLineIndex: 0,
isTyping: false,
isComplete: false,
skipRequested: false,
selectedIndex: -1
});
typeText();
}
};
}
-405
View File
@@ -1,405 +0,0 @@
import type { TerminalAPI, TerminalLine } from './types';
import { user } from '$lib/config';
export interface CommandContext {
terminal: TerminalAPI;
navigate: (path: string, external?: boolean) => void;
toggleMode: () => void;
setColorTheme: (name: string) => void;
getMode: () => string;
getColorTheme: () => string;
themeNames: string[];
user: typeof user;
skills: Record<string, string[]>;
navigation: Array<{ name: string; path: string; external?: boolean }>;
}
export interface Command {
name: string;
description: string;
usage?: string;
aliases?: string[];
hidden?: boolean;
run: (args: string[], ctx: CommandContext) => void;
}
const out = (content: string): TerminalLine => ({ type: 'output', content });
const ok = (content: string): TerminalLine => ({ type: 'success', content });
const err = (content: string): TerminalLine => ({ type: 'error', content });
const info = (content: string): TerminalLine => ({ type: 'info', content });
const blank = (): TerminalLine => ({ type: 'blank', content: '' });
interface PageNode {
name: string;
path: string;
children?: PageNode[];
}
const pageTree: PageNode[] = [
{ name: 'home', path: '/' },
{ name: 'about', path: '/about' },
{ name: 'portfolio', path: '/portfolio' },
{
name: 'projects',
path: '/projects',
children: [
{ name: 'opensource', path: '/projects/opensource' },
{ name: 'packages', path: '/projects/packages' },
{ name: 'hackathons', path: '/projects/hackathons' },
{ name: 'research', path: '/projects/research' }
]
},
{ name: 'models', path: '/models' },
{ name: 'components', path: '/components' },
{ name: 'links', path: '/links' },
{ name: 'blog', path: '/blog' }
];
const pageRoutes: Record<string, string> = pageTree.reduce((routes, node) => {
routes[node.name] = node.path;
node.children?.forEach((child) => {
routes[child.name] = child.path;
});
return routes;
}, {} as Record<string, string>);
function resolvePagePath(target: string): string | undefined {
if (pageRoutes[target] !== undefined) return pageRoutes[target];
const segments = target.split('/').filter(Boolean);
if (segments.length < 2) return undefined;
let nodes: PageNode[] = pageTree;
let path: string | undefined;
for (const segment of segments) {
const node = nodes.find((candidate) => candidate.name === segment);
if (!node) return undefined;
path = node.path;
nodes = node.children ?? [];
}
return path;
}
function cowsay(message: string): string[] {
const text = message || 'Moo?';
const border = '-'.repeat(text.length + 2);
return [
` ${border}`,
`< ${text} >`,
` ${border}`,
' \\ ^__^',
' \\ (oo)\\_______',
' (__)\\ )\\/\\',
' ||----w |',
' || ||'
];
}
const baseCommands: Command[] = [
{
name: 'help',
aliases: ['h', '?'],
description: 'List all available commands',
run: (_args, ctx) => {
const rows = commands
.filter((command) => !command.hidden)
.map((command) => [
`(&primary,bold)${command.name}(&)` +
(command.aliases?.length ? ` (&muted)(${command.aliases.join(', ')})(&)` : ''),
command.description
]);
ctx.terminal.write({
type: 'table',
content: 'Available Commands',
tableHeaders: ['Command', 'Description'],
tableRows: rows,
style: 'accent'
} as TerminalLine);
ctx.terminal.write(info('Tip: try (&accent)neofetch(&), (&accent)cowsay(&), or (&accent)cd projects(&)'));
ctx.terminal.write(blank());
}
},
{
name: 'clear',
aliases: ['cls'],
description: 'Clear the terminal',
run: (_args, ctx) => ctx.terminal.clear()
},
{
name: 'whoami',
description: 'Show a short bio',
run: (_args, ctx) => {
ctx.terminal.writeLines([
out(`(&primary,bold)${ctx.user.displayname}(&) (&muted)— ${ctx.user.title}(&)`),
out(`(&muted)${ctx.user.bio}(&)`),
blank()
]);
}
},
{
name: 'neofetch',
aliases: ['banner'],
description: 'Display system info',
run: (_args, ctx) => {
const launched = new Date('2020-01-01T00:00:00Z');
const days = Math.floor((Date.now() - launched.getTime()) / 86400000);
const infoLines: TerminalLine[] = [
out(`(&accent,bold)${ctx.user.username}(&)(&muted)@(&)(&primary,bold)${ctx.user.hostname}(&)`),
out('(&muted)------------------------------(&)'),
out(`(&blue,bold)Name(&): ${ctx.user.displayname}`),
out(`(&blue,bold)Title(&): ${ctx.user.title}`),
out(`(&blue,bold)Location(&): ${ctx.user.location}`),
out(`(&blue,bold)Shell(&): tui-terminal`),
out(`(&blue,bold)Theme(&): ${ctx.getColorTheme()} (${ctx.getMode()})`),
out(`(&blue,bold)Uptime(&): ${days} days since launch`),
out(`(&blue,bold)Languages(&): ${ctx.skills.languages.slice(0, 6).join(', ')}`),
out('(&red)███(&)(&yellow)███(&)(&green)███(&)(&cyan)███(&)(&blue)███(&)(&magenta)███(&)')
];
ctx.terminal.writeLines([
{
type: 'image',
content: '',
image: ctx.user.avatar,
imageAlt: ctx.user.displayname,
imageWidth: 325,
inline: true
},
{
type: 'group',
content: '',
inline: true,
groupDirection: 'column',
groupGap: '0',
children: infoLines
},
blank()
]);
}
},
{
name: 'skills',
description: 'List skills by category',
run: (_args, ctx) => {
const order = ['languages', 'frameworks', 'tools', 'platforms', 'applications', 'databases', 'interests'];
const lines: TerminalLine[] = [];
for (const key of order) {
const values = ctx.skills[key];
if (!values?.length) continue;
lines.push(info(`(&blue,bold)${key}(&)`));
lines.push(out(' ' + values.map((value) => `(&cyan)${value}(&)`).join(' (&muted)•(&) ')));
}
lines.push(blank());
ctx.terminal.writeLines(lines);
}
},
{
name: 'contact',
aliases: ['socials'],
description: 'Show social links',
run: (_args, ctx) => {
ctx.terminal.write(info('(&accent,bold)Find me online:(&)'));
ctx.terminal.writeLines(
ctx.user.socials.map((social) => ({
type: 'button',
content: social.name,
icon: social.icon,
href: social.link,
style: 'primary',
inline: true
})) as TerminalLine[]
);
ctx.terminal.write(blank());
}
},
{
name: 'email',
description: 'Show contact email',
run: (_args, ctx) => {
ctx.terminal.write(out(`(&accent)${ctx.user.email}(&)`));
}
},
{
name: 'cd',
aliases: ['goto', 'open'],
description: 'Navigate to a page',
usage: 'cd <page> (e.g. projects/opensource)',
run: (args, ctx) => {
const target = (args[0] || 'home').toLowerCase().replace(/^\/+|\/+$/g, '');
const navItem = ctx.navigation.find((item) => item.name.toLowerCase() === target);
if (navItem) {
ctx.terminal.write(info(`Navigating to (&accent)${target}(&)...`));
ctx.navigate(navItem.path, navItem.external);
return;
}
const resolvedPath = resolvePagePath(target);
if (resolvedPath !== undefined) {
ctx.terminal.write(info(`Navigating to (&accent)${target}(&)...`));
ctx.navigate(resolvedPath);
return;
}
ctx.terminal.write(err(`No such page: ${target}. Try (&bold)ls(&).`));
}
},
{
name: 'ls',
aliases: ['dir'],
description: 'List the pages you can visit',
run: (_args, ctx) => {
const lines: TerminalLine[] = [];
pageTree.forEach((node) => {
lines.push(out(`(&blue)drwxr-xr-x(&) (&primary,bold)${node.name}(&)/`));
node.children?.forEach((child, i) => {
const branch = i === node.children!.length - 1 ? '└──' : '├──';
lines.push(out(`(&muted) ${branch}(&) (&primary,bold)${child.name}(&)/`));
});
});
ctx.navigation.forEach((item) => {
if (item.external && pageRoutes[item.name] === undefined) {
lines.push(out(`(&blue)drwxr-xr-x(&) (&primary,bold)${item.name}(&)/`));
}
});
ctx.terminal.writeLines(lines);
}
},
{
name: 'theme',
description: 'List or set the color theme',
usage: 'theme [name]',
run: (args, ctx) => {
if (!args.length) {
ctx.terminal.write(out('Themes: ' + ctx.themeNames.map((name) => `(&blue)${name}(&)`).join(' ')));
ctx.terminal.write(info(`Current: (&accent)${ctx.getColorTheme()}(&)`));
return;
}
const name = args[0].toLowerCase();
if (ctx.themeNames.includes(name)) {
ctx.setColorTheme(name);
ctx.terminal.write(ok(`Theme set to (&accent)${name}(&)`));
} else {
ctx.terminal.write(err(`Unknown theme: ${name}. Available: ${ctx.themeNames.join(', ')}`));
}
}
},
{
name: 'mode',
description: 'Toggle or set dark/light mode',
usage: 'mode [dark|light]',
run: (args, ctx) => {
const current = ctx.getMode();
if (!args.length) {
ctx.toggleMode();
ctx.terminal.write(ok(`Switched to ${current === 'dark' ? 'light' : 'dark'} mode`));
return;
}
const requested = args[0].toLowerCase();
if (requested === 'dark' || requested === 'light') {
if (requested !== current) ctx.toggleMode();
ctx.terminal.write(ok(`${requested} mode active`));
} else {
ctx.terminal.write(err('Usage: mode [dark|light]'));
}
}
},
{
name: 'echo',
description: 'Print text back',
usage: 'echo <text>',
run: (args, ctx) => ctx.terminal.write(out(args.join(' ')))
},
{
name: 'cowsay',
description: 'Make a cow say something',
usage: 'cowsay <text>',
run: (args, ctx) => {
const lines = cowsay(args.join(' '));
ctx.terminal.writeLines(lines.map((line) => out(`(&accent)${line}(&)`)));
}
},
{
name: 'date',
description: 'Show the current date and time (Eastern)',
run: (_args, ctx) => {
const now = new Date();
const easternString = now.toLocaleString('en-US', {
timeZone: 'America/New_York',
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZoneName: 'short'
});
const easternParts = new Intl.DateTimeFormat('en-US', {
timeZone: 'America/New_York',
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).formatToParts(now);
const part = (type: string) => Number(easternParts.find((p) => p.type === type)?.value);
const easternDate = new Date(Date.UTC(part('year'), part('month') - 1, part('day')));
const startOfYear = new Date(Date.UTC(part('year'), 0, 1));
const dayOfYear = Math.floor((easternDate.getTime() - startOfYear.getTime()) / 86400000) + 1;
ctx.terminal.writeLines([
out(easternString),
info(`(&muted)Day ${dayOfYear} of ${part('year')}(&)`)
]);
}
},
{
name: 'sudo',
hidden: true,
description: 'Elevated privileges',
run: (_args, ctx) =>
ctx.terminal.write(
err(`(&error)Nice try.(&) ${ctx.user.username} is not in the sudoers file. This incident will be reported.`)
)
}
];
const pageCommands: Command[] = Object.entries(pageRoutes).map(([name, path]) => ({
name,
description: `Go to the ${name} page`,
hidden: true,
run: (_args, ctx) => ctx.navigate(path)
}));
const socialCommands: Command[] = user.socials.map((social) => ({
name: social.name.toLowerCase(),
description: `Open ${social.name}`,
hidden: true,
run: (_args, ctx) => ctx.navigate(social.link, true)
}));
export const commands: Command[] = [...baseCommands, ...pageCommands, ...socialCommands];
function findCommand(name: string): Command | undefined {
const lower = name.toLowerCase();
return commands.find(
(command) => command.name === lower || command.aliases?.some((alias) => alias.toLowerCase() === lower)
);
}
export function runCommand(input: string, ctx: CommandContext): void {
const raw = input.trim();
ctx.terminal.write({ type: 'command', content: raw });
if (!raw) return;
const parts = raw.split(/\s+/);
const name = parts[0];
const args = parts.slice(1);
const command = findCommand(name);
if (!command) {
ctx.terminal.write(err(`command not found: (&bold)${name}(&) — type (&accent)help(&) for a list.`));
return;
}
command.run(args, ctx);
}
-169
View File
@@ -1,169 +0,0 @@
/**
* Terminal Keyboard Handler
*
* Handles keyboard navigation and interaction for the terminal.
*/
import type { DisplayedLine } from './types';
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
export interface KeyboardHandlerOptions {
getIsTyping: () => boolean;
getIsComplete: () => boolean;
getInteractive: () => boolean;
getButtonIndices: () => number[];
getSelectedIndex: () => number;
setSelectedIndex: (index: number) => void;
getDisplayedLines: () => DisplayedLine[];
getBodyElement: () => HTMLDivElement | undefined;
skipAnimation: () => void;
scrollMargin?: number;
}
/**
* Scroll selected button into view with margin for context
*/
export function scrollToSelected(
bodyElement: HTMLDivElement | undefined,
selectedIndex: number,
displayedLines: DisplayedLine[],
scrollMargin: number = 80
): void {
if (!bodyElement || selectedIndex < 0) return;
// Get all buttons in DOM order (including nested ones in groups)
const allButtons = bodyElement.querySelectorAll('.tui-button');
const selectedButton = allButtons[selectedIndex] as HTMLElement | undefined;
if (selectedButton && bodyElement) {
const containerRect = bodyElement.getBoundingClientRect();
const buttonRect = selectedButton.getBoundingClientRect();
if (buttonRect.top < containerRect.top + scrollMargin) {
const scrollAmount = buttonRect.top - containerRect.top - scrollMargin;
bodyElement.scrollBy({ top: scrollAmount, behavior: 'smooth' });
} else if (buttonRect.bottom > containerRect.bottom - scrollMargin) {
const scrollAmount = buttonRect.bottom - containerRect.bottom + scrollMargin;
bodyElement.scrollBy({ top: scrollAmount, behavior: 'smooth' });
}
}
}
/**
* Handle button/link click navigation
*/
export function handleNavigation(line: { action?: () => void; href?: string; external?: boolean } | undefined): void {
if (!line) return;
if (line.action) {
line.action();
} else if (line.href) {
const isExternal = line.external || line.href.startsWith('http://') || line.href.startsWith('https://');
if (isExternal) {
window.open(line.href, '_blank', 'noopener,noreferrer');
} else {
// Use SvelteKit's goto for client-side navigation (no page reload)
goto(line.href);
}
}
}
/**
* Create a keyboard event handler for the terminal
*/
export function createKeyboardHandler(options: KeyboardHandlerOptions) {
const {
getIsTyping,
getIsComplete,
getInteractive,
getButtonIndices,
getSelectedIndex,
setSelectedIndex,
getDisplayedLines,
getBodyElement,
skipAnimation,
scrollMargin = 80
} = options;
return function handleKeydown(event: KeyboardEvent): void {
// Ignore key events originating from editable fields (e.g. command input)
const target = event.target as HTMLElement | null;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
return;
}
// Skip animation on Y key
if (getIsTyping() && (event.key === 'y' || event.key === 'Y')) {
event.preventDefault();
skipAnimation();
return;
}
if (!getInteractive() || !getIsComplete()) return;
const bodyElement = getBodyElement();
if (!bodyElement) return;
// Get all buttons from the DOM (including nested ones in groups)
const allButtons = bodyElement.querySelectorAll('.tui-button');
const buttonCount = allButtons.length;
if (buttonCount === 0) return;
const selectedIndex = getSelectedIndex();
const displayedLines = getDisplayedLines();
if (event.key === 'ArrowDown' || event.key === 'j') {
event.preventDefault();
const nextIdx = selectedIndex < 0 ? 0 : (selectedIndex + 1) % buttonCount;
setSelectedIndex(nextIdx);
scrollToSelected(bodyElement, nextIdx, displayedLines, scrollMargin);
// Update visual selection on buttons
allButtons.forEach((btn, i) => btn.classList.toggle('selected', i === nextIdx));
} else if (event.key === 'ArrowUp' || event.key === 'k') {
event.preventDefault();
const prevIdx = selectedIndex < 0 ? buttonCount - 1 : (selectedIndex - 1 + buttonCount) % buttonCount;
setSelectedIndex(prevIdx);
scrollToSelected(bodyElement, prevIdx, displayedLines, scrollMargin);
// Update visual selection on buttons
allButtons.forEach((btn, i) => btn.classList.toggle('selected', i === prevIdx));
} else if (event.key === 'Enter') {
event.preventDefault();
if (selectedIndex >= 0 && selectedIndex < buttonCount) {
const selectedButton = allButtons[selectedIndex] as HTMLElement;
if (selectedButton) {
// Read navigation data from button data attributes
const href = selectedButton.dataset.href;
const isExternal = selectedButton.dataset.external === 'true';
if (href) {
handleNavigation({ href, external: isExternal });
} else {
// Fallback to click for buttons with actions (non-navigational)
selectedButton.click();
}
}
}
}
};
}
/**
* Scroll to hash target (anchor link like #skills)
*/
export function scrollToHash(bodyElement: HTMLDivElement | undefined): void {
if (!browser || !bodyElement) return;
const hash = window.location.hash;
if (!hash) return;
const targetId = hash.slice(1); // Remove the #
const targetElement = bodyElement.querySelector(`#${CSS.escape(targetId)}`);
if (targetElement) {
// Small delay to ensure layout is complete
setTimeout(() => {
targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 100);
}
}
-222
View File
@@ -1,222 +0,0 @@
/**
* Terminal Typing Animation Engine
*
* Handles the character-by-character typing animation for terminal lines.
*/
import type { ParsedLine, DisplayedLine } from './types';
import type { TerminalSettings } from '$lib/config';
import { calculateTypeSpeed } from '$lib';
export interface TypingState {
displayedLines: DisplayedLine[];
currentLineIndex: number;
isTyping: boolean;
isComplete: boolean;
skipRequested: boolean;
selectedIndex: number;
}
export interface TypingOptions {
parsedLines: ParsedLine[];
speedMultiplier: number;
terminalSettings: TerminalSettings;
buttonIndices: number[];
interactive: boolean;
autoscroll: boolean;
getBodyElement: () => HTMLDivElement | undefined;
getState: () => TypingState;
setState: (updates: Partial<TypingState>) => void;
onComplete?: () => void;
scrollToHash: () => void;
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Scroll terminal body to bottom
*/
function scrollToBottom(bodyElement: HTMLDivElement | undefined, autoscroll: boolean) {
if (!autoscroll || !bodyElement) return;
bodyElement.scrollTo({
top: bodyElement.scrollHeight,
behavior: 'smooth'
});
}
/**
* Run the typing animation for all lines
*/
export async function runTypingAnimation(options: TypingOptions): Promise<void> {
const {
parsedLines,
speedMultiplier,
terminalSettings,
buttonIndices,
interactive,
autoscroll,
getBodyElement,
getState,
setState,
onComplete,
scrollToHash
} = options;
if (parsedLines.length === 0) return;
setState({ isTyping: true });
// Apply speed multiplier to start delay
const startDelayMs = speedMultiplier === 0 ? 0 : terminalSettings.startDelay * speedMultiplier;
await sleep(startDelayMs);
if (getState().skipRequested) return;
for (let i = 0; i < parsedLines.length; i++) {
const state = getState();
if (state.skipRequested) return;
const parsed = parsedLines[i];
const line = parsed.line;
const plainLength = parsed.plainText.length;
// Add new line to displayed lines
const currentDisplayed = [...state.displayedLines, { parsed, charIndex: 0, complete: false, showImage: false }];
setState({ displayedLines: currentDisplayed, currentLineIndex: i });
if (line.delay && speedMultiplier > 0) {
await sleep(line.delay * speedMultiplier);
if (getState().skipRequested) return;
}
const bodyElement = getBodyElement();
// Handle different line types
if (line.type === 'image') {
await sleep(speedMultiplier === 0 ? 0 : 100 * speedMultiplier);
if (getState().skipRequested) return;
updateDisplayedLine(i, { parsed, charIndex: plainLength, complete: true, showImage: true }, getState, setState);
scrollToBottom(bodyElement, autoscroll);
} else if (line.type === 'blank' || line.type === 'divider') {
updateDisplayedLine(i, { parsed, charIndex: plainLength, complete: true, showImage: false }, getState, setState);
} else if (line.type === 'button') {
// Buttons appear instantly
updateDisplayedLine(i, { parsed, charIndex: plainLength, complete: true, showImage: false }, getState, setState);
scrollToBottom(bodyElement, autoscroll);
} else if (speedMultiplier === 0) {
// Instant mode - no typing animation
updateDisplayedLine(i, { parsed, charIndex: plainLength, complete: true, showImage: false }, getState, setState);
if (i % 5 === 0) scrollToBottom(bodyElement, autoscroll);
} else if (line.type === 'header') {
const typeSpeed = calculateTypeSpeed(plainLength, speedMultiplier * 0.25);
for (let j = 0; j <= plainLength; j++) {
if (getState().skipRequested) return;
updateDisplayedLine(i, {
parsed,
charIndex: j,
complete: j === plainLength,
showImage: false
}, getState, setState);
if (j < plainLength) await sleep(typeSpeed);
}
scrollToBottom(bodyElement, autoscroll);
} else {
const typeSpeed = calculateTypeSpeed(plainLength, speedMultiplier);
for (let j = 0; j <= plainLength; j++) {
if (getState().skipRequested) return;
updateDisplayedLine(i, {
parsed,
charIndex: j,
complete: j === plainLength,
showImage: false
}, getState, setState);
if (j % 10 === 0) scrollToBottom(bodyElement, autoscroll);
if (j < plainLength) {
await sleep(typeSpeed);
}
}
}
if (getState().skipRequested) return;
// Mark line as complete
const finalState = getState();
const updatedLines = [...finalState.displayedLines];
if (updatedLines[i]) {
updatedLines[i].complete = true;
setState({ displayedLines: updatedLines });
}
scrollToBottom(bodyElement, autoscroll);
if (i < parsedLines.length - 1 && speedMultiplier > 0) {
await sleep(terminalSettings.lineDelay * speedMultiplier);
}
}
setState({ isTyping: false, isComplete: true });
if (interactive && buttonIndices.length > 0) {
setState({ selectedIndex: buttonIndices[0] });
}
// Scroll to hash anchor if present in URL
scrollToHash();
onComplete?.();
}
/**
* Update a single displayed line at index
*/
function updateDisplayedLine(
index: number,
update: DisplayedLine,
getState: () => TypingState,
setState: (updates: Partial<TypingState>) => void
) {
const state = getState();
const updatedLines = [...state.displayedLines];
updatedLines[index] = update;
setState({ displayedLines: updatedLines });
}
/**
* Skip animation and show all content instantly
*/
export function skipTypingAnimation(
parsedLines: ParsedLine[],
buttonIndices: number[],
interactive: boolean,
getState: () => TypingState,
setState: (updates: Partial<TypingState>) => void,
getBodyElement: () => HTMLDivElement | undefined,
autoscroll: boolean,
onComplete?: () => void
): void {
const state = getState();
if (!state.isTyping) return;
const displayedLines = parsedLines.map(parsed => ({
parsed,
charIndex: parsed.plainText.length,
complete: true,
showImage: parsed.line.type === 'image'
}));
setState({
skipRequested: true,
displayedLines,
isTyping: false,
isComplete: true,
currentLineIndex: parsedLines.length - 1,
selectedIndex: interactive && buttonIndices.length > 0 ? buttonIndices[0] : -1
});
scrollToBottom(getBodyElement(), autoscroll);
onComplete?.();
}
-278
View File
@@ -1,278 +0,0 @@
import type { TextSegment } from './utils';
import type { Card } from '$lib/config';
// Re-export TextSegment for convenience
export type { TextSegment };
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'
| 'group';
// Option type for radio and select components
export interface FormOption {
value: string;
label: string;
icon?: string;
disabled?: boolean;
}
// Base interface for all terminal lines
export interface BaseTerminalLine {
type: LineType;
content: string;
id?: string;
inline?: boolean;
display?: 'flex' | 'block' | 'grid' | 'inline';
delay?: number;
style?: 'primary' | 'secondary' | 'accent' | 'warning' | 'error';
mobile?: boolean;
}
// Specific line types
export interface CommandLine extends BaseTerminalLine {
type: 'command';
}
export interface OutputLine extends BaseTerminalLine {
type: 'output';
}
export interface PromptLine extends BaseTerminalLine {
type: 'prompt';
}
export interface ErrorLine extends BaseTerminalLine {
type: 'error';
}
export interface SuccessLine extends BaseTerminalLine {
type: 'success';
}
export interface InfoLine extends BaseTerminalLine {
type: 'info';
}
export interface WarningLine extends BaseTerminalLine {
type: 'warning';
}
export interface BlankLine extends BaseTerminalLine {
type: 'blank';
height?: string | number;
}
export interface DividerLine extends BaseTerminalLine {
type: 'divider';
}
export interface HeaderLine extends BaseTerminalLine {
type: 'header';
}
export interface ButtonLine extends BaseTerminalLine {
type: 'button';
action?: () => void;
href?: string;
icon?: string;
external?: boolean;
textStyle?: 'start' | 'center' | 'end';
border?: boolean;
flex?: boolean;
}
export interface LinkLine extends BaseTerminalLine {
type: 'link';
href: string;
external?: boolean;
icon?: string;
}
export interface ImageLine extends BaseTerminalLine {
type: 'image';
image: string;
imageAlt?: string;
imageWidth?: number;
}
export interface CardLine extends BaseTerminalLine {
type: 'card';
cardTitle?: string;
cardFooter?: string;
icon?: string;
image?: string;
imageAlt?: string;
children?: TerminalLine[];
cardWidth?: string | number;
cardHeight?: string | number;
cardFloat?: 'start' | 'center' | 'end';
link?: string;
}
export interface ProgressLine extends BaseTerminalLine {
type: 'progress';
progress: number; // 0-100
progressLabel?: string;
progressStyle?: 'block' | 'continuous' | 'text';
}
export interface AccordionLine extends BaseTerminalLine {
type: 'accordion';
accordionItems?: { title: string; content: string }[];
children?: TerminalLine[];
accordionOpen?: boolean;
}
export interface TableLine extends BaseTerminalLine {
type: 'table';
tableHeaders: string[];
tableRows: string[][];
}
export interface TooltipLine extends BaseTerminalLine {
type: 'tooltip';
tooltipText: string;
tooltipPosition?: 'top' | 'bottom' | 'left' | 'right';
}
export interface CardGridLine extends BaseTerminalLine {
type: 'cardgrid';
cards: Card[];
}
export interface InputLine extends BaseTerminalLine {
type: 'input';
inputPlaceholder?: string;
inputType?: 'text' | 'email' | 'password' | 'number' | 'url' | 'tel' | 'search';
inputDisabled?: boolean;
inputError?: boolean;
inputErrorMessage?: string;
inputPrefix?: string;
inputSuffix?: string;
icon?: string;
}
export interface TextareaLine extends BaseTerminalLine {
type: 'textarea';
inputPlaceholder?: string;
textareaRows?: number;
textareaMaxLength?: number;
inputDisabled?: boolean;
inputError?: boolean;
inputErrorMessage?: string;
icon?: string;
}
export interface CheckboxLine extends BaseTerminalLine {
type: 'checkbox';
checkboxIndeterminate?: boolean;
inputDisabled?: boolean;
icon?: string;
}
export interface RadioLine extends BaseTerminalLine {
type: 'radio';
radioOptions: FormOption[];
radioHorizontal?: boolean;
inputDisabled?: boolean;
icon?: string;
}
export interface SelectLine extends BaseTerminalLine {
type: 'select';
selectOptions: FormOption[];
selectSearchable?: boolean;
inputPlaceholder?: string;
inputDisabled?: boolean;
inputError?: boolean;
inputErrorMessage?: string;
icon?: string;
}
export interface ToggleLine extends BaseTerminalLine {
type: 'toggle';
toggleOnLabel?: string;
toggleOffLabel?: string;
toggleShowLabels?: boolean;
inputDisabled?: boolean;
icon?: string;
}
export interface GroupLine extends BaseTerminalLine {
type: 'group';
children: TerminalLine[];
groupDirection?: 'row' | 'column';
groupAlign?: 'start' | 'center' | 'end' | 'stretch';
groupGap?: string;
groupExpand?: boolean;
}
// Discriminated union of all line types
export type TerminalLine =
| CommandLine | OutputLine | PromptLine | ErrorLine | SuccessLine | InfoLine | WarningLine
| BlankLine | DividerLine | HeaderLine | ButtonLine | LinkLine | ImageLine
| CardLine | ProgressLine | AccordionLine | TableLine | TooltipLine | CardGridLine
| InputLine | TextareaLine | CheckboxLine | RadioLine | SelectLine | ToggleLine
| GroupLine;
// Terminal API for reactive manipulation
export interface TerminalAPI {
/** Clear all lines from the terminal */
clear: () => void;
/** Write a new line to the terminal (appends) */
write: (line: TerminalLine) => void;
/** Write multiple lines to the terminal (appends) */
writeLines: (lines: TerminalLine[]) => void;
/** Update a specific line by index */
update: (index: number, line: Partial<TerminalLine>) => void;
/** Update a line's content by index */
updateContent: (index: number, content: string) => void;
/** Insert a line at a specific index */
insert: (index: number, line: TerminalLine) => void;
/** Remove a line by index */
remove: (index: number) => void;
/** Remove lines by range */
removeRange: (startIndex: number, count: number) => void;
/** Replace all lines */
setLines: (lines: TerminalLine[]) => void;
/** Get current line count */
getLineCount: () => number;
/** Get a line by index */
getLine: (index: number) => TerminalLine | undefined;
/** Get all lines */
getLines: () => TerminalLine[];
/** Find line index by id */
findById: (id: string) => number;
/** Update a line by its id */
updateById: (id: string, line: Partial<TerminalLine>) => void;
/** Remove a line by its id */
removeById: (id: string) => void;
/** Scroll to bottom */
scrollToBottom: () => void;
/** Scroll to a specific line index */
scrollToLine: (index: number) => void;
/** Check if terminal is currently animating */
isAnimating: () => boolean;
/** Skip current animation */
skip: () => void;
/** Restart the typing animation */
restart: () => void;
}
// Pre-parsed line with segments ready for rendering
export interface ParsedLine {
line: TerminalLine;
segments: TextSegment[];
plainText: string; // Plain text without color codes for length calculation
}
// Display state for a line during typing animation
export interface DisplayedLine {
parsed: ParsedLine;
charIndex: number; // How many plain-text characters to show
complete: boolean;
showImage: boolean;
}
-239
View File
@@ -1,239 +0,0 @@
// Shared utilities used by TUI components
export interface TextSegment {
text: string;
color?: string;
background?: string;
bold?: boolean;
dim?: boolean;
italic?: boolean;
underline?: boolean;
strikethrough?: boolean;
overline?: boolean;
// For inline icons
icon?: string;
iconSize?: number;
// For inline clickable text
href?: string;
action?: () => void;
}
// Color maps for each theme
export type ThemeColorMap = Record<string, string>;
// Default color map (fallback) - hex values as defaults, overwritten by theme colorMap at runtime
export const defaultColorMap: ThemeColorMap = {
// Basic colors
'red': '#f38ba8',
'green': '#a6e3a1',
'yellow': '#f9e2af',
'blue': '#89b4fa',
'magenta': '#cba6f7',
'cyan': '#94e2d5',
'white': '#cdd6f4',
'gray': '#6c7086',
'orange': '#fab387',
'pink': '#f5c2e7',
'black': '#1e1e2e',
'surface': '#313244',
// Extended colors
'teal': '#94e2d5',
'sky': '#89dceb',
'sapphire': '#74c7ec',
'lavender': '#b4befe',
'peach': '#fab387',
'maroon': '#eba0ac',
'mauve': '#cba6f7',
'flamingo': '#f2cdcd',
'rosewater': '#f5e0dc',
// Semantic colors
'primary': '#cba6f7',
'accent': '#a6e3a1',
'muted': '#6c7086',
'error': '#f38ba8',
'success': '#a6e3a1',
'warning': '#f9e2af',
'info': '#89b4fa',
// Theme colors
'text': '#cdd6f4',
'textMuted': '#a6adc8',
'background': '#1e1e2e',
'backgroundLight': '#313244',
'border': '#45475a',
'terminal': '#1e1e2e',
'terminalPrompt': '#cba6f7',
'terminalUser': '#a6e3a1',
'terminalPath': '#89b4fa',
};
// Legacy alias for backwards compatibility
export const colorMap = defaultColorMap;
// Text style keywords
const textStyles = ['bold', 'dim', 'italic', 'underline', 'strikethrough', 'overline'];
export function parseColorText(text: string, colors: ThemeColorMap = colorMap): TextSegment[] {
const segments: TextSegment[] = [];
// Match (&icon, iconName) patterns FIRST, then (&specs)content(&)
// This prevents (&icon, ...) from being consumed as the start of a color block
const regex = /\(&icon,\s*([^)]+)\)|\(&([^)]+)\)(.*?)\(&\)/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) {
segments.push({ text: text.slice(lastIndex, match.index) });
}
// Check if this is an icon match (Group 1 is the icon name, optionally followed by a size)
if (match[1]) {
const [iconName, sizeArg] = match[1].split(',').map(part => part.trim());
const iconSize = sizeArg ? Number(sizeArg) : undefined;
segments.push({ text: '', icon: iconName, iconSize: iconSize && !isNaN(iconSize) ? iconSize : undefined });
lastIndex = match.index + match[0].length;
continue;
}
// Standard text formatting (Group 2 is specs, Group 3 is content)
const specs = match[2].split(',').map(s => s.trim().toLowerCase());
const content = match[3];
const segment: TextSegment = { text: content };
for (const spec of specs) {
// Text styles
if (spec === 'bold') segment.bold = true;
else if (spec === 'dim') segment.dim = true;
else if (spec === 'italic') segment.italic = true;
else if (spec === 'underline') segment.underline = true;
else if (spec === 'strikethrough' || spec === 'strike') segment.strikethrough = true;
else if (spec === 'overline') segment.overline = true;
// Background color (bg-colorname or bg-#hex)
else if (spec.startsWith('bg-')) {
const bgColor = spec.slice(3);
if (colors[bgColor]) {
segment.background = colors[bgColor];
} else if (bgColor.startsWith('#')) {
segment.background = bgColor;
}
}
// Foreground color
else if (colors[spec] && !textStyles.includes(spec)) {
segment.color = colors[spec];
} else if (spec.startsWith('#')) {
segment.color = spec;
}
}
segments.push(segment);
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
segments.push({ text: text.slice(lastIndex) });
}
if (segments.length === 0) {
segments.push({ text });
}
return segments;
}
// Get plain text from segments (for length calculation)
export function getPlainText(segments: TextSegment[]): string {
return segments.map(s => s.text).join('');
}
// Get segments up to a certain character count (for typing animation)
export function getSegmentsUpToChar(segments: TextSegment[], charCount: number): TextSegment[] {
const result: TextSegment[] = [];
let remaining = charCount;
for (const segment of segments) {
if (remaining <= 0) break;
if (segment.text.length <= remaining) {
result.push(segment);
remaining -= segment.text.length;
} else {
// Partial segment
result.push({ ...segment, text: segment.text.slice(0, remaining) });
remaining = 0;
}
}
return result;
}
export function getSegmentStyle(segment: TextSegment): string {
const styles: string[] = [];
if (segment.color) styles.push(`color: ${segment.color}`);
if (segment.background) styles.push(`background-color: ${segment.background}; padding: 0.1em 0.25em; border-radius: 3px`);
if (segment.bold) styles.push('font-weight: bold');
if (segment.dim) styles.push('opacity: 0.6');
if (segment.italic) styles.push('font-style: italic');
// Combine text decorations
const decorations: string[] = [];
if (segment.underline) decorations.push('underline');
if (segment.strikethrough) decorations.push('line-through');
if (segment.overline) decorations.push('overline');
if (decorations.length > 0) {
styles.push(`text-decoration: ${decorations.join(' ')}`);
}
return styles.join('; ');
}
export function getLinePrefix(type: string): string {
switch (type) {
case 'error':
return '✗ ';
case 'success':
return '✓ ';
case 'warning':
return '⚠ ';
case 'info':
return '› ';
default:
return '';
}
}
export function getButtonStyle(style?: string): string {
switch (style) {
case 'primary':
return 'var(--terminal-primary)';
case 'accent':
return 'var(--terminal-accent)';
case 'warning':
return '#f9e2af';
case 'error':
return '#f38ba8';
default:
return 'var(--terminal-text)';
}
}
export function parseDimension(value: string | number | undefined): string | undefined {
if (value === undefined) return undefined;
if (typeof value === 'number') {
// If it's a decimal between 0 and 1, treat as percentage
if (value > 0 && value <= 1) {
return `${value * 100}%`;
}
return `${value}px`;
}
if (typeof value === 'string') {
// Handle fractions like "1/2", "1/3"
if (value.includes('/')) {
const [num, den] = value.split('/').map(Number);
if (!isNaN(num) && !isNaN(den) && den !== 0) {
return `${(num / den) * 100}%`;
}
}
// Return as is if it already has a unit or is just a number string
return value;
}
return undefined;
}
+52
View File
@@ -0,0 +1,52 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Icon from '@iconify/svelte';
import { isExternalUrl } from '#lib/desktop/actions';
let {
href,
icon,
variant = 'outline',
children
}: { href: string; icon?: string; variant?: 'outline' | 'filled'; children: Snippet } = $props();
const external = $derived(isExternalUrl(href));
</script>
<a
class="link-button {variant}"
{href}
target={external ? '_blank' : undefined}
rel={external ? 'noopener noreferrer' : undefined}
>
{#if icon}
<Icon {icon} width="17" />
{/if}
{@render children()}
</a>
<style>
.link-button {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.3rem 0.7rem;
border: 1px solid var(--accent);
color: var(--accent);
font-size: 0.85rem;
transition:
background 0.15s,
color 0.15s;
}
.link-button:hover,
.filled {
background: var(--accent);
color: var(--background);
}
.filled:hover {
background: transparent;
color: var(--accent);
}
</style>
+68
View File
@@ -0,0 +1,68 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { profile } from '#lib/content';
let { command, title, subtitle, children }: { command: string; title?: string; subtitle?: string; children?: Snippet } =
$props();
</script>
<svelte:head>
{#if title}
<title>{title} | {profile.displayName}</title>
{/if}
{#if subtitle}
<meta name="description" content={subtitle} />
{/if}
</svelte:head>
<header class="page-header">
<div class="command">
<span class="prompt">{profile.username}@{profile.hostname}</span>
<span class="path">~</span>
<span class="symbol">$</span>
<span>{command}</span>
</div>
{#if title}
<h1>{title}</h1>
{/if}
{#if subtitle}
<p class="subtitle">{subtitle}</p>
{/if}
{@render children?.()}
</header>
<style>
.page-header {
margin-bottom: 1.25rem;
}
.command {
display: flex;
flex-wrap: wrap;
gap: 0.5ch;
margin-bottom: 0.75rem;
}
.prompt {
color: var(--green);
}
.path {
color: var(--blue);
}
.symbol {
color: var(--accent);
}
h1 {
margin: 0;
color: var(--accent);
font-size: 1.5rem;
}
.subtitle {
margin: 0.25rem 0 0;
color: var(--muted);
}
</style>
+49
View File
@@ -0,0 +1,49 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Icon from '@iconify/svelte';
let { title, icon, href, children }: { title?: string; icon?: string; href?: string; children?: Snippet } = $props();
</script>
<svelte:element
this={href ? 'a' : 'article'}
class="panel"
class:linked={!!href}
{href}
target={href?.startsWith('http') ? '_blank' : undefined}
rel={href?.startsWith('http') ? 'noopener noreferrer' : undefined}
>
{#if title}
<header class="panel-title">
{#if icon}
<Icon {icon} width="16" />
{/if}
<span>{title}</span>
</header>
{/if}
{@render children?.()}
</svelte:element>
<style>
.panel {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.8rem 0.9rem;
border: 1px solid color-mix(in srgb, var(--foreground) 22%, transparent);
background: color-mix(in srgb, var(--foreground) 3%, transparent);
}
.linked:hover {
border-color: var(--accent);
background: color-mix(in srgb, var(--foreground) 7%, transparent);
}
.panel-title {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--accent);
font-weight: 700;
}
</style>
+48
View File
@@ -0,0 +1,48 @@
<script lang="ts">
import { profile } from '#lib/content';
let { heading, subheading, text }: { heading: string; subheading: string; text: string } = $props();
</script>
<div class="profile-intro">
<img src={profile.avatar} alt={profile.displayName} />
<div class="details">
<h1>{heading}</h1>
<div class="subheading">{subheading}</div>
<p class="muted">{text}</p>
</div>
</div>
<style>
.profile-intro {
display: flex;
flex-wrap: wrap;
gap: 1.25rem;
align-items: center;
margin-bottom: 1rem;
}
img {
width: 160px;
image-rendering: pixelated;
border: 1px solid color-mix(in srgb, var(--foreground) 25%, transparent);
}
.details {
flex: 1;
min-width: 240px;
}
h1 {
margin: 0;
font-size: 1.5rem;
}
.subheading {
color: var(--accent);
}
p {
margin: 0.5rem 0 0;
}
</style>
+43
View File
@@ -0,0 +1,43 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let { title, id, children }: { title: string; id?: string; children: Snippet } = $props();
</script>
<section class="section" {id}>
<h2 class="section-title"><span>{title}</span></h2>
{@render children()}
</section>
<style>
.section {
margin: 1.75rem 0;
scroll-margin-top: 1rem;
}
.section-title {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0 0 1rem;
color: var(--accent);
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.section-title::before,
.section-title::after {
content: '';
height: 1px;
background: color-mix(in srgb, var(--foreground) 25%, transparent);
}
.section-title::before {
width: 1.5rem;
}
.section-title::after {
flex: 1;
}
</style>
+41
View File
@@ -0,0 +1,41 @@
<script lang="ts">
let { sections }: { sections: { id: string; label: string; color: string }[] } = $props();
function jumpTo(id: string) {
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
</script>
<nav class="section-nav">
<span class="label">Links &gt;</span>
{#each sections as section (section.id)}
<button style="--chip-color: var(--{section.color})" onclick={() => jumpTo(section.id)}>{section.label}</button>
{/each}
</nav>
<style>
.section-nav {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem;
}
.label {
color: var(--accent);
font-weight: 700;
}
button {
padding: 0.05rem 0.5rem;
background: var(--chip-color);
border: 1px solid var(--chip-color);
color: var(--background);
font-weight: 700;
}
button:hover {
background: transparent;
color: var(--chip-color);
}
</style>
+19
View File
@@ -0,0 +1,19 @@
<script lang="ts">
let { label, colorIndex = 0 }: { label: string; colorIndex?: number } = $props();
const tagColors = ['blue', 'magenta', 'cyan', 'orange', 'green', 'yellow'];
const color = $derived(tagColors[colorIndex % tagColors.length]);
</script>
<span class="tag" style="--tag-color: var(--{color})">{label}</span>
<style>
.tag {
display: inline-block;
padding: 0.05rem 0.45rem;
border: 1px solid var(--tag-color);
color: var(--tag-color);
font-size: 0.75rem;
white-space: nowrap;
}
</style>
-20
View File
@@ -1,20 +0,0 @@
// ============================================================================
// CONFIGURATION - BARREL EXPORT
// ============================================================================
// This file re-exports all configuration from the modular config/ directory.
// For backward compatibility, you can continue importing from '$lib/config'.
//
// The configuration is now split into smaller, focused files:
// - config/user.ts → User profile and skills
// - config/layout.ts → Layout, breakpoints, fonts, navbar, scrollbar
// - config/theme.ts → Colors, animations, effects, loading screen
// - config/content.ts → Projects, models, hackathon cards
// - config/terminal.ts → Terminal settings, TUI styling, shortcuts
// - config/navigation.ts → Navigation links, site metadata, page meta
//
// You can import directly from individual files for smaller bundles:
// import { user } from '$lib/config/user';
// import { colorPalette } from '$lib/config/theme';
// ============================================================================
export * from './config/index';
-2
View File
@@ -1,2 +0,0 @@
export const blogName = 'Finding Out';
export const blogTagline = 'My Engineering Blog';
-360
View File
@@ -1,360 +0,0 @@
// ============================================================================
// PROJECTS
// ============================================================================
export interface OpenSourceProject {
name: string;
description: string;
tech: string[];
github?: string;
live?: string;
image?: string;
}
export const openSourceProjects: OpenSourceProject[] = [
{
name: 'TypstDrive',
description: 'TypstDrive is a self-hosted collaborative web editor for Typst.',
tech: ['Typst', 'Self-Hosted', 'Collaborative'],
github: 'https://github.com/SirBlobby/TypstDrive',
image: '/projects/typstdrive.png',
},
{
name: 'Typst Desktop',
description: 'Typst Desktop is a native desktop app for writing and compiling Typst documents offline.',
tech: ['Typst', 'Desktop', 'Offline'],
github: 'https://github.com/SirBlobby/typst-desktop',
image: '/projects/typstdesktop.png',
},
{
name: 'Pkit',
description: 'CLI toolkit and utilities (Rust project) — small developer-focused CLI.',
tech: ['Rust', 'CLI'],
live: 'https://pkit.sirblob.co/',
github: 'https://github.com/dead-projects-inc/pkit-cli',
image: 'https://icons.veryicon.com/png/o/business/vscode-program-item-icon/rust-1.png',
},
{
name: 'OBookLLM',
description: "OBookLLM is a powerful, self-hosted, offline-capable alternative to NotebookLLM.",
tech: ['Python', 'LLM', 'AI', 'NotebookLLM'],
github: 'https://github.com/SirBlobby/OBookLLM',
image: 'https://upload.wikimedia.org/wikipedia/commons/c/c3/Python-logo-notext.svg',
}
];
// ============================================================================
// PACKAGES
// ============================================================================
export interface PackageProject {
name: string;
description: string;
tech: string[];
github?: string;
live?: string;
image?: string;
featured?: boolean;
}
export const packageProjects: PackageProject[] = [
{
name: 'PokemonTCGAPI',
description: 'UnOfficial NPM package wrapper for the PokemonTCG API.',
tech: ['TypeScript', 'Node.js'],
live: 'https://www.npmjs.com/package/@bosstop/pokemontcgapi',
image: 'https://upload.wikimedia.org/wikipedia/commons/thumb/d/db/Npm-logo.svg/1200px-Npm-logo.svg.png',
featured: false
},
{
name: 'MCSS TS API',
description: 'TypeScript API for MC Server Soft services, published to NPM.',
tech: ['TypeScript', 'Node.js'],
live: 'https://www.npmjs.com/package/@mcserversoft/mcss-api',
image: 'https://upload.wikimedia.org/wikipedia/commons/thumb/d/db/Npm-logo.svg/1200px-Npm-logo.svg.png',
featured: false
},
{
name: 'MCP Selenium',
description: 'Selenium automation utilities for MCP workflows, packaged for reuse.',
tech: ['TypeScript', 'Selenium'],
live: 'https://www.npmjs.com/package/@sirblob/mcp-selenium',
image: 'https://upload.wikimedia.org/wikipedia/commons/thumb/d/db/Npm-logo.svg/1200px-Npm-logo.svg.png',
featured: true
}
];
// ============================================================================
// 3D MODELS
// ============================================================================
export interface Model3D {
name: string;
description: string;
thumbnail: string;
modelUrl?: string; // URL to .glb/.gltf file for preview
downloadUrl?: string;
software: string[];
polyCount?: string;
featured?: boolean;
}
export const models: Model3D[] = [
{
name: 'Bake Potato',
description: 'A fun baked-potato 3D model for demos and testing.',
thumbnail: '/models/BakePotato-thumb.png',
modelUrl: '/models/BakePotato.glb',
software: ['Blender'],
polyCount: '1k tris',
featured: true
},
{
name: 'Soda Can',
description: 'A realistic soda can GLB used for materials/tests.',
thumbnail: '/models/Soda-thumb.png',
modelUrl: '/models/Soda.glb',
software: ['Blender'],
polyCount: '800 tris',
featured: false
}
];
// ============================================================================
// EXPERIENCE
// ============================================================================
export interface Experience {
role: string;
organization: string;
employmentType?: string;
location?: string;
period: string;
description?: string;
tech?: string[];
awards?: string[];
}
export const experience: Experience[] = [
{
role: 'Research Assistant',
organization: 'College of Engineering and Computing',
employmentType: 'Part-time',
period: 'May 2026 – Present · 3 mos'
},
{
role: 'Software Engineering Lead',
organization: 'Raytheon Autonomous Vehicle Competition',
period: 'Dec 2025 – May 2026 · 6 mos',
description:
'Developing a collaborative autonomous system utilizing Python and ROS2 to coordinate communication and control as a UAV scouts and lands on a moving UGV. Engineering Gazebo simulation environments to validate computer vision algorithms for ArUco marker target identification and obstacle avoidance.',
tech: ['Python', 'ROS2'],
awards: ['1st Place Overall Winner']
},
{
role: 'QA Automation Intern',
organization: 'Deltek',
employmentType: 'Internship',
location: 'Hybrid',
period: 'Jun 2025 – Aug 2025 · 3 mos',
description:
'Designed and implemented a workflow integrating AI into the QA automation pipeline, enhancing test efficiency for the GovWin IQ team. Researched Model Context Protocol (MCP) servers to improve automation, developed and published a public Selenium MCP package to NPM, enabling browser automation interactions with Agentic AI.',
tech: ['Artificial Intelligence (AI)', 'TypeScript', 'Selenium']
}
];
// ============================================================================
// RESEARCH
// ============================================================================
export interface ResearchItem {
title: string;
organization?: string;
period?: string;
description?: string;
tech?: string[];
link?: string;
}
export const researchItems: ResearchItem[] = [
{
title: 'Research Assistant',
organization: 'College of Engineering and Computing',
period: 'May 2026 – Present · 3 mos'
}
];
// ============================================================================
// HACKATHONS
// ============================================================================
export type Card = {
title: string;
description: string;
image?: string | null;
link?: string;
repo?: string;
devpost?: string;
hackathonName?: string;
university?: string;
location?: string;
year?: string;
tags?: string[];
featured?: boolean;
awards?: { track: string; place: string }[];
liveWarning?: boolean;
};
export const cards: Card[] = [
{
image: "/hacks/hushmap.png",
title: "HushMap",
description:
"Architected a real-time campus noise and occupancy monitoring system utilizing SvelteKit, FastAPI, and MongoDB to help students locate quiet study spaces.",
link: "https://hushmap.study/",
devpost: "https://devpost.com/software/hushmap-193nia",
hackathonName: "Bitcamp 2026",
university: "University of Maryland",
location: "College Park, MD",
year: "2026",
tags: ["Svelte", "YOLOv8", "FastAPI", "MongoDB"],
featured: true,
awards: [
{ track: "Best UI/UX Hack", place: "Winner" }
]
},
{
image: "/hacks/learningbuddy.png",
title: "LearningBuddy",
description:
"Engineered an ESP32-S3 IoT study companion in C++ with secure Wi-Fi provisioning, utilizing WebSockets and Socket.IO to stream low-latency raw audio for live lecture capture.",
devpost: "https://devpost.com/software/studybuddy-bc0i3j",
hackathonName: "HaxFax x PatriotHacks 2026",
university: "George Mason University",
location: "Fairfax, VA",
year: "2026",
tags: ["C++", "WebSocket", "ESP32-S3", "Socket.IO"],
featured: true,
awards: [
{ track: "Best use of MongoDB", place: "Winner" }
]
},
{
image: "/hacks/fooddecisive.png",
title: "Food Decisive",
description:
"Ever felt the indecisiveness of choosing what to eat? Don't fret! Decide your next bite with Food Decisive",
link: "https://fooddecisive.sirblob.co/",
repo: "https://github.com/SirBlobby/VTHacks-12",
devpost: "https://devpost.com/software/food-decisive",
hackathonName: "VTHacks 12",
university: "Virginia Tech",
location: "Blacksburg, VA",
year: "2024",
tags: ["AI", "Llama3.1", "Svelte", "Node.js"],
featured: false,
liveWarning: true
},
{
image: "/hacks/carbin.png",
title: "Carbin",
description:
"Encourage student participation in responsible waste management with smart bins that guide proper disposal.",
repo: "https://github.com/SirBlobby/patriotHacks2024",
devpost: "https://devpost.com/software/carboniferous-akc4mj",
hackathonName: "PatriotHacks 2024",
university: "George Mason University",
location: "Fairfax, VA",
year: "2024",
tags: ["AI", "Azure", "CloudConvert", "Python", "React", "TypeScript", "API"],
featured: true,
awards: [
{ track: "Save the World", place: "2nd Place" },
{ track: "Microsoft X Cloudforce", place: "2nd Place" }
]
},
{
image: "/hacks/patsafe.png",
title: "PatSafe",
description:
"Bridging the gap between doctors and patients for seamless post-discharge care",
link: "https://hoya-hax2025.vercel.app/",
repo: "https://github.com/SirBlobby/HoyaHax2025",
devpost: "https://devpost.com/software/patsafe",
hackathonName: "HoyaHax 2025",
university: "Georgetown University",
location: "Washington, D.C.",
year: "2025",
tags: ["Javascript", "Next.js", "React", "TypeScript", "LangChain", "OpenAI"],
},
{
image: "https://placehold.co/800x400/334155/94a3b8?text=Fauxcall",
title: "Fauxcall",
description:
"This product is perfect for situations where you are walking at night and you feel unsafe from someone. Fauxcall lets users create a convincing fake phone call to deter potential attackers and provide a quick escape mechanism.",
link: "",
repo: "https://github.com/SirBlobby/HooHacks-12",
devpost: "https://devpost.com/software/fauxcall",
hackathonName: "HooHacks 2025",
university: "University of Virginia",
location: "Charlottesville, VA",
year: "2025",
tags: ["mongodb", "next.js", "python", "react", "sesame.com", "skeleton", "twilio"],
featured: false
},
{
image: "/hacks/drinkhappy.png",
title: "Drink Happy",
description:
"drinkhappy.tech is a gamified hydration wellness app that helps users track drinks, earn points for healthy choices, and compete with friends. It's powered by Gemini AI for smart drink recognition.",
link: "https://drinkhappy.tech",
repo: "https://github.com/SirBlobby/Bitcamp-2025",
devpost: "https://devpost.com/software/drink-happy",
hackathonName: "Bitcamp",
university: "University of Maryland",
location: "College Park, MD",
year: "2025",
tags: ["api", "auth0", "gemini", "github", "javascript", "mongodb", "nextjs", "react", "tailwind", "vercel"],
featured: false
},
{
image: "/hacks/roadcast.png",
title: "Roadcast",
description:
"Roadcast provides drivers with crash data and weather insights to choose safer routes home. The project combines historical crash data, weather feeds, and spatial analysis to surface hazardous areas and route-level safety recommendations.",
link: "https://roadcast.sirblob.co/",
repo: "https://github.com/SirBlobby/roadcast",
devpost: "https://devpost.com/software/roadcast",
hackathonName: "VTHacks 13",
university: "Virginia Tech",
location: "Blacksburg, VA",
year: "2025",
tags: ["react", "node.js", "python", "mongodb", "geospatial", "mapbox"],
featured: false,
liveWarning: true
},
{
image: "/img/hoya26.png",
title: "Ethix",
description: "A sustainability platform that helps users detect and report greenwashing - misleading environmental claims made by companies and products.",
repo: "https://github.com/SirBlobby/Hoya26",
devpost: "https://devpost.com/software/ethix",
link: "https://ethix.sirblob.co/",
hackathonName: "HoyaHax 2026",
university: "Georgetown University",
location: "Washington, D.C.",
year: "2026",
tags: ["Svelte", "TypeScript", "Python", "Flask", "Tailwind", "Sustainability"],
featured: true
}
];
// Sort cards to show featured first
export const sortedCards = [...cards].sort((a, b) => {
if (a.featured && !b.featured) return -1;
if (!a.featured && b.featured) return 1;
return 0;
});
-81
View File
@@ -1,81 +0,0 @@
// ============================================================================
// CONFIGURATION INDEX
// ============================================================================
// This file re-exports all configuration modules for backward compatibility.
// You can import from here or directly from the individual config files.
//
// Import examples:
// import { user, skills } from '$lib/config'; // via barrel
// import { user } from '$lib/config/user'; // direct import
// import { colorPalette } from '$lib/config/theme'; // direct import
// ============================================================================
// User profile and skills
export type { LinkItem } from './user';
export { user, skills, links } from './user';
// Layout, breakpoints, fonts, navbar, scrollbar
export { layout, breakpoints, fonts, navbar, scrollbar } from './layout';
// Theme colors, buttons, animations, effects, loading screen
export {
colorPalette,
terminalButtons,
loadingScreen,
effects,
animations
} from './theme';
// Content: projects, models, experience, research, hackathon cards
export type { OpenSourceProject, PackageProject, Model3D, Experience, ResearchItem, Card } from './content';
export { openSourceProjects, packageProjects, models, experience, researchItems, cards, sortedCards } from './content';
// Terminal settings, TUI styling, speed presets, model viewer, particles, shortcuts
export type { SpeedPreset, TerminalSettings } from './terminal';
export {
terminalSettings,
tuiStyle,
tuiText,
pageSpeedSettings,
pageAutoscrollSettings,
speedPresets,
modelViewer,
particles,
keyboardShortcuts
} from './terminal';
// Navigation, site metadata, page meta
export type { PageMeta } from './navigation';
export { navigation, site, pageMeta } from './navigation';
// ============================================================================
// COLOR FORMATTING REFERENCE
// ============================================================================
// Use (&color)text(&) syntax in terminal content for colored text
// Use (&icon, iconName) syntax for inline icons
// Use (&icon, iconName, size) to set a custom icon size in pixels
//
// Available colors:
// red, green, yellow, blue, magenta, cyan, white, gray, orange, pink
// primary, accent, muted, error, success, warning, info
//
// Available styles:
// bold, dim, italic, underline
//
// Examples:
// "Hello (&red)world(&)!" -> red "world"
// "(&bold,green)Success(&): Task done" -> bold green "Success"
// "(&italic,cyan)Note(&): See docs" -> italic cyan "Note"
// "Status: (&primary)Active(&)" -> theme primary color
// "(&dim)This is muted text(&)" -> dimmed text
// "(&bold,underline,yellow)Warning(&)" -> bold underlined yellow
// "(&error)Failed(&) - (&success)Passed(&)" -> multiple colors
// "(&icon, mdi:github) GitHub" -> inline GitHub icon
// "Check (&icon, mdi:check) Done" -> inline check icon
// "(&icon, mdi:trophy, 32) Winner" -> bigger trophy icon (32px)
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
// NOTE: Helper functions are in `src/lib/index.ts` to provide a central
// barrel and avoid duplication. Import helpers from `$lib` instead of `$lib/config`.
-94
View File
@@ -1,94 +0,0 @@
// ============================================================================
// LAYOUT & DIMENSIONS
// ============================================================================
export const layout = {
// Navbar
navbarHeight: 60, // px
navbarMaxWidth: 1400, // px
navbarZIndex: 1000,
// Container
containerMaxWidth: 1200, // px
containerPadding: '1.5rem',
// Grid
gridGap: '1.5rem',
gridMinColumnWidth: 300, // px
// Page margins
pageBottomMargin: 80, // px (desktop)
pageMobileBottomMargin: 60, // px (mobile)
};
// ============================================================================
// RESPONSIVE BREAKPOINTS
// ============================================================================
export const breakpoints = {
mobile: 768, // px
tablet: 1024, // px
desktop: 1400, // px
};
// ============================================================================
// FONTS
// ============================================================================
export const fonts = {
mono: "'JetBrains Mono', 'Fira Code', 'Consolas', 'Monaco', monospace",
sans: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
monoWeight: 600,
};
// ============================================================================
// NAVBAR
// ============================================================================
export const navbar = {
height: 60, // px - also update layout.navbarHeight if changing
maxWidth: 1400, // px
padding: '0.75rem 1.5rem',
gap: '2rem',
zIndex: 1000,
// Brand
brandFontSize: '0.9rem',
cursorWidth: 8, // px
// Hamburger menu
hamburgerWidth: 24, // px
hamburgerHeight: 2, // px
hamburgerSpacing: 7, // px
// Links
linksGap: '1.5rem',
linkFontSize: '0.85rem',
linkPadding: '0.5rem 0.75rem',
linkBorderRadius: 4, // px
linkUnderlineHeight: 2, // px
// Mode toggle
modeToggleSize: 40, // px
modeToggleBorderRadius: 8, // px
themeButtonBorderRadius: 6, // px
// Dropdown
dropdownMinWidth: 180, // px
dropdownBorderRadius: 8, // px
dropdownZIndex: 1001,
backdropZIndex: 999,
mobileExpandedHeight: 200, // px
};
// ============================================================================
// SCROLLBAR
// ============================================================================
export const scrollbar = {
width: 8, // px
borderRadius: 4, // px
trackColor: 'rgba(0, 0, 0, 0.1)',
thumbColor: 'rgba(128, 128, 128, 0.5)',
thumbHoverColor: 'rgba(128, 128, 128, 0.7)',
};
-115
View File
@@ -1,115 +0,0 @@
import { user, skills } from './user';
// ============================================================================
// NAVIGATION
// ============================================================================
export const navigation = [
{ name: 'home', path: '/', icon: '~' },
{ name: 'about', path: '/about', icon: 'mdi:account' },
// { name: 'portfolio', path: '/portfolio', icon: '📁' },
// { name: 'models', path: '/models', icon: '🎨' },
{ name: 'projects', path: '/projects', icon: '🏆' },
// { name: 'components', path: '/components', icon: '🧩' },
{ name: 'blog', path: '/blog', icon: '📝' }
];
// ============================================================================
// SITE METADATA
// ============================================================================
export const site = {
title: `${user.displayname} | Portfolio`,
description: `${user.title} - ${user.bio}`,
keywords: ['developer', 'portfolio', 'programming', ...skills.languages, ...skills.frameworks],
ogImage: '/og-image.png',
twitterHandle: '@yourusername'
};
// ============================================================================
// PAGE META (per-route)
// ============================================================================
export interface PageMeta {
title: string;
description?: string;
icon?: string; // Iconify id or path to an image (e.g. 'mdi:home' or '/icons/home.svg')
keywords?: string[];
ogImage?: string;
}
// Map route => meta. Use route paths as keys (e.g. '/', '/portfolio')
export const pageMeta: Record<string, PageMeta> = {
'/': {
title: `${user.displayname} — Home`,
description: `Home — ${user.title} portfolio and projects.`,
icon: 'mdi:home',
keywords: ['home', 'portfolio', 'about']
},
'/about': {
title: `${user.displayname} — About`,
description: `About — ${user.title} portfolio and projects.`,
icon: 'mdi:account',
keywords: ['about', 'portfolio', 'about']
},
'/portfolio': {
title: `${user.displayname} — Portfolio`,
description: 'Selected projects, highlights and case studies.',
icon: 'mdi:folder-multiple',
keywords: ['projects', 'portfolio', 'showcase']
},
'/models': {
title: `${user.displayname} — 3D Models`,
description: 'A curated collection of 3D models and previews.',
icon: 'mdi:cube-outline',
keywords: ['3d', 'models', 'glb', 'gltf']
},
'/projects': {
title: `${user.displayname} — Projects`,
description: 'Hackathon projects, demos and awards.',
icon: 'mdi:trophy',
keywords: ['hackathon', 'projects', 'events']
},
'/projects/opensource': {
title: `${user.displayname} — Open Source`,
description: 'Libraries and tools built and maintained in the open.',
icon: 'mdi:source-branch',
keywords: ['open source', 'projects', 'github']
},
'/projects/packages': {
title: `${user.displayname} — Packages`,
description: 'Published packages and CLI tools.',
icon: 'mdi:package-variant',
keywords: ['packages', 'npm', 'cli']
},
'/projects/hackathons': {
title: `${user.displayname} — Hackathons`,
description: 'Hackathon projects, demos and awards.',
icon: 'mdi:trophy',
keywords: ['hackathon', 'projects', 'events']
},
'/projects/research': {
title: `${user.displayname} — Research`,
description: 'Research roles, projects, and publications.',
icon: 'mdi:microscope',
keywords: ['research', 'academic', 'publications']
},
'/components': {
title: `${user.displayname} — Components`,
description: 'Terminal UI components showcase and documentation.',
icon: 'mdi:puzzle',
keywords: ['components', 'ui', 'terminal', 'tui']
},
'/links': {
title: `${user.displayname} — Links`,
description: 'All my links with scannable QR codes.',
icon: 'mdi:link-variant',
keywords: ['links', 'qr', 'social', 'contact']
},
'/blog': {
title: `${user.displayname} — Blog`,
description: 'Notes and write-ups from my projects.',
icon: 'mdi:post-outline',
keywords: ['blog', 'writing', 'notes']
}
};
-261
View File
@@ -1,261 +0,0 @@
// ============================================================================
// TERMINAL DISPLAY SETTINGS
// ============================================================================
export type SpeedPreset = 'instant' | 'fast' | 'normal' | 'slow' | 'typewriter';
export interface TerminalSettings {
baseTypeSpeed: number;
minTypeSpeed: number;
maxTypeSpeed: number;
startDelay: number;
lineDelay: number;
showCursor: boolean;
promptStyle: 'full' | 'short' | 'minimal';
icon: string;
scrollMargin: number;
}
export const terminalSettings: TerminalSettings = {
// Base typing speed in ms per character (will be adjusted by content length)
baseTypeSpeed: 20,
// Minimum typing speed (fastest)
minTypeSpeed: 5,
// Maximum typing speed (slowest)
maxTypeSpeed: 50,
// Delay before starting to type (ms)
startDelay: 300,
// Delay between lines (ms)
lineDelay: 100,
// Show cursor
showCursor: true,
// Prompt style
promptStyle: 'full',
// Terminal icon (emoji or text)
icon: '🐧',
// Scroll margin when navigating buttons (px)
scrollMargin: 80,
};
// ============================================================================
// TUI (Terminal UI) STYLING
// ============================================================================
export const tuiStyle = {
// Border & container
borderRadius: 8, // px
borderWidth: 2, // px
width: '95%',
borderGlowOpacity: 0.5,
borderGlowBlur: 4, // px
// Header
headerPadding: '0.5rem 0.75rem',
headerFontSize: '0.8rem',
// Body
bodyPadding: '1rem 1.25rem 2rem 1.25rem',
bodyFontSize: '0.9rem',
lineHeight: 1.7,
lineMinHeight: '1.7em',
blankLineHeight: '0.5em',
// Divider
dividerMargin: '0.75rem 0',
// Images
imageBorderRadius: 6, // px
defaultImageWidth: 300, // px
// Cursor
cursorWidth: 8, // px
// Scrollbar
scrollbarWidth: 6, // px
// Footer/Statusbar
statusbarPadding: '0.4rem 0.75rem',
statusbarFontSize: '0.75rem',
hintFontSize: '0.7rem',
hintBorderRadius: 3, // px
// Buttons
buttonPadding: '0.5rem 0.75rem',
buttonMargin: '0.2rem 0',
buttonBorderRadius: 4, // px
buttonFontSize: '0.9rem',
buttonIndicatorFontSize: '0.8rem',
buttonIndicatorWidth: '1rem',
};
// ============================================================================
// TUI TEXT & INDICATORS
// ============================================================================
export const tuiText = {
// Status text
loading: 'Loading...',
ready: 'Ready',
skipButton: 'Skip (Y)',
tuiLabel: 'TUI',
// Keyboard hints
hints: {
navigate: '↑↓ navigate',
select: '⏎ select',
toggle: 'T theme',
},
// Line prefixes
prefixes: {
error: '✗ ',
success: '✓ ',
info: '› ',
warning: '⚠ ',
command: '$ ',
},
// Button indicators
buttonIndicator: {
selected: '▶',
unselected: ' ',
},
};
// ============================================================================
// Per-page speed settings (override global settings)
// Use preset names or custom multiplier (0.1 = 10x faster, 2 = 2x slower)
// ============================================================================
export const pageSpeedSettings: Record<string, SpeedPreset | number> = {
// Examples:
// 'home': 'fast', // Fast typing on home page
// 'portfolio': 'normal', // Normal speed
// 'models': 'instant', // No typing animation
// 'hackathons': 0.5, // Custom: 2x faster than normal
'home': 'fast',
'about': 'normal',
'portfolio': 'instant',
'models': 'fast',
'projects': 'fast',
'opensource': 'fast',
'packages': 'fast',
'hackathons': 'fast',
'research': 'fast',
'components': 'fast'
};
// Per-page autoscroll settings (whether to auto-scroll as content types)
// Set to false to disable autoscroll on specific pages
export const pageAutoscrollSettings: Record<string, boolean> = {
// Examples:
// 'home': true, // Enable autoscroll
// 'portfolio': false, // Disable autoscroll
'home': true,
'about': false,
'portfolio': false,
'models': false,
'projects': false,
'opensource': false,
'packages': false,
'hackathons': false,
'research': false,
'components': false
};
// Speed preset multipliers (lower = faster)
export const speedPresets: Record<SpeedPreset, number> = {
'instant': 0, // No delay, instant display
'fast': 0.1, // 3x faster
'normal': 0.5, // Default speed
'slow': 1.5, // 2x slower
'typewriter': 2 // 3x slower, classic typewriter feel
};
// ============================================================================
// MODEL VIEWER (3D)
// ============================================================================
export const modelViewer = {
// Container
borderRadius: 8, // px
minHeight: 300, // px
headerPadding: '0.5rem 0.75rem',
nameFontSize: '0.85rem',
// Controls
controlButtonSize: 28, // px
controlButtonBorderRadius: 4, // px
fullscreenZIndex: 100,
fullscreenTopOffset: 60, // px (navbar height)
// Camera
cameraFov: 45,
cameraNear: 0.1,
cameraFar: 1000,
cameraZ: 2.5, // initial camera distance
// Controls behavior
controlsMinDistance: 1,
controlsMaxDistance: 10,
dampingFactor: 0.05,
autoRotateSpeed: 2,
// Model
modelScale: 1.5,
// Ground plane
groundColor: 0x222222,
groundOpacity: 0.3,
// Lighting
ambientIntensity: 0.6,
directionalIntensity: 0.8,
lightColor: 0xffffff,
rimLightColor: 0x88ccff,
// Text
loadingText: 'Loading model...',
errorText: 'Failed to load model',
hints: {
rotate: 'Drag to rotate',
zoom: 'Scroll to zoom',
},
// Timing
resizeTimeout: 100, // ms
};
// ============================================================================
// PARTICLES (3D Background)
// ============================================================================
export const particles = {
count: 400,
spreadArea: 20,
size: 0.1,
velocityRange: 0.01,
// Opacity
darkOpacity: 0.6,
lightOpacity: 0.4,
// Motion
waveAmplitude: 0.001,
waveFrequency: 0.1,
boundary: 10, // wrap boundary
rotationSpeedY: 0.05,
rotationSpeedX: 0.02,
};
// ============================================================================
// KEYBOARD SHORTCUTS
// ============================================================================
export const keyboardShortcuts = {
skip: ['y', 'Y'], // Skip typing animation
toggleTheme: ['t', 'T'], // Toggle dark/light mode
navigateUp: ['ArrowUp', 'k'],
navigateDown: ['ArrowDown', 'j'],
select: ['Enter'],
};
-89
View File
@@ -1,89 +0,0 @@
// ============================================================================
// COLOR PALETTE (DEPRECATED)
// ============================================================================
// NOTE: This is deprecated. Use themeColors.colorMap from $lib/stores/theme instead.
// The colorMap in theme JSON files is the source of truth and supports dynamic themes.
// This is kept for backward compatibility only.
export const colorPalette = {
// Basic colors
red: '#f38ba8',
green: '#a6e3a1',
yellow: '#f9e2af',
blue: '#89b4fa',
magenta: '#cba6f7',
cyan: '#94e2d5',
white: '#cdd6f4',
gray: '#6c7086',
orange: '#fab387',
pink: '#f5c2e7',
black: '#1e1e2e',
surface: '#313244',
// Semantic colors (map to basic colors by default)
error: '#f38ba8', // red
success: '#a6e3a1', // green
warning: '#f9e2af', // yellow
info: '#89b4fa', // blue
};
// ============================================================================
// TERMINAL WINDOW BUTTONS (traffic lights)
// ============================================================================
export const terminalButtons = {
close: '#ff5f56',
minimize: '#ffbd2e',
maximize: '#27ca40',
};
// ============================================================================
// LOADING SCREEN
// ============================================================================
export const loadingScreen = {
background: '#0d1117',
textColor: '#c9d1d9',
cursorColor: '#1793d1',
text: "Blob's Portfolio",
};
// ============================================================================
// EFFECTS
// ============================================================================
export const effects = {
backdropBlur: 10, // px
selectionBackground: 'rgba(23, 147, 209, 0.3)',
};
// ============================================================================
// ANIMATIONS
// ============================================================================
export const animations = {
// Terminal/TUI animations
terminalFadeIn: { duration: '0.4s', easing: 'ease-out' },
tuiFadeIn: { duration: '0.4s', easing: 'ease-out' },
lineSlideIn: { duration: '0.15s', easing: 'ease-out' },
cursorBlink: { duration: '1s' },
borderGlow: { duration: '8s', transition: '0.3s' },
// Button/interaction animations
buttonHover: { duration: '0.2s' },
buttonTransition: { duration: '0.15s' },
// Navigation animations
hamburgerTransition: { duration: '0.3s' },
navLinkTransition: { duration: '0.2s' },
linkUnderline: { duration: '0.3s' },
dropdown: { flyDuration: 200, fadeDuration: 150 }, // ms
// Model viewer
modelViewerTransition: { duration: '0.3s' },
spin: { duration: '1s' },
// General
pulse: { duration: '2s' },
float: { duration: '3s' },
};
-60
View File
@@ -1,60 +0,0 @@
// ============================================================================
// USER PROFILE
// ============================================================================
export const user = {
name: 'Gagan "Adith" M',
displayname: 'Sir Blob',
username: 'sirblob',
hostname: 'engineering',
title: 'Engineering Student',
email: 'sirblob0@gmail.com',
location: 'USA (EAST)',
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.`,
// Prefer an absolute avatar URL if you want to pull directly from GitHub
avatar: '/blob_nerd.png',
// Social links - array of { name, icon (Iconify), link }
socials: [
{ name: 'GitHub', icon: 'mdi:github', link: 'https://github.com/SirBlobby' },
{ name: 'LinkedIn', icon: 'mdi:linkedin', link: 'https://www.linkedin.com/in/gmanjunatha/' },
{ name: 'Devpost', icon: 'simple-icons:devpost', link: 'https://devpost.com/Sir_Blob_' },
{ name: 'Discord', icon: 'ic:baseline-discord', link: 'https://discord.com/users/sir_blob_' }
]
};
// ============================================================================
// LINKS PAGE
// ============================================================================
export interface LinkItem {
name: string;
icon: string;
link: string;
}
export const links: LinkItem[] = [
{ name: 'GitHub', icon: 'mdi:github', link: 'https://github.com/SirBlobby' },
{ name: 'LinkedIn', icon: 'mdi:linkedin', link: 'https://www.linkedin.com/in/gmanjunatha/' },
{ name: 'Devpost', icon: 'simple-icons:devpost', link: 'https://devpost.com/Sir_Blob_' },
{ name: 'Discord', icon: 'ic:baseline-discord', link: 'https://discord.com/users/sir_blob_' },
{ name: 'Email', icon: 'mdi:email', link: `mailto:${user.email}` },
// { name: 'Blog', icon: 'mdi:rss', link: 'https://blog.sirblob.co' }
];
// ============================================================================
// SKILLS
// ============================================================================
export const skills = {
languages: ['Python', 'JavaScript', 'TypeScript', 'C', 'C++', 'Java', 'Node.js', 'Rust', 'Go'],
frameworks: ['React', 'Electron', 'Svelte', 'Bootstrap', 'TailwindCSS', 'Discord.js', ],
applications: ['IntelliJ', 'VS Code', 'Git', 'Blender', 'Godot'],
platforms: ['Windows', 'Linux', 'macOS', 'Arduino', 'Raspberry Pi'],
tools: ['Git', 'Docker', 'Neovim', 'VS Code'],
databases: ['MongoDB', 'SQLite'],
interests: ['Open Source', 'Game Development', 'Embedded Systems', 'AI/ML']
};
Loaded 100 of 197 files, more files were not shown because too many files have changed in this diff. Show more