diff --git a/README.md b/README.md
index 3c80d66..4b58b2d 100644
--- a/README.md
+++ b/README.md
@@ -364,6 +364,28 @@ Supported inline types: `button`, `link`, `tooltip`, `progress`, `output`, `info
}
```
+### 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
+ 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
@@ -411,15 +433,24 @@ src/lib/components/
├── terminal-typing.ts # Typing animation engine
├── terminal-keyboard.ts# Keyboard navigation handler
├── TuiHeader.svelte # Top status bar
- ├── TuiBody.svelte # Scrollable content area
+ ├── TuiBody.svelte # Scrollable content area (uses TuiLine)
├── TuiFooter.svelte # Bottom status bar
+ ├── TuiLine.svelte # Unified line renderer for all types
+ ├── TuiGroup.svelte # Container for grouped elements
├── TuiButton.svelte # Full-width button
├── TuiLink.svelte # Inline clickable link
├── TuiCard.svelte # Card with header/body/footer
+ ├── TuiCardGrid.svelte # Grid layout for cards
├── TuiProgress.svelte # Animated progress bar
├── TuiAccordion.svelte # Collapsible sections
├── TuiTable.svelte # Data table with headers
- └── TuiTooltip.svelte # Hover tooltip
+ ├── TuiTooltip.svelte # Hover tooltip
+ ├── TuiInput.svelte # Text input field
+ ├── TuiTextarea.svelte # Multi-line text input
+ ├── TuiCheckbox.svelte # Checkbox input
+ ├── TuiRadio.svelte # Radio button group
+ ├── TuiSelect.svelte # Dropdown select
+ └── TuiToggle.svelte # Toggle switch
```
## Terminal API
diff --git a/package.json b/package.json
index 3f9b4a5..28db855 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,8 @@
"scripts": {
"dev": "vite dev --host",
"build": "vite build",
- "start": "vite build && node server/server.js"
+ "server": "bun server/server.js",
+ "start": "vite build && bun server/server.js"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.4.0",
diff --git a/src/lib/components/tui/TuiBody.svelte b/src/lib/components/tui/TuiBody.svelte
index acb20f7..fbc631a 100644
--- a/src/lib/components/tui/TuiBody.svelte
+++ b/src/lib/components/tui/TuiBody.svelte
@@ -1,22 +1,7 @@
- {#each groupedLines as group}
- {#if group.type === 'inline-group'}
-
+ {#each processedGroups as group, gi (gi)}
+ {#if group.kind === 'inline'}
- {#each group.items as displayed, j}
- {@const line = displayed.parsed.line}
- {@const idx = group.indices[j]}
- {@const visibleSegments = getSegmentsUpToChar(displayed.parsed.segments, displayed.charIndex)}
- {@const showImage = displayed.showImage}
- {#if line.type === 'button'}
-
- {:else if line.type === 'link'}
-
onLinkClick(idx)} />
- {:else if line.type === 'tooltip'}
-
- {:else if line.type === 'progress'}
-
- {:else if line.type === 'input'}
-
- {:else if line.type === 'checkbox'}
-
- {:else if line.type === 'toggle'}
-
- {:else if line.type === 'group'}
-
- {:else if line.type === 'image' && showImage}
-
-

- {#if line.content}
-
{line.content}
- {/if}
-
- {:else if line.type !== 'image'}
-
- {getLinePrefix(line.type)}{#each visibleSegments as segment}{#if segment.icon}{:else if getSegmentStyle(segment)}{segment.text}{:else}{segment.text}{/if}{/each}
-
- {/if}
+ {#each group.items as item (item.index)}
+
{/each}
- {:else if group.type === 'single'}
- {@const i = group.index}
- {@const displayed = group.displayed}
- {@const line = displayed.parsed.line}
- {@const visibleSegments = getSegmentsUpToChar(displayed.parsed.segments, displayed.charIndex)}
- {@const complete = displayed.complete}
- {@const showImage = displayed.showImage}
- {#if line.type === 'divider'}
-
-
- {#if line.content}
- {line.content}
- {/if}
-
-
- {:else if line.type === 'button'}
-
- {:else if line.type === 'link'}
-
- onLinkClick(i)} />
-
- {:else if line.type === 'card'}
-
- {:else if line.type === 'cardgrid'}
-
- {:else if line.type === 'progress'}
-
- {:else if line.type === 'accordion'}
-
- {:else if line.type === 'table'}
-
- {:else if line.type === 'tooltip'}
-
-
-
- {:else if line.type === 'input'}
-
- {:else if line.type === 'textarea'}
-
- {:else if line.type === 'checkbox'}
-
- {:else if line.type === 'radio'}
-
- {:else if line.type === 'select'}
-
- {:else if line.type === 'toggle'}
-
- {:else if line.type === 'group'}
-
- {:else}
-
- {#if line.type === 'command' || line.type === 'prompt'}
-
- {user.username}@{user.hostname}
- :~$
-
- {/if}
-
- {#if line.type === 'image' && showImage}
-
-

- {#if line.content}
-
{line.content}
- {/if}
-
- {:else if line.type === 'header'}
-
- {:else if line.type !== 'blank'}
-
- {getLinePrefix(line.type)}{#each visibleSegments as segment}{#if segment.icon}{:else if getSegmentStyle(segment)}{segment.text}{:else}{segment.text}{/if}{/each}
-
- {/if}
-
- {#if terminalSettings.showCursor && i === currentLineIndex && !complete && isTyping && line.type !== 'image' && line.type !== 'blank'}
-
- {/if}
-
- {/if}
+ {:else}
+
{/if}
{/each}
diff --git a/src/lib/components/tui/TuiGroup.svelte b/src/lib/components/tui/TuiGroup.svelte
index 52cf446..2c18844 100644
--- a/src/lib/components/tui/TuiGroup.svelte
+++ b/src/lib/components/tui/TuiGroup.svelte
@@ -1,17 +1,7 @@
-
- {#each parsedChildren as parsed, idx}
- {@const child = parsed.line}
- {@const visibleSegments = parsed.segments}
- {@const childInline = child.inline !== false}
-
- {#if child.type === 'image'}
-
-

- {#if child.content}
-
{child.content}
- {/if}
-
- {:else if child.type === 'button'}
-
handleNavigation(child)} onHover={() => {}} inline={childInline} />
- {:else if child.type === 'link'}
- onLinkClick(idx)} />
- {:else if child.type === 'tooltip'}
-
- {:else if child.type === 'progress'}
-
- {:else if child.type === 'input'}
-
- {:else if child.type === 'checkbox'}
-
- {:else if child.type === 'toggle'}
-
- {:else if child.type === 'group'}
-
- {:else if child.type === 'header'}
-
- {:else if child.type === 'blank'}
-
- {:else if child.type === 'command' || child.type === 'prompt'}
-
-
- {user.username}@{user.hostname}
- :~$
-
-
- {#each visibleSegments as segment}
- {#if segment.icon}
-
- {:else if getSegmentStyle(segment)}
- {segment.text}
- {:else}
- {segment.text}
- {/if}
- {/each}
-
-
- {:else}
-
- {getLinePrefix(child.type)}{#each visibleSegments as segment}{#if segment.icon}{:else if getSegmentStyle(segment)}{segment.text}{:else}{segment.text}{/if}{/each}
-
- {/if}
+
+ {#each parsedChildren as parsed, idx (idx)}
+
{/each}
@@ -154,117 +74,15 @@
display: inline-flex;
}
- .group-line {
- display: block;
- line-height: 1.7;
- }
-
- .group-line.output {
- color: var(--terminal-muted);
- }
-
- .group-line.info {
- color: var(--terminal-primary);
- }
-
- .group-line.success {
- color: #a6e3a1;
- }
-
- .group-line.error {
- color: #f38ba8;
- }
-
- .group-line.warning {
- color: #f9e2af;
- }
-
- .group-line.command,
- .group-line.prompt {
- display: flex;
- gap: 0.5rem;
- }
-
- .prompt {
- display: inline-flex;
- 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;
- }
-
- .header-text {
- color: var(--terminal-accent);
- font-weight: 600;
- font-size: 1rem;
- display: flex;
- align-items: center;
- gap: 0.5rem;
- }
-
- .tui-image {
- display: flex;
- flex-direction: column;
- gap: 0.5rem;
- 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;
- }
-
@keyframes lineSlideIn {
- from {
- opacity: 0;
- transform: translateX(-5px);
- }
- to {
- opacity: 1;
- transform: translateX(0);
- }
+ from { opacity: 0; transform: translateX(-5px); }
+ to { opacity: 1; transform: translateX(0); }
}
- /* Mobile: inline groups become vertical stacked */
@media (max-width: 768px) {
.tui-group.inline {
flex-direction: column;
align-items: stretch;
}
-
- .tui-group.inline .tui-image img {
- max-width: 100% !important;
- width: 100%;
- }
}
diff --git a/src/lib/components/tui/TuiLine.svelte b/src/lib/components/tui/TuiLine.svelte
new file mode 100644
index 0000000..746acb4
--- /dev/null
+++ b/src/lib/components/tui/TuiLine.svelte
@@ -0,0 +1,138 @@
+
+
+{#if isBlank}
+ {#if inline}{:else}{/if}
+{:else if isDivider}
+
+
+ {#if line.content}{line.content}{/if}
+
+
+{:else if line.type === 'button'}
+
+{:else if isLink}
+ {#if inline}
+ handleNavigation(line)} />
+ {:else}
+ onLinkClick(index)} />
+ {/if}
+{:else if isTooltip}
+ {#if inline}{:else}
{/if}
+{:else if line.type === 'card'}
+
+{:else if line.type === 'cardgrid'}
+
+{:else if line.type === 'progress'}
+
+{:else if line.type === 'accordion'}
+
+{:else if line.type === 'table'}
+
+{:else if line.type === 'input'}
+
+{:else if line.type === 'textarea'}
+
+{:else if line.type === 'checkbox'}
+
+{:else if line.type === 'radio'}
+
+{:else if line.type === 'select'}
+
+{:else if line.type === 'toggle'}
+
+{:else if line.type === 'group'}
+
+{:else if isImage && showImage}
+
+

+ {#if line.content}
{line.content}{/if}
+
+{:else if !isImage}
+ {#if inline}
+
+ {getLinePrefix(line.type)}{#each segments as seg}{#if seg.icon}{:else if getSegmentStyle(seg)}{seg.text}{:else}{seg.text}{/if}{/each}
+
+ {:else}
+
+ {#if isPrompt}
+
+ {user.username}@{user.hostname}
+ :~$
+
+ {/if}
+ {#if isHeader}
+
+ {:else}
+
+ {getLinePrefix(line.type)}{#each segments as seg}{#if seg.icon}{:else if getSegmentStyle(seg)}{seg.text}{:else}{seg.text}{/if}{/each}
+
+ {/if}
+ {#if showCursor}{/if}
+
+ {/if}
+{/if}
diff --git a/src/lib/components/tui/types.ts b/src/lib/components/tui/types.ts
index a9a854e..2cf0f0c 100644
--- a/src/lib/components/tui/types.ts
+++ b/src/lib/components/tui/types.ts
@@ -1,6 +1,9 @@
import type { TextSegment } from './utils';
import type { Card } from '$lib/config';
+// Re-export TextSegment for convenience
+export type { TextSegment };
+
export type LineType =
| 'command' | 'output' | 'prompt' | 'error' | 'success' | 'info' | 'warning'
| 'image' | 'blank' | 'header' | 'button' | 'divider' | 'link'
diff --git a/src/lib/pages/components.ts b/src/lib/pages/components.ts
index 343693d..85f4d7a 100644
--- a/src/lib/pages/components.ts
+++ b/src/lib/pages/components.ts
@@ -26,7 +26,7 @@ export const lines: TerminalLine[] = [
// ═══════════════════════════════════════════════════════════════
// TEXT FORMATTING
// ═══════════════════════════════════════════════════════════════
- { type: 'divider', content: 'TEXT FORMATTING' },
+ { type: 'divider', content: 'TEXT FORMATTING', id: 'text-formatting' },
{ type: 'blank', content: '' },
{ type: 'info', content: '(&blue,bold)Colors(&)' },
@@ -70,7 +70,7 @@ export const lines: TerminalLine[] = [
// ═══════════════════════════════════════════════════════════════
// BUTTONS
// ═══════════════════════════════════════════════════════════════
- { type: 'divider', content: 'BUTTONS' },
+ { type: 'divider', content: 'BUTTONS', id: 'buttons' },
{ type: 'blank', content: '' },
{ type: 'info', content: '(&blue,bold)Button Styles(&)' },
@@ -182,6 +182,93 @@ export const lines: TerminalLine[] = [
},
{ type: 'blank', content: '' },
+ // ═══════════════════════════════════════════════════════════════
+ // GROUPS
+ // ═══════════════════════════════════════════════════════════════
+ { type: 'divider', content: 'GROUPS' },
+ { type: 'blank', content: '' },
+
+ { type: 'info', content: '(&blue,bold)Horizontal Group (Row)(&)' },
+ {
+ type: 'group',
+ content: '',
+ groupDirection: 'row',
+ groupAlign: 'center',
+ groupGap: '1rem',
+ children: [
+ { type: 'output', content: '(&primary,bold)Status:(&)', inline: true },
+ { type: 'success', content: '(&success)Online(&)', inline: true },
+ { type: 'button', content: 'Refresh', icon: 'mdi:refresh', style: 'accent', inline: true, action: () => console.log('Refresh clicked') }
+ ]
+ },
+ { type: 'blank', content: '' },
+
+ { type: 'info', content: '(&blue,bold)Vertical Group (Column)(&)' },
+ {
+ type: 'group',
+ content: '',
+ groupDirection: 'column',
+ groupAlign: 'start',
+ groupGap: '0.25rem',
+ children: [
+ { type: 'header', content: '(&accent,bold)User Profile(&)' },
+ { type: 'output', content: '(&muted)Name:(&) John Doe' },
+ { type: 'output', content: '(&muted)Role:(&) Developer' },
+ { type: 'output', content: '(&muted)Status:(&) (&success)Active(&)' }
+ ]
+ },
+ { type: 'blank', content: '' },
+
+ { type: 'info', content: '(&blue,bold)Group with Links(&)' },
+ {
+ type: 'group',
+ content: '',
+ groupAlign: 'start',
+ groupGap: '1rem',
+ children: [
+ { type: 'output', content: '(&primary,bold)Quick Links:(&)', inline: true },
+ { type: 'link', href: '#text-formatting', content: '(&bg-blue,black)Formatting(&)', inline: true },
+ { type: 'link', href: '#buttons', content: '(&bg-green,black)Buttons(&)', inline: true },
+ { type: 'link', href: '#terminal-api', content: '(&bg-orange,black)API(&)', inline: true }
+ ]
+ },
+ { type: 'blank', content: '' },
+
+ { type: 'info', content: '(&blue,bold)Nested Groups(&)' },
+ {
+ type: 'group',
+ content: '',
+ groupDirection: 'column',
+ groupGap: '0.5rem',
+ children: [
+ { type: 'header', content: '(&cyan,bold)Settings Panel(&)' },
+ {
+ type: 'group',
+ content: '',
+ groupDirection: 'row',
+ groupGap: '1rem',
+ children: [
+ { type: 'output', content: '(&muted)Theme:(&)', inline: true },
+ { type: 'button', content: 'Dark', icon: 'mdi:weather-night', style: 'primary', inline: true, action: () => {} },
+ { type: 'button', content: 'Light', icon: 'mdi:weather-sunny', style: 'accent', inline: true, action: () => {} }
+ ]
+ },
+ {
+ type: 'group',
+ content: '',
+ groupDirection: 'row',
+ groupGap: '1rem',
+ children: [
+ { type: 'output', content: '(&muted)Speed:(&)', inline: true },
+ { type: 'button', content: 'Slow', style: 'secondary', inline: true, action: () => {} },
+ { type: 'button', content: 'Normal', style: 'primary', inline: true, action: () => {} },
+ { type: 'button', content: 'Fast', style: 'accent', inline: true, action: () => {} }
+ ]
+ }
+ ]
+ },
+ { type: 'blank', content: '' },
+
// ═══════════════════════════════════════════════════════════════
// IMAGES
// ═══════════════════════════════════════════════════════════════
@@ -510,6 +597,9 @@ export const lines: TerminalLine[] = [
{ type: 'output', content: "(&muted)// Checkbox(&)" },
{ type: 'output', content: "{ type: 'checkbox', content: 'Enable option', style: 'accent' }" },
{ type: 'blank', content: '' },
+ { type: 'output', content: "(&muted)// Group with children(&)" },
+ { type: 'output', content: "{ type: 'group', groupDirection: 'row', groupGap: '1rem', children: [...] }" },
+ { type: 'blank', content: '' },
{ type: 'output', content: "(&muted)// Select dropdown(&)" },
{ type: 'output', content: "{ type: 'select', content: 'Choose:', selectOptions: [{ value: 'a', label: 'Option A' }] }" },
{ type: 'blank', content: '' },