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

+3
View File
@@ -0,0 +1,3 @@
DISCORD_TOKEN=
DISCORD_USER_ID=
DISCORD_GUILD_ID=
+3 -1
View File
@@ -24,4 +24,6 @@ vite.config.ts.timestamp-*
bun.lock bun.lock
Update.md Update.md
blogs/ PROJECT.md
content/blog/
blogs/
+6
View File
@@ -0,0 +1,6 @@
{
"recommendations": [
"svelte.svelte-vscode",
"bradlc.vscode-tailwindcss"
]
}
+5
View File
@@ -0,0 +1,5 @@
{
"files.associations": {
"*.css": "tailwindcss"
}
}
+103 -732
View File
@@ -1,778 +1,149 @@
# Terminal Portfolio # Sir Blob's Desktop
An Arch Linux terminal-themed portfolio website with Hyprland-style TUI components, built with SvelteKit, Tailwind CSS, and Three.js. A portfolio website that recreates my Arch Linux desktop (Hyprland, Quickshell bar, kitty terminals) in the browser. Built with SvelteKit 3, Svelte 5 runes and Tailwind CSS 4.
## Features ## Running
- Hyprland-style TUI - Terminal interface inspired by Textual Python TUI ```bash
- Interactive command line - Type commands directly into the terminal to navigate and run actions bun install
- Theme Support - Arch, Catppuccin, Wintry, Rose, and Cerberus themes, each with dark/light variants bun run dev
- Dark/Light Mode - Toggle between dark and light modes bun run build
- Keyboard Navigation - Navigate with arrow keys or vim-style j/k bun run check
- Native Blog - Markdown posts with tags, authors, syntax highlighting, search, pagination, and an RSS feed
- 3D Model Viewer - Interactive Three.js viewer for .glb models
- QR Links Page - Generate a scannable QR code for any of your links
- Configurable Speed - Per-page typing animation speed
- Responsive - Works on desktop and mobile
- Rich Text Formatting - Colors, backgrounds, text decorations, and sizeable inline icons
## Pages
- Home (`/`) - Neofetch-style intro with navigation
- About (`/about`) - Background and details
- Portfolio (`/portfolio`) - Profile, experience, skills, and contact info
- Projects (`/projects`) - Hub linking out to the three project categories below
- Open Source (`/projects/opensource`) - Projects built and maintained in the open
- Packages (`/projects/packages`) - Published packages and CLI tools
- Hackathons (`/projects/hackathons`) - Hackathon projects, awards, and stats
- Blog (`/blog`) - Native markdown blog with tags, search, pagination, and an RSS feed at `/blog/rss.xml`
- Models (`/models`) - 3D model gallery with interactive viewer
- Components (`/components`) - Showcase of all TUI components
- Links (`/links`) - All links with on-demand QR codes
The navbar exposes Home, About, Projects, and Blog. Portfolio, Models, Components, and Links are reachable directly by URL or with the terminal `cd` command (for example, `cd links`).
## Terminal Commands
Every terminal page has an interactive command line at the bottom. Click the terminal (or just start typing once focused), enter a command, and press Enter. Output is printed back into the terminal.
Use the Up/Down arrows to cycle through command history.
| Command | Aliases | Description |
|---------|---------|-------------|
| `help` | `h`, `?` | List all available commands |
| `clear` | `cls` | Clear the terminal |
| `whoami` | | Show a short bio |
| `neofetch` | `banner` | Display system info with profile picture |
| `skills` | | List skills by category |
| `contact` | `socials` | Show social links as buttons |
| `email` | | Show contact email |
| `cd <page>` | `goto`, `open` | Navigate to a page, including subpages (e.g. `cd projects/opensource`) |
| `ls` | `dir` | List the pages you can visit as a tree, with project subpages nested under `projects/` |
| `theme [name]` | | List or set the color theme |
| `mode [dark\|light]` | | Toggle or set dark/light mode |
| `echo <text>` | | Print text back |
| `cowsay <text>` | | Make a cow say something |
| `date` | | Show the current date and time (Eastern) |
Command definitions live in `src/lib/components/tui/terminal-commands.ts`. Add a new entry to the `commands` list to extend the command set. Pages and their subpages are declared once in the `pageTree` structure in that file, which both `ls` (tree rendering) and `cd` (nested path resolution) read from.
## Configuration
The site configuration is modular - split into focused files in `src/lib/config/` for easier maintenance. You can still import everything from `$lib/config` for backward compatibility.
### Config File Structure
```
src/lib/config/
├── index.ts # Barrel export (re-exports all modules)
├── user.ts # User profile, socials, links, skills
├── layout.ts # Layout dimensions, breakpoints, fonts, navbar, scrollbar
├── theme.ts # Colors, animations, effects, loading screen
├── content.ts # Projects, 3D models, experience, hackathon cards
├── terminal.ts # Terminal settings, TUI styling, speed presets, shortcuts
├── navigation.ts # Navigation links, site metadata, page meta
└── blog.ts # Blog name and tagline shown on /blog
``` ```
### Import Examples ## How the desktop works
```typescript - The top bar mirrors the blob-shell Quickshell bar: chai menu (app launcher), workspaces 1-9, clock, Discord status and theme picker.
// Barrel import (backward compatible) - Every page (`/about`, `/projects/hackathons`, ...) opens as a tiled window, so old resume links still work.
import { user, colorPalette, projects } from '$lib/config'; - Terminals open next to it and tile with the Hyprland dwindle layout.
- Desktop icons open a page, a terminal running a command, or an external link.
- On small screens the focused window goes fullscreen and open windows are listed as tabs.
// Direct imports (smaller bundles, faster builds) ### Keybinds
import { user, skills, links } from '$lib/config/user';
import { colorPalette, animations } from '$lib/config/theme';
import { openSourceProjects, cards, experience } from '$lib/config/content';
import { terminalSettings, keyboardShortcuts } from '$lib/config/terminal';
import { navigation, site, pageMeta } from '$lib/config/navigation';
```
### Config Modules | Keys | Action |
| --- | --- |
| Alt + Enter | Open a kitty terminal |
| Alt + Space | App launcher |
| Alt + Q | Close focused window |
| Alt + F | Fullscreen the page window |
| Alt + J / K | Focus next / previous window |
| Alt + 1-9 | Switch workspace |
| Alt + T | Next theme |
Alt stands in for Super, which browsers do not receive.
## Editing content
Everything you would normally change lives in `content/`. No code changes needed.
| File | Contents | | File | Contents |
|------|----------| | --- | --- |
| `user.ts` | `user`, `skills`, `links` | | `content/profile.json` | name (shown only on the portfolio page), bio, avatar, education, socials (also used by the links page), skills, interests |
| `layout.ts` | `layout`, `breakpoints`, `fonts`, `navbar`, `scrollbar` | | `content/experience.json` | experience with highlights, tech, awards and links |
| `theme.ts` | `colorPalette`, `terminalButtons`, `loadingScreen`, `effects`, `animations` | | `content/leadership.json` | leadership roles |
| `content.ts` | `openSourceProjects`, `packageProjects`, `models`, `experience`, `cards`, `sortedCards` + types | | `content/about.json` | hobbies, games, shows, movies |
| `terminal.ts` | `terminalSettings`, `tuiStyle`, `tuiText`, `pageSpeedSettings`, `pageAutoscrollSettings`, `speedPresets`, `modelViewer`, `particles`, `keyboardShortcuts` | | `content/projects/opensource.json` | open source projects |
| `navigation.ts` | `navigation`, `site`, `pageMeta` | | `content/projects/packages.json` | published packages |
| `blog.ts` | `blogName`, `blogTagline` | | `content/projects/hackathons.json` | hackathon projects, events and awards |
| `content/projects/research.json` | research roles and projects |
| `content/models.json` | 3D models (files go in `static/models/`) |
| `content/desktop.json` | default theme, workspaces, desktop icons, startup command |
| `content/blog.json` | blog name, tagline, posts per page |
| `content/blog/*.md` | blog posts |
### Example: Key config snippets ### Projects
```typescript Every project in every category uses the same shape. Only `name`, `description`, `tags` and `links` are required.
// Toggle theme keys and other 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'],
};
// Terminal / typing ```json
export const terminalSettings = { {
baseTypeSpeed: 20, "name": "HushMap",
minTypeSpeed: 5, "description": "What it does.",
maxTypeSpeed: 50, "image": "/hacks/hushmap.png",
startDelay: 300, "tags": ["Svelte", "FastAPI"],
lineDelay: 100, "featured": true,
showCursor: true, "event": { "name": "Bitcamp 2026", "university": "University of Maryland", "location": "College Park, MD", "year": "2026" },
promptStyle: 'full', "awards": [{ "track": "Best UI/UX Hack", "place": "Winner" }],
scrollMargin: 80, "demoMayBeOffline": false,
}; "version": "2.2.0",
"links": { "demo": "https://...", "code": "https://...", "devpost": "https://...", "package": "https://...", "article": "https://..." }
// Color palette (Catppuccin Mocha by default) }
export const colorPalette = {
red: '#f38ba8',
green: '#a6e3a1',
yellow: '#f9e2af',
blue: '#89b4fa',
magenta: '#cba6f7',
cyan: '#94e2d5',
white: '#cdd6f4',
gray: '#6c7086',
error: '#f38ba8',
success: '#a6e3a1',
};
``` ```
### How to Customize Featured projects are listed first.
- Edit `config/user.ts` to update your profile, socials, links, and skills ### Desktop icons
- Edit `config/content.ts` to add projects, models, experience, or hackathon entries
- Edit `config/theme.ts` to change colors, animations, or loading screen
- Edit `config/terminal.ts` to adjust typing speed, TUI styling, or shortcuts
- Edit `config/layout.ts` to change dimensions, breakpoints, or navbar settings
- Edit `config/navigation.ts` to add/remove nav links or update page metadata
Changes take effect on next reload. Some values (fonts, CSS variables) may also require adjusting CSS or Tailwind config. Each icon in `content/desktop.json` has a `label`, an Iconify `icon` and one `open` action:
### Where to Look for Types & Utilities ```json
{ "label": "about", "icon": "mdi:account", "open": { "page": "/about" } }
- `src/lib/config/` - All configuration modules { "label": "skills.sh", "icon": "mdi:script-text", "open": { "terminal": "skills" } }
- `src/lib/components/tui/types.ts` - TerminalLine types { "label": "github", "icon": "mdi:github", "open": { "url": "https://github.com/SirBlobby" } }
- `src/lib/components/tui/utils.ts` - Parsing utilities and style helpers
- `src/lib/components/tui/terminal-commands.ts` - Interactive command definitions
- `src/lib/stores/theme.ts` - Theme store & `toggleMode()`
- `src/lib/index.ts` - Helper functions (barrel export)
## Links Page
The `/links` page lists everything in the `links` array (a dedicated list, separate from `user.socials`) and renders a scannable QR code for whichever link is selected, plus a clickable button to open it.
Add or remove entries in `src/lib/config/user.ts`:
```typescript
export const links: LinkItem[] = [
{ name: 'GitHub', icon: 'mdi:github', link: 'https://github.com/SirBlobby' },
{ name: 'Email', icon: 'mdi:email', link: `mailto:${user.email}` },
// Add page-only links here without touching user.socials
];
``` ```
QR codes are generated with the `svelte-qrcode` package, loaded on the client. `{ "terminal": "" }` opens an empty terminal.
## Blog ### Blog posts
The `/blog` page is a native markdown blog — no external blogging platform required. Add a markdown file to `content/blog/`. The file name is the URL slug.
### Writing a Post
Add a markdown file to the `blogs/` directory at the project root:
```markdown ```markdown
--- ---
title: My Post Title title: My Post Title
author: Your Name author: Sir Blob
date: 2026-01-01 date: 2026-01-01
tags: meta, notes tags: meta, notes
excerpt: A one line summary shown on the blog list and in the RSS feed. excerpt: One line summary for the list and the RSS feed.
--- ---
Post content goes here, written in markdown. Post content in markdown.
``` ```
Posts are picked up automatically via `import.meta.glob` in `src/lib/blog/posts.ts` — no manual registration needed. `author` and `date` are optional; `author` falls back to `user.displayname`. The RSS feed is at `/blog/rss.xml`.
### Rendering ## Themes
`src/lib/blog/markdown.ts` is a small hand-written markdown-to-HTML renderer (headings, bold/italic, links, images, lists, blockquotes, and fenced code blocks) — no external markdown dependency. Fenced code blocks with a language tag (` ```js `, ` ```python `, etc.) get lightweight syntax highlighting via `src/lib/blog/highlight.ts`, and every code block gets a copy-to-clipboard button. Themes live in `src/lib/theme/themes.ts` and use the same keys as a blomarchy `colors.toml`, so a theme can be copied over from the dotfiles. Each theme can set a `wallpaper` (from `static/wallpapers/`) and an `activeBorder` gradient. The default theme is set in `content/desktop.json`.
Dates are parsed and formatted in Eastern time via `src/lib/blog/date.ts`, anchored at noon UTC to avoid off-by-one day shifts from timezone conversion. Shipped themes: Flats (default), Retro 82, Tokyo Night, Catppuccin, Arch Linux, Gruvbox, Nord, Kanagawa, Wintry, Rose, Cerberus, Catppuccin Latte, Rose Pine Dawn.
### Features ## Terminal commands
- Tags with click-to-filter on the blog list `help`, `whoami`, `fastfetch`, `skills`, `contact`, `email`, `experience`, `projects [category]`, `ls`, `cd <page>`, `theme [name]`, `echo`, `cowsay`, `date`, `clear`, `exit`. Page names (`about`, `hackathons`, ...) and social names (`github`, `linkedin`, ...) also work as commands. Tab completes commands, pages after `cd`, themes after `theme` and categories after `projects`; when several match it lists them like bash. Up/Down walk history, Ctrl+L clears.
- Full-text search across title, excerpt, and tags
- Pagination (5 posts per page)
- Previous/next post navigation on each post page
- RSS feed at `/blog/rss.xml` (`src/routes/blog/rss.xml/+server.ts`)
## Speed Presets Commands live in `src/lib/terminal/commands/`.
| Preset | Effect | ## Code layout
|--------|--------|
| `instant` | No animation, appears immediately |
| `fast` | 3x faster than normal |
| `normal` | Default typing speed |
| `slow` | 2x slower than normal |
| `typewriter` | 3x slower, classic feel |
## Text Formatting | Path | Contents |
| --- | --- |
| `src/lib/content/` | typed loader for `content/` |
| `src/lib/blog/` | markdown renderer, frontmatter, highlighting, dates |
| `src/lib/theme/` | theme palettes, stylesheet generation, current theme state |
| `src/lib/desktop/` | window manager, dwindle tiling, keybinds, desktop actions |
| `src/lib/terminal/` | terminal output model and commands |
| `src/lib/site/pages.ts` | page list used by the terminal and launcher |
| `src/lib/components/shell/` | bar, workspaces, clock, theme menu, Discord status |
| `src/lib/server/discord.ts` | Discord bot and presence lookup |
| `src/lib/components/desktop/` | desktop, windows, icons, launcher |
| `src/lib/components/terminal/` | kitty terminal |
| `src/lib/components/ui/` | shared page pieces |
| `src/routes/` | pages |
Use inline formatting with the `(&specs)text(&)` syntax: ## Discord status
### Colors The bar shows live Discord presence (status, avatar, guild tag, activities). A discord.js bot starts with the server (`src/hooks.server.ts`) and the bar polls `/api/discord` every 30 seconds. Copy `.env.example` to `.env` and fill in:
```typescript | Variable | Value |
// Basic colors | --- | --- |
'(&red)Red text(&)' | `DISCORD_TOKEN` | bot token, with the Server Members and Presence intents enabled |
'(&green)Green text(&)' | `DISCORD_USER_ID` | your user id |
'(&blue)Blue text(&)' | `DISCORD_GUILD_ID` | a server both you and the bot are in |
'(&yellow)Yellow text(&)'
'(&magenta)Magenta text(&)'
'(&cyan)Cyan text(&)'
'(&orange)Orange text(&)'
'(&pink)Pink text(&)'
'(&gray)Gray text(&)'
'(&white)White text(&)'
// Semantic colors (theme-aware) Variables are declared in `src/env.ts`. Without them the widget shows your profile name as offline.
'(&primary)Primary color(&)'
'(&accent)Accent color(&)'
'(&muted)Muted text(&)'
'(&error)Error text(&)'
'(&success)Success text(&)'
'(&warning)Warning text(&)'
'(&info)Info text(&)'
// Custom hex colors ## Analytics
'(&#ff6b6b)Custom color(&)'
```
### Background Colors Umami is loaded in `src/app.html`.
Add `bg-` prefix to any color:
```typescript
'(&bg-red)Red background(&)'
'(&bg-blue,white)Blue bg with white text(&)'
'(&bg-surface)Surface background(&)'
'(&bg-#333333)Custom bg color(&)'
```
### Text Styles
```typescript
'(&bold)Bold text(&)'
'(&italic)Italic text(&)'
'(&dim)Dimmed text (60% opacity)(&)'
'(&underline)Underlined text(&)'
'(&strikethrough)Strikethrough text(&)'
'(&strike)Strikethrough shorthand(&)'
'(&overline)Overlined text(&)'
```
### Inline Icons
```typescript
'(&icon, mdi:github) GitHub' // Inline icon, default size
'(&icon, mdi:trophy, 32) Winner' // Inline icon with a custom size in pixels
```
### Combining Styles
Combine multiple styles with commas:
```typescript
'(&bold,red)Bold red text(&)'
'(&italic,cyan,underline)Italic cyan underlined(&)'
'(&bg-blue,white,bold)Bold white on blue(&)'
'(&dim,strikethrough,gray)Dim gray strikethrough(&)'
```
## Line Types
### Basic Lines
```typescript
const lines: TerminalLine[] = [
{ type: 'command', content: 'ls -la' }, // With prompt prefix
{ type: 'output', content: 'File listing...' }, // Muted text
{ type: 'error', content: 'Error message' }, // Red with prefix
{ type: 'success', content: 'Success!' }, // Green with prefix
{ type: 'info', content: 'Information' }, // Primary with prefix
{ type: 'header', content: 'Section Title' }, // Bold heading
{ type: 'blank', content: '' }, // Empty line
{ type: 'divider', content: 'SECTION', id: 'section' }, // Horizontal divider with anchor ID
];
```
### Line Properties
All line types support these optional properties:
```typescript
{
type: 'output',
content: 'Hello world',
id: 'my-section', // Anchor ID for URL hash scrolling (e.g., /page#my-section)
inline: true, // Render inline with adjacent inline elements
delay: 500, // Delay before this line appears (ms)
}
```
### Button (Full-width interactive)
```typescript
{
type: 'button',
content: 'Click me',
icon: 'mdi:github', // Iconify icon
style: 'primary', // primary | accent | warning | error
href: 'https://github.com', // URL to navigate to
external: true, // Open in new tab (auto-detected for http/https)
inline: true, // Render as compact inline button
// OR
action: () => doSomething(), // Custom action
border: false, // Disable default border (default: true)
flex: true, // Grow to fill available space in a row group (default: false)
}
```
### Inline Elements
Multiple elements can be rendered on the same line using `inline: true`:
```typescript
// These will appear on the same line
{ type: 'output', content: 'Status:', inline: true },
{ type: 'success', content: 'Online', inline: true },
{ type: 'button', content: 'Refresh', icon: 'mdi:refresh', inline: true },
// Next line without inline breaks the group
{ type: 'blank', content: '' },
```
Supported inline types: `button`, `link`, `tooltip`, `progress`, `output`, `info`, `success`, `error`, `warning`
### Link (Inline clickable text)
```typescript
{
type: 'link',
content: 'Visit GitHub',
icon: 'mdi:github', // Optional icon
style: 'accent', // Styling
href: 'https://github.com',
external: true, // Open in new tab (auto-detected for http/https)
}
```
### Image
```typescript
{
type: 'image',
content: 'Caption text', // Optional caption
image: '/path/to/image.png',
imageAlt: 'Alt text',
imageWidth: 300, // Max width in pixels
}
```
### Card
```typescript
{
type: 'card',
content: 'Card body text with (&bold)formatting(&) support',
cardTitle: 'Card Title', // Optional header
cardFooter: 'Footer text', // Optional footer
icon: 'mdi:star', // Optional header icon
image: '/path/to/image.png', // Optional card image
style: 'primary', // Border accent color
display: 'flex', // flex | grid | block (controls children layout)
children: [ // Optional nested elements
{ type: 'button', content: 'Action', style: 'primary' }
],
cardWidth: '1/2', // Width (string fraction, decimal, or px)
cardHeight: 300, // Height (string fraction, decimal, or px)
cardFloat: 'center', // start | center | end
}
```
### Progress Bar
```typescript
{
type: 'progress',
content: 'Loading assets...', // Label above bar
progress: 75, // 0-100 percentage
progressLabel: '75%', // Custom label (defaults to percentage)
style: 'accent', // Bar color
}
```
### Accordion
```typescript
{
type: 'accordion',
content: '',
accordionItems: [
{ title: 'Section 1', content: 'Content for section 1 with (&cyan)colors(&)' },
{ title: 'Section 2', content: 'Content for section 2' },
],
accordionOpen: true, // First item open by default
style: 'primary', // Accent color
}
```
### Table
```typescript
{
type: 'table',
content: 'Table Title', // Optional title
tableHeaders: ['Name', 'Role', 'Status'],
tableRows: [
['Alice', 'Developer', '(&success)Active(&)'],
['Bob', 'Designer', '(&warning)Away(&)'],
],
style: 'accent', // Header color
}
```
### Tooltip
```typescript
{
type: 'tooltip',
content: 'Hover me', // Trigger text
tooltipText: 'This is helpful information!',
tooltipPosition: 'top', // top | bottom | left | right
style: 'info', // Tooltip border color
}
```
### Group
Groups allow you to arrange multiple elements together with custom layout:
```typescript
{
type: 'group',
content: '',
groupDirection: 'row', // row | column (default: row)
groupAlign: 'start', // start | center | end
groupGap: '1rem', // CSS gap value
groupExpand: true, // Expand children to fill width (default: false)
inline: true, // Render inline with other elements
children: [
{ type: 'output', content: 'Label:', inline: true },
{ type: 'button', content: 'Action', style: 'primary', inline: true },
{ type: 'link', content: 'More info', href: '/help', inline: true }
]
}
```
Groups support nested groups and all element types as children. Children are rendered using the same `TuiLine` component, ensuring consistent behavior.
## TUI Components
### TerminalTUI
Main terminal component:
```svelte
<TerminalTUI
lines={lines}
title="~/directory"
interactive={true}
speed="normal"
autoscroll={true}
enableCommands={true}
onComplete={() => console.log('Done!')}
/>
```
Props:
- `lines` - Array of TerminalLine objects
- `title` - Terminal window title
- `interactive` - Enable keyboard navigation
- `speed` - Typing speed preset or multiplier
- `autoscroll` - Auto-scroll as content types (default: true)
- `enableCommands` - Show the interactive command input (default: true)
- `onComplete` - Callback when typing animation finishes
### Anchor Scrolling
Add `id` to any line to create an anchor that can be linked to:
```typescript
{ type: 'divider', content: 'SKILLS', id: 'skills' },
```
Then link to it with `/portfolio#skills` - the page will scroll to that section after typing completes.
## Terminal API
The `TerminalTUI` component exposes a reactive API for programmatic control via the `terminal` bindable prop.
### Setup
```svelte
<script lang="ts">
import TerminalTUI from '$lib/components/TerminalTUI.svelte';
import type { TerminalAPI, TerminalLine } from '$lib';
let terminal = $state<TerminalAPI>();
let lines = $state<TerminalLine[]>([
{ type: 'output', content: 'Hello world!' }
]);
</script>
<TerminalTUI bind:terminal bind:lines title="~/demo" />
```
### API Methods
#### Writing Lines
| Method | Description |
|--------|-------------|
| `terminal.write(line)` | Append a single line |
| `terminal.writeLines(lines)` | Append multiple lines |
| `terminal.clear()` | Remove all lines |
| `terminal.setLines(lines)` | Replace all lines |
#### Updating Lines
| Method | Description |
|--------|-------------|
| `terminal.update(index, updates)` | Update line by index with partial changes |
| `terminal.updateContent(index, content)` | Update just the content of a line |
| `terminal.updateById(id, updates)` | Update line by its `id` property |
#### Removing Lines
| Method | Description |
|--------|-------------|
| `terminal.remove(index)` | Remove line at index |
| `terminal.removeRange(start, count)` | Remove a range of lines |
| `terminal.removeById(id)` | Remove line by its `id` property |
#### Inserting Lines
| Method | Description |
|--------|-------------|
| `terminal.insert(index, line)` | Insert line at a specific index |
#### Reading State
| Method | Description |
|--------|-------------|
| `terminal.getLine(index)` | Get line at index |
| `terminal.getLines()` | Get all lines (copy) |
| `terminal.getLineCount()` | Get number of lines |
| `terminal.findById(id)` | Find index of line by id |
| `terminal.isAnimating()` | Check if typing animation is active |
#### Navigation & Control
| Method | Description |
|--------|-------------|
| `terminal.scrollToBottom()` | Scroll to bottom of terminal |
| `terminal.scrollToLine(index)` | Scroll to specific line |
| `terminal.skip()` | Skip current typing animation |
| `terminal.restart()` | Restart typing animation from beginning |
### Example: Dynamic Updates
```svelte
<script lang="ts">
import TerminalTUI from '$lib/components/TerminalTUI.svelte';
import type { TerminalAPI } from '$lib';
let terminal = $state<TerminalAPI>();
let lines = $state([
{ type: 'output', content: 'Initializing...', id: 'status' }
]);
async function runProcess() {
terminal?.updateById('status', {
type: 'info',
content: 'Processing...'
});
await delay(1000);
terminal?.write({
type: 'progress',
content: '',
progress: 50,
progressLabel: '50%'
});
await delay(1000);
terminal?.updateById('status', {
type: 'success',
content: 'Complete!'
});
}
</script>
<TerminalTUI bind:terminal bind:lines />
<button onclick={runProcess}>Start</button>
```
## 3D Model Viewer
The `ModelViewer` component provides an interactive Three.js viewer for `.glb` models.
### Features
- Mouse Controls: Drag to rotate, scroll to zoom
- Arrow Key Controls: Use arrow keys to orbit the camera (click viewer to focus first)
- Auto-rotate: Toggle automatic rotation
- Wireframe Mode: View model wireframe
- Adjustable Lighting: Increase/decrease scene brightness
- Fullscreen Mode: Expand to full viewport (press Escape to exit)
- Ground Plane: Optional shadow-receiving ground
- Graceful Fallback: Shows an error message when WebGL is unavailable instead of crashing
### Usage
```svelte
<ModelViewer
modelPath="/models/my-model.glb"
modelName="My Model"
/>
```
Place `.glb` files in `/static/models/` and they'll be accessible at `/models/filename.glb`.
## Tech Stack
- Framework: SvelteKit 2.x with Svelte 5 runes
- Styling: Tailwind CSS 4.x
- 3D: Three.js with GLTFLoader
- QR codes: svelte-qrcode
- Icons: @iconify/svelte
- Font: JetBrains Mono
- Runtime: Bun
## Development
```bash
# Install dependencies
bun install
# Start dev server
bun run dev
# Build for production
bun run build
# Type-check
bun run check
```
## Keyboard Shortcuts
| Key | Action |
|-----|--------|
| Up / k | Navigate up |
| Down / j | Navigate down |
| Enter | Activate button |
| Y | Skip typing animation |
| T | Toggle dark/light mode |
When the command input is focused, these keys type normally instead of triggering navigation.
### 3D Model Viewer
| Key | Action |
|-----|--------|
| Left | Rotate camera left |
| Right | Rotate camera right |
| Up | Rotate camera up |
| Down | Rotate camera down |
| Escape | Exit fullscreen |
## Theme System
Themes are defined as JSON files in `src/lib/assets/themes/`. Each theme contains colors for both dark and light modes. Shipped themes: Arch, Catppuccin, Wintry, Rose, and Cerberus (`src/lib/stores/theme.ts`).
### Theme File Structure
```json
{
"name": "Theme Name",
"icon": "arch",
"dark": {
"colors": {
"primary": "#89b4fa",
"secondary": "#313244",
"accent": "#a6e3a1",
"background": "#1e1e2e",
"backgroundLight": "#313244",
"text": "#cdd6f4",
"textMuted": "#a6adc8",
"border": "#45475a",
"terminal": "#1e1e2e",
"terminalPrompt": "#cba6f7",
"terminalUser": "#a6e3a1",
"terminalPath": "#89b4fa"
},
"colorMap": {
"red": "#f38ba8",
"green": "#a6e3a1",
"blue": "#89b4fa",
"primary": "var(--terminal-primary)",
"accent": "var(--terminal-accent)",
"muted": "var(--terminal-muted)"
}
},
"light": {
"colors": { },
"colorMap": { }
}
}
```
### Adding a New Theme
1. Create a new file: `src/lib/assets/themes/mytheme.theme.json`
2. Import it in `src/lib/stores/theme.ts`:
```typescript
import myTheme from '$lib/assets/themes/mytheme.theme.json';
```
3. Add it to the themes object:
```typescript
const themes: Record<ColorTheme, ThemeJson> = {
arch: archTheme,
catppuccin: catppuccinTheme,
mytheme: myTheme as ThemeJson
};
```
4. Update the `ColorTheme` type to include your theme name
### Theme-Specific Colors
Beyond the basic colors, themes include:
- `teal`, `sky`, `sapphire`, `lavender`
- `peach`, `maroon`, `mauve`
- `flamingo`, `rosewater`
```typescript
// These colors adapt to the current theme
'(&teal)Teal text(&)'
'(&lavender)Lavender text(&)'
'(&peach)Peach text(&)'
```
## Mobile Considerations
- Viewport height: Uses `100dvh` (dynamic viewport height) to properly handle mobile browser chrome
- Background color: A fallback dark background (`#1e1e2e`) is set on `html` and `body` to prevent white bars when the page content doesn't fill the viewport
- Overflow handling: Hidden horizontal scrollbar to prevent accidental horizontal scroll on mobile
+28
View File
@@ -0,0 +1,28 @@
{
"hobbies": [
{ "name": "Swimming", "icon": "proicons:person-swim", "description": "I enjoy swimming as a way to relax and stay active. It is my favorite sport." },
{ "name": "Baking", "icon": "proicons:cake", "description": "I love baking pastries and experimenting with new dessert recipes." },
{ "name": "Robotics", "icon": "proicons:robot", "description": "Building and programming robots to solve problems is fun." }
],
"games": [
{ "name": "Minecraft", "image": "/img/minecraft.png", "url": "https://minecraft.net" },
{ "name": "Pokemon TCG Online", "image": "/img/pokemontcg.png", "url": "https://www.pokemon.com/us/pokemon-tcg" },
{ "name": "Fire Watch", "image": "/img/firewatch.png", "url": "https://firewatchgame.com" },
{ "name": "Expedition 33", "image": "/img/e33.png", "url": "https://expedition33.com" },
{ "name": "Mini Motorways", "image": "/img/mini.png", "url": "https://store.steampowered.com/app/1127500/Mini_Motorways/" },
{ "name": "Risk", "image": "/img/risk.png", "url": "https://store.steampowered.com/app/1128810/RISK_Global_Domination/" },
{ "name": "Slay the Spire", "image": "/img/slay_the_spire.png", "url": "https://store.steampowered.com/app/646570/Slay_the_Spire/" },
{ "name": "Slay the Spire 2", "image": "/img/slay_the_spire_2.png", "url": "https://store.steampowered.com/app/2868840/Slay_the_Spire_2/" }
],
"shows": [
{ "name": "Silo", "rating": 10, "url": "https://www.imdb.com/title/tt14688458/" },
{ "name": "D.P. (KDrama)", "rating": 10, "url": "https://www.imdb.com/title/tt13024974/" },
{ "name": "Andor", "rating": 9, "url": "https://www.imdb.com/title/tt9253284/" },
{ "name": "Severance", "rating": 9, "url": "https://www.imdb.com/title/tt11280740/" },
{ "name": "The Mandalorian", "rating": 7, "url": "https://www.imdb.com/title/tt8111088/" }
],
"movies": [
{ "name": "Interstellar", "rating": 10, "url": "https://www.imdb.com/title/tt0816692/" },
{ "name": "How to Train Your Dragon", "rating": 10, "url": "https://www.imdb.com/title/tt1621800/" }
]
}
+5
View File
@@ -0,0 +1,5 @@
{
"name": "Finding Out",
"tagline": "My Engineering Blog",
"postsPerPage": 5
}
+28
View File
@@ -0,0 +1,28 @@
{
"defaultTheme": "flats",
"workspaces": [
{ "number": 1, "name": "home", "path": "/" },
{ "number": 2, "name": "about", "path": "/about" },
{ "number": 3, "name": "projects", "path": "/projects" },
{ "number": 4, "name": "blog", "path": "/blog" },
{ "number": 5, "name": "links", "path": "/links" },
{ "number": 6, "name": "portfolio", "path": "/portfolio" },
{ "number": 7, "name": "models", "path": "/models" }
],
"icons": [
{ "label": "about", "icon": "ph:user-circle-duotone", "open": { "page": "/about" } },
{ "label": "portfolio", "icon": "ph:briefcase-duotone", "open": { "page": "/portfolio" } },
{ "label": "projects", "icon": "ph:folder-open-duotone", "open": { "page": "/projects" } },
{ "label": "hackathons", "icon": "ph:trophy-duotone", "open": { "page": "/projects/hackathons" } },
{ "label": "blog", "icon": "ph:notebook-duotone", "open": { "page": "/blog" } },
{ "label": "models", "icon": "ph:cube-duotone", "open": { "page": "/models" } },
{ "label": "links", "icon": "ph:qr-code-duotone", "open": { "page": "/links" } },
{ "label": "kitty", "icon": "ph:terminal-window-duotone", "open": { "terminal": "" } },
{ "label": "whoami.sh", "icon": "ph:identification-card-duotone", "open": { "terminal": "whoami" } },
{ "label": "fastfetch.sh", "icon": "ph:cpu-duotone", "open": { "terminal": "fastfetch" } },
{ "label": "skills.sh", "icon": "ph:lightning-duotone", "open": { "terminal": "skills" } },
{ "label": "contact.sh", "icon": "ph:envelope-simple-duotone", "open": { "terminal": "contact" } },
{ "label": "github", "icon": "ph:github-logo-duotone", "open": { "url": "https://github.com/SirBlobby" } }
],
"startupCommand": "fastfetch"
}
+54
View File
@@ -0,0 +1,54 @@
[
{
"role": "Undergraduate Research Assistant, CREATE Lab",
"organization": "George Mason University, College of Engineering and Computing",
"period": "May 2026 – Present",
"highlights": [
"Built Astroswarm, a visual coding game that turns swarm control into a playable simulator, generating trial data at scale on which algorithms best defend a target.",
"Researching swarm robotics in Python and ROS for a research program on controlling robot fleets with one operator."
],
"tech": ["Python", "ROS", "Swarm Robotics"],
"links": [
{ "label": "Profile", "url": "https://create.autonomousrobotics.club/team/gagan-manjunatha" },
{ "label": "Astroswarm", "url": "https://astroswarm.autonomousrobotics.club/" },
{ "label": "GMU CEC News", "url": "https://cec.gmu.edu/news/2026-07/new-art-herding-robots" }
]
},
{
"role": "AI Summer Camp Instructor",
"organization": "ACCESS Academy",
"period": "Jul 2026 – Aug 2026",
"highlights": [
"Taught the robotics section of an AI camp for 60 rising 9th to 12th graders, guiding teams to build robot kits and design algorithms that autonomously navigated a maze."
],
"tech": ["Robotics", "Teaching"],
"links": [{ "label": "Program", "url": "https://accessacademy.cehd.gmu.edu/ai-summer-camp" }]
},
{
"role": "Lead Software Engineer",
"organization": "Raytheon Autonomous Vehicle Competition",
"period": "Dec 2025 – May 2026",
"highlights": [
"Beat four competing universities including Virginia Tech.",
"Led development of an autonomous system in Python and ROS2, enabling a UAV to scout and land on a moving UGV in an environment without GPS, validated against ArUco marker detection in Gazebo simulation."
],
"tech": ["Python", "ROS2", "Gazebo", "Computer Vision"],
"awards": ["1st Place Overall Winner"],
"links": [
{ "label": "GMU News", "url": "https://www.gmu.edu/news/2026-05/george-mason-students-fly-first-place-no-gps-no-grade-and-almost-no-problems" },
{ "label": "Video", "url": "https://www.youtube.com/watch?v=J9QkS8nLYy0" }
]
},
{
"role": "QA Automation Intern",
"organization": "Deltek",
"employmentType": "Internship · Hybrid",
"period": "Jun 2025 – Aug 2025",
"highlights": [
"Designed a workflow integrating AI into the QA automation pipeline for the GovWin IQ team.",
"Researched Model Context Protocol servers, publishing a public Selenium MCP package to NPM for agentic browser automation."
],
"tech": ["AI", "TypeScript", "Selenium", "MCP"],
"links": [{ "label": "NPM Package", "url": "https://www.npmjs.com/package/@sirblob/mcp-selenium" }]
}
]
+6
View File
@@ -0,0 +1,6 @@
[
{ "role": "Team Lead", "organization": "GMU RAVC/ARC Team", "url": "https://gmu.autonomousrobotics.club/" },
{ "role": "President", "organization": "GMU Minecraft Club", "url": "https://gmmc.sirblob.co/" },
{ "role": "Eagle Scout", "organization": "BSA" },
{ "role": "Volunteer", "organization": "Fairfax County CERT" }
]
+16
View File
@@ -0,0 +1,16 @@
[
{
"name": "Bake Potato",
"description": "A fun baked-potato 3D model for demos and testing.",
"file": "/models/BakePotato.glb",
"software": ["Blender"],
"polyCount": "1k tris"
},
{
"name": "Soda Can",
"description": "A realistic soda can GLB used for materials/tests.",
"file": "/models/Soda.glb",
"software": ["Blender"],
"polyCount": "800 tris"
}
]
+29
View File
@@ -0,0 +1,29 @@
{
"name": "Gagan Manjunatha",
"displayName": "Sir Blob",
"username": "sirblob",
"hostname": "engineering",
"title": "Computer Science Student",
"email": "sirblob0@gmail.com",
"avatar": "/blob_nerd.png",
"bio": "An engineer who loves making things. I build fun coding projects, compete in hackathons and robotics competitions, and enjoy games like Minecraft and Pokémon TCG Live. I'm interested in Open Source, Robotics, Embedded Systems, and AI/ML.",
"tagline": "My passion is using Computer Science with practical Engineering to enhance technology for the future.",
"education": {
"school": "George Mason University",
"degree": "Bachelor of Science, Computer Science",
"graduation": "Expected June 2028"
},
"socials": [
{ "name": "GitHub", "icon": "mdi:github", "url": "https://github.com/SirBlobby" },
{ "name": "LinkedIn", "icon": "mdi:linkedin", "url": "https://www.linkedin.com/in/gmanjunatha/" },
{ "name": "Devpost", "icon": "simple-icons:devpost", "url": "https://devpost.com/Sir_Blob_" },
{ "name": "Discord", "icon": "ic:baseline-discord", "url": "https://discord.com/users/sir_blob_" },
{ "name": "Email", "icon": "mdi:email", "url": "mailto:sirblob0@gmail.com" }
],
"skills": {
"Languages": ["Python", "TypeScript/JavaScript", "C/C++", "Java", "Go", "Rust", "SQL"],
"Frameworks": ["React/Next.js", "SvelteKit", "Node/Express", "Flask", "Tailwind CSS", "ROS2", "Gazebo"],
"Tools & Data": ["Docker", "Git", "Linux/Windows Server", "PostgreSQL", "MySQL", "SQLite", "MongoDB", "Redis"]
},
"interests": ["Open Source", "Robotics", "Embedded Systems", "Game Development", "AI/ML"]
}
+85
View File
@@ -0,0 +1,85 @@
[
{
"name": "HushMap",
"description": "Architected a real-time campus noise and occupancy monitoring system utilizing SvelteKit, FastAPI, and MongoDB to help students locate quiet study spaces.",
"image": "/hacks/hushmap.png",
"tags": ["Svelte", "YOLOv8", "FastAPI", "MongoDB"],
"featured": true,
"event": { "name": "Bitcamp 2026", "university": "University of Maryland", "location": "College Park, MD", "year": "2026" },
"awards": [{ "track": "Best UI/UX Hack", "place": "Winner" }],
"links": { "demo": "https://hushmap.study/", "devpost": "https://devpost.com/software/hushmap-193nia" }
},
{
"name": "LearningBuddy",
"description": "ESP32-S3 IoT study companion in C++ that streams audio over WebSockets with low latency for live lecture capture, with transcription and MongoDB indexing behind a searchable \"Desk Pet\" dashboard.",
"image": "/hacks/learningbuddy.png",
"tags": ["C++", "ESP32-S3", "WebSocket", "MongoDB", "IoT"],
"featured": true,
"event": { "name": "HackFax x PatriotHacks 2026", "university": "George Mason University", "location": "Fairfax, VA", "year": "2026" },
"awards": [{ "track": "Best use of MongoDB", "place": "Winner" }],
"links": { "code": "https://github.com/SirBlobby/LearningBuddy", "devpost": "https://devpost.com/software/studybuddy-bc0i3j" }
},
{
"name": "Carbin",
"description": "Encourage student participation in responsible waste management with smart bins that guide proper disposal.",
"image": "/hacks/carbin.png",
"tags": ["AI", "Azure", "CloudConvert", "Python", "React", "TypeScript", "API"],
"featured": true,
"event": { "name": "PatriotHacks 2024", "university": "George Mason University", "location": "Fairfax, VA", "year": "2024" },
"awards": [
{ "track": "Save the World", "place": "2nd Place" },
{ "track": "Microsoft X Cloudforce", "place": "2nd Place" }
],
"links": { "code": "https://github.com/SirBlobby/patriotHacks2024", "devpost": "https://devpost.com/software/carboniferous-akc4mj" }
},
{
"name": "Ethix",
"description": "A sustainability platform that helps users detect and report greenwashing, misleading environmental claims made by companies and products.",
"image": "/img/hoya26.png",
"tags": ["Svelte", "TypeScript", "Python", "Flask", "Tailwind", "Sustainability"],
"featured": true,
"event": { "name": "HoyaHax 2026", "university": "Georgetown University", "location": "Washington, D.C.", "year": "2026" },
"links": { "demo": "https://ethix.sirblob.co/", "code": "https://github.com/SirBlobby/Hoya26", "devpost": "https://devpost.com/software/ethix" }
},
{
"name": "Food Decisive",
"description": "Ever felt the indecisiveness of choosing what to eat? Don't fret! Decide your next bite with Food Decisive.",
"image": "/hacks/fooddecisive.png",
"tags": ["AI", "Llama3.1", "Svelte", "Node.js"],
"demoMayBeOffline": true,
"event": { "name": "VTHacks 12", "university": "Virginia Tech", "location": "Blacksburg, VA", "year": "2024" },
"links": { "demo": "https://fooddecisive.sirblob.co/", "code": "https://github.com/SirBlobby/VTHacks-12", "devpost": "https://devpost.com/software/food-decisive" }
},
{
"name": "PatSafe",
"description": "Bridging the gap between doctors and patients for seamless post-discharge care.",
"image": "/hacks/patsafe.png",
"tags": ["Javascript", "Next.js", "React", "TypeScript", "LangChain", "OpenAI"],
"event": { "name": "HoyaHax 2025", "university": "Georgetown University", "location": "Washington, D.C.", "year": "2025" },
"links": { "demo": "https://hoya-hax2025.vercel.app/", "code": "https://github.com/SirBlobby/HoyaHax2025", "devpost": "https://devpost.com/software/patsafe" }
},
{
"name": "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.",
"tags": ["MongoDB", "Next.js", "Python", "React", "Sesame", "Skeleton", "Twilio"],
"event": { "name": "HooHacks 2025", "university": "University of Virginia", "location": "Charlottesville, VA", "year": "2025" },
"links": { "code": "https://github.com/SirBlobby/HooHacks-12", "devpost": "https://devpost.com/software/fauxcall" }
},
{
"name": "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.",
"image": "/hacks/drinkhappy.png",
"tags": ["API", "Auth0", "Gemini", "JavaScript", "MongoDB", "Next.js", "React", "Tailwind", "Vercel"],
"event": { "name": "Bitcamp 2025", "university": "University of Maryland", "location": "College Park, MD", "year": "2025" },
"links": { "demo": "https://drinkhappy.tech", "code": "https://github.com/SirBlobby/Bitcamp-2025", "devpost": "https://devpost.com/software/drink-happy" }
},
{
"name": "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.",
"image": "/hacks/roadcast.png",
"tags": ["React", "Node.js", "Python", "MongoDB", "Geospatial", "Mapbox"],
"demoMayBeOffline": true,
"event": { "name": "VTHacks 13", "university": "Virginia Tech", "location": "Blacksburg, VA", "year": "2025" },
"links": { "demo": "https://roadcast.sirblob.co/", "code": "https://github.com/SirBlobby/roadcast", "devpost": "https://devpost.com/software/roadcast" }
}
]
+36
View File
@@ -0,0 +1,36 @@
[
{
"name": "TypstDrive",
"description": "Self hosted collaborative Typst editor with a Rust backend, SvelteKit frontend, real time multi user editing via Yjs, and a public render API.",
"version": "2.2.0",
"period": "2026 – Present",
"featured": true,
"tags": ["Rust", "SvelteKit", "Yjs", "Typst", "Self-Hosted"],
"image": "/projects/typstdrive.png",
"links": { "code": "https://github.com/SirBlobby/TypstDrive" }
},
{
"name": "Typst Desktop",
"description": "Tauri desktop client for TypstDrive that keeps the same documents as local files and syncs them back with a three way merge.",
"version": "2.2.0",
"period": "2026 – Present",
"featured": true,
"tags": ["Tauri", "Rust", "Typst", "Offline Sync"],
"image": "/projects/typstdesktop.png",
"links": { "code": "https://github.com/SirBlobby/typst-desktop" }
},
{
"name": "Pkit",
"description": "CLI toolkit and utilities (Rust project), a small developer-focused CLI.",
"tags": ["Rust", "CLI"],
"image": "https://icons.veryicon.com/png/o/business/vscode-program-item-icon/rust-1.png",
"links": { "code": "https://github.com/dead-projects-inc/pkit-cli", "demo": "https://pkit.sirblob.co/" }
},
{
"name": "OBookLLM",
"description": "OBookLLM is a powerful, self-hosted, offline-capable alternative to NotebookLLM.",
"tags": ["Python", "LLM", "AI", "NotebookLLM"],
"image": "https://upload.wikimedia.org/wikipedia/commons/c/c3/Python-logo-notext.svg",
"links": { "code": "https://github.com/SirBlobby/OBookLLM" }
}
]
+21
View File
@@ -0,0 +1,21 @@
[
{
"name": "MCP Selenium",
"description": "Selenium automation utilities for MCP workflows, packaged for reuse.",
"tags": ["TypeScript", "Selenium"],
"featured": true,
"links": { "package": "https://www.npmjs.com/package/@sirblob/mcp-selenium" }
},
{
"name": "PokemonTCGAPI",
"description": "Unofficial NPM package wrapper for the PokemonTCG API.",
"tags": ["TypeScript", "Node.js"],
"links": { "package": "https://www.npmjs.com/package/@bosstop/pokemontcgapi" }
},
{
"name": "MCSS TS API",
"description": "TypeScript API for MC Server Soft services, published to NPM.",
"tags": ["TypeScript", "Node.js"],
"links": { "package": "https://www.npmjs.com/package/@mcserversoft/mcss-api" }
}
]
+14
View File
@@ -0,0 +1,14 @@
[
{
"name": "Astroswarm",
"organization": "CREATE Lab, George Mason University",
"period": "May 2026 – Present",
"description": "A visual coding game that turns swarm control into a playable simulator, generating trial data at scale on which algorithms best defend a target. Part of a research program on controlling robot fleets with one operator.",
"tags": ["Python", "ROS", "Swarm Robotics"],
"featured": true,
"links": {
"demo": "https://astroswarm.autonomousrobotics.club/",
"article": "https://cec.gmu.edu/news/2026-07/new-art-herding-robots"
}
}
]
+25 -23
View File
@@ -1,38 +1,40 @@
{ {
"name": "website", "name": "new",
"private": true, "private": true,
"version": "7.0.0", "version": "0.0.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev --host", "dev": "vite dev",
"build": "vite build", "build": "vite build",
"server": "bun server/server.js", "preview": "vite preview",
"start": "vite build && bun server/server.js" "prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-node": "^5.5.7", "@sveltejs/adapter-node": "^6.0.0",
"@sveltejs/kit": "^2.70.1", "@sveltejs/kit": "^3.0.0",
"@sveltejs/vite-plugin-svelte": "^6.2.4", "@sveltejs/vite-plugin-svelte": "^7.2.0",
"@tailwindcss/forms": "^0.5.11", "@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.20", "@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.0",
"svelte": "^5.56.8", "@types/node": "^24",
"svelte-check": "^4.7.4", "svelte": "^5.57.1",
"tailwindcss": "^4.3.3", "svelte-check": "^4.6.0",
"typescript": "^5.9.3", "tailwindcss": "^4.3.0",
"vite": "^7.3.6" "typescript": "^6.0.3",
"vite": "^8.3.0"
}, },
"dependencies": { "dependencies": {
"@iconify/svelte": "^5.2.2", "@iconify/svelte": "^5.2.3",
"@threlte/core": "^8.5.16", "@types/three": "^0.185.4",
"@types/three": "^0.185.1",
"cors": "^2.8.6",
"discord.js": "^14.27.0", "discord.js": "^14.27.0",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"hotkeys-js": "^4.0.4",
"play-dl": "^1.9.7",
"svelte-qrcode": "^1.0.1", "svelte-qrcode": "^1.0.1",
"three": "^0.185.1" "three": "^0.185.1"
},
"imports": {
"#lib": "./src/lib/index.js",
"#lib/*": "./src/lib/*",
"#content/*": "./content/*"
} }
} }
-18
View File
@@ -1,18 +0,0 @@
import { handler } from '../build/handler.js';
import dotenv from 'dotenv';
import http from 'http';
import express from 'express';
import cors from 'cors';
dotenv.config();
const app = express();
const server = http.Server(app);
app.use(cors());
app.use(handler);
server.listen(process.env.PORT, () => {
console.log('listening on port http://localhost:' + process.env.PORT);
});
+7
View File
@@ -3,6 +3,13 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" />
<script>
try {
const savedTheme = localStorage.getItem('theme');
if (savedTheme) document.documentElement.dataset.theme = savedTheme;
} catch {}
</script>
<script defer src="https://umami.sirblob.co/script.js" data-website-id="5cf0aa1f-5585-4a8e-a7eb-b1348932e3aa"></script> <script defer src="https://umami.sirblob.co/script.js" data-website-id="5cf0aa1f-5585-4a8e-a7eb-b1348932e3aa"></script>
%sveltekit.head% %sveltekit.head%
</head> </head>
+18
View File
@@ -0,0 +1,18 @@
import { defineEnvVars } from '@sveltejs/kit/env';
const optional = (value: string | undefined) => value || undefined;
export const variables = defineEnvVars({
DISCORD_TOKEN: {
schema: optional,
description: 'Discord bot token used to read presence'
},
DISCORD_USER_ID: {
schema: optional,
description: 'Discord user whose status is shown in the bar'
},
DISCORD_GUILD_ID: {
schema: optional,
description: 'Guild shared by the bot and the user, needed to read presence'
}
});
+6
View File
@@ -0,0 +1,6 @@
import type { ServerInit } from '@sveltejs/kit';
import { getDiscordClient } from '#lib/server/discord';
export const init: ServerInit = () => {
getDiscordClient();
};
-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 { parseFrontmatter } from './frontmatter';
import { renderMarkdown } from './markdown'; import { renderMarkdown } from './markdown';
export interface BlogPost { export interface BlogPostSummary {
slug: string; slug: string;
title: string; title: string;
author: string; author: string;
date: string; date: string;
tags: string[]; tags: string[];
excerpt: string; excerpt: string;
html: string;
readingTime: number; 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, eager: true,
query: '?raw', query: '?raw',
import: 'default' import: 'default'
@@ -24,8 +32,8 @@ function slugFromPath(path: string): string {
} }
function estimateReadingTime(content: string): number { function estimateReadingTime(content: string): number {
const words = content.trim().split(/\s+/).filter(Boolean).length; const wordCount = content.trim().split(/\s+/).filter(Boolean).length;
return Math.max(1, Math.round(words / 200)); return Math.max(1, Math.round(wordCount / 200));
} }
function buildPost(path: string, raw: string): BlogPost { function buildPost(path: string, raw: string): BlogPost {
@@ -35,7 +43,7 @@ function buildPost(path: string, raw: string): BlogPost {
return { return {
slug, slug,
title: (data.title as string) || 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) || '', date: (data.date as string) || '',
tags: (data.tags as string[]) || [], tags: (data.tags as string[]) || [],
excerpt: (data.excerpt as string) || '', excerpt: (data.excerpt as string) || '',
@@ -46,27 +54,21 @@ function buildPost(path: string, raw: string): BlogPost {
const allPosts: BlogPost[] = Object.entries(postFiles) const allPosts: BlogPost[] = Object.entries(postFiles)
.map(([path, raw]) => buildPost(path, raw)) .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[] { export function getAllPosts(): BlogPost[] {
return allPosts; return allPosts;
} }
export function getPostSummaries(): BlogPostSummary[] {
return allPosts.map(({ html, ...summary }) => summary);
}
export function getPostBySlug(slug: string): BlogPost | undefined { export function getPostBySlug(slug: string): BlogPost | undefined {
return allPosts.find((post) => post.slug === slug); return allPosts.find((post) => post.slug === slug);
} }
export interface BlogPostNav { export function getAdjacentPosts(slug: string): { previous: BlogPostLink | null; next: BlogPostLink | null } {
slug: string;
title: string;
}
export interface AdjacentPosts {
previous: BlogPostNav | null;
next: BlogPostNav | null;
}
export function getAdjacentPosts(slug: string): AdjacentPosts {
const index = allPosts.findIndex((post) => post.slug === slug); const index = allPosts.findIndex((post) => post.slug === slug);
if (index === -1) return { previous: null, next: null }; if (index === -1) return { previous: null, next: null };
@@ -80,9 +82,5 @@ export function getAdjacentPosts(slug: string): AdjacentPosts {
} }
export function getAllTags(): string[] { export function getAllTags(): string[] {
const tags = new Set<string>(); return [...new Set(allPosts.flatMap((post) => post.tags))].sort();
for (const post of allPosts) {
for (const tag of post.tags) tags.add(tag);
}
return Array.from(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();
}
};
}
Loaded 100 of 197 files, more files were not shown because too many files have changed in this diff. Show more