Chore: Move landing page to phranck/tuikit.dev repo

- Remove docs/ directory (now in phranck/tuikit.dev)
- Remove scheduled workflows (update-plans-data, update-weekly-activity, update-social-cache)
- Remove build-landing and deploy-landing CI jobs
- Remove pages/id-token permissions (no longer needed)
- Keep: build (lint/test), update-badge, deploy-docs (DocC -> docs.tuikit.dev)
This commit is contained in:
phranck
2026-02-13 18:55:38 +01:00
parent 73ac66b702
commit 1f08efade5
67 changed files with 0 additions and 18962 deletions
-65
View File
@@ -10,8 +10,6 @@ on:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: ci-${{ github.ref }}
@@ -107,69 +105,6 @@ jobs:
git commit -m "Chore: Update test count badge to ${{ steps.counts.outputs.tests }} tests [skip ci]"
git push
# ── Landing Page (Astro → tuikit.dev) ─────────────────────────
build-landing:
name: Build Landing Page
needs: build
runs-on: macos-15
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Determine version
id: version
run: |
# Use tag name if triggered by a tag push, otherwise latest tag
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
VERSION="${GITHUB_REF#refs/tags/}"
else
VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "0.1.0")
fi
# Strip leading 'v' if present (v1.2.3 -> 1.2.3)
VERSION="${VERSION#v}"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Resolved version: $VERSION"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
cache-dependency-path: docs/package-lock.json
- name: Build landing page
working-directory: docs
env:
TUIKIT_VERSION: ${{ steps.version.outputs.version }}
PUBLIC_GITHUB_TOKEN: ${{ secrets.DASHBOARD_GITHUB_TOKEN }}
run: |
npm ci
npm run build
- name: Add CNAME for custom domain
run: echo "tuikit.dev" > docs/dist/CNAME
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: docs/dist
# ── Deploy Landing Page to GitHub Pages ──────────────────────
deploy-landing:
name: Deploy Landing Page
needs: build-landing
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
# ── DocC Documentation (→ docs.tuikit.dev) ───────────────────
deploy-docs:
name: Build & Deploy DocC
-40
View File
@@ -1,40 +0,0 @@
name: Update Plans Data
on:
schedule:
- cron: '0 * * * *' # Every hour
workflow_dispatch:
permissions:
contents: write
jobs:
update:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: docs/package-lock.json
- name: Install dependencies
run: npm ci
working-directory: docs
- name: Update plans data
run: npm run update:plans
working-directory: docs
- name: Commit if changed
run: |
git diff --quiet docs/public/data/plans.json && echo "No changes" && exit 0
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add docs/public/data/plans.json
git commit -m "chore: update plans data [skip ci]"
git push
-63
View File
@@ -1,63 +0,0 @@
name: Update Social Cache
on:
schedule:
- cron: '0 */2 * * *' # Every 2 hours (incremental)
- cron: '0 4 * * 0' # Weekly full refresh (Sundays 4 AM UTC)
workflow_dispatch:
inputs:
full_refresh:
description: 'Run full refresh instead of incremental'
required: false
default: 'false'
type: boolean
permissions:
contents: write
jobs:
update:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ secrets.DASHBOARD_GITHUB_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: docs/package-lock.json
- name: Install dependencies
run: npm ci
working-directory: docs
- name: Determine if full refresh
id: mode
run: |
# Full refresh on Sundays (cron) or if manually triggered with full_refresh=true
if [[ "${{ github.event.schedule }}" == "0 4 * * 0" ]] || [[ "${{ inputs.full_refresh }}" == "true" ]]; then
echo "args=--full" >> "$GITHUB_OUTPUT"
echo "Running FULL refresh"
else
echo "args=" >> "$GITHUB_OUTPUT"
echo "Running incremental update"
fi
- name: Update social cache
run: npx tsx scripts/update-social-cache.ts ${{ steps.mode.outputs.args }}
working-directory: docs
env:
GITHUB_TOKEN: ${{ secrets.DASHBOARD_GITHUB_TOKEN }}
- name: Commit if changed
run: |
git diff --quiet docs/public/social-cache.json && echo "No changes" && exit 0
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add docs/public/social-cache.json
git commit -m "chore: update social cache [skip ci]"
git push
@@ -1,58 +0,0 @@
name: Update Weekly Activity Cache
on:
schedule:
- cron: '0 */2 * * *' # Every 2 hours
workflow_dispatch:
permissions:
contents: write
jobs:
update:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Fetch commit activity with retries
run: |
MAX_RETRIES=5
RETRY_DELAY=10
OUTPUT_FILE="docs/public/weekly-activity-cache.json"
for i in $(seq 1 $MAX_RETRIES); do
echo "Attempt $i of $MAX_RETRIES..."
HTTP_CODE=$(curl -s -w "%{http_code}" -o /tmp/activity.json \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
"https://api.github.com/repos/${{ github.repository }}/stats/commit_activity")
if [ "$HTTP_CODE" = "200" ]; then
# Verify it's valid JSON array with data
if jq -e 'type == "array" and length > 0' /tmp/activity.json > /dev/null 2>&1; then
echo "Success! Got $(jq 'length' /tmp/activity.json) weeks of data."
cp /tmp/activity.json "$OUTPUT_FILE"
exit 0
else
echo "Got 200 but empty or invalid data, retrying..."
fi
else
echo "Got HTTP $HTTP_CODE, waiting ${RETRY_DELAY}s before retry..."
fi
sleep $RETRY_DELAY
done
echo "Failed to fetch valid data after $MAX_RETRIES attempts"
exit 1
- name: Commit if changed
run: |
git diff --quiet docs/public/weekly-activity-cache.json && echo "No changes" && exit 0
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add docs/public/weekly-activity-cache.json
git commit -m "chore: update weekly activity cache [skip ci]"
git push
-32
View File
@@ -1,32 +0,0 @@
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import tailwindcss from '@tailwindcss/vite';
import fs from 'fs';
// Load project stats from prebuild script (if available)
let testCount = '0';
let suiteCount = '0';
try {
const stats = JSON.parse(fs.readFileSync('./project-stats.json', 'utf-8'));
testCount = String(stats.testCount ?? 0);
suiteCount = String(stats.suiteCount ?? 0);
} catch {
// File not found or invalid JSON, use defaults
}
// https://astro.build/config
export default defineConfig({
site: 'https://tuikit.dev',
output: 'static',
integrations: [
react(),
],
vite: {
plugins: [tailwindcss()],
define: {
'import.meta.env.PUBLIC_TUIKIT_VERSION': JSON.stringify(process.env.TUIKIT_VERSION ?? '0.1.0'),
'import.meta.env.PUBLIC_TUIKIT_TEST_COUNT': JSON.stringify(testCount),
'import.meta.env.PUBLIC_TUIKIT_SUITE_COUNT': JSON.stringify(suiteCount),
},
},
});
-7644
View File
File diff suppressed because it is too large Load Diff
-33
View File
@@ -1,33 +0,0 @@
{
"name": "tuikit-landing",
"type": "module",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"prebuild": "tsx scripts/generate-terminal-data.ts && tsx scripts/update-plans-data.ts",
"build": "astro build",
"preview": "astro preview",
"update:plans": "tsx scripts/update-plans-data.ts"
},
"dependencies": {
"@astrojs/react": "^4.3.1",
"@tabler/icons-react": "^3.36.1",
"astro": "^5.10.3",
"howler": "^2.2.4",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-markdown": "^9.0.1",
"simple-icons": "^16.7.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.10",
"@types/howler": "^2.2.12",
"@types/react": "^19",
"@types/react-dom": "^19",
"tailwindcss": "^4",
"tsx": "^4.21.0",
"typescript": "^5"
}
}
-253
View File
@@ -1,253 +0,0 @@
{
"generated": "2026-02-13T17:37:29.194Z",
"open": [
{
"date": "2026-02-13",
"slug": "codebase-refactoring",
"title": "Refactor: Codebase Quality & SwiftUI API Parity",
"preface": "Konsolidierte Findings aus zwei Audits:\n1. **SwiftUI API Parity** - Vergleich der oeffentlichen APIs mit SwiftUI-Signaturen\n2. **Code Quality** - Duplikate, Inkonsistenzen, grosse Dateien\n\n---"
},
{
"date": "2026-02-10",
"slug": "bundle-resource-loading",
"title": "Bundle Resource Loading Support",
"preface": "This plan integrates Foundation.Bundle into TUIkit for resource loading (images, data files, localized strings). Rather than reimplementing Bundle, we leverage SPM's automatic Bundle.module generation for resource management. The implementation adds resource declarations to Package.swift, organizes assets in a Resources directory, and provides type-safe resource accessors. This enables Image(_:bundle:) API and future features like localized strings, custom fonts, and data-driven UI components. The approach is cross-platform (macOS + Linux), requires zero custom Bundle code, and follows Apple's SPM resource conventions."
},
{
"date": "2026-02-10",
"slug": "image-view-ascii-art",
"title": "Image View with ASCII Art Rendering",
"preface": "This plan introduces SwiftUI-conformant Image view support for TUIkit using ASCII art rendering with full 24-bit true color support. The implementation converts raster images (PNG, JPEG) to colored ASCII/Unicode characters, enabling rich visual content in terminal UIs. The system uses platform-specific image decoding (CoreGraphics on macOS, swift-png on Linux) and sophisticated algorithms including brightness mapping, color quantization, Floyd-Steinberg dithering, and aspect ratio correction. The public API mirrors SwiftUI's Image API with terminal-specific modifiers for character set selection and color modes. Performance is optimized through caching, downsampling, and lazy rendering."
},
{
"date": "2026-02-02",
"slug": "state-persistence",
"title": "State Persistence (Session Continuity / Crash Recovery)",
"preface": "Session state now survives app crashes with `@RestoredState`. Ua disk-persisted wrapper around `@State`. Values auto-save on change (debounced) and auto-restore on startup. Signal handlers (SIGTERM/SIGINT) flush state before shutdown so no data is lost. Perfect for file managers, editors, or any app tracking last directory, scroll position, or open files. Builds on the State Storage Identity system to keep state stable across renders while adding optional persistence."
}
],
"done": [
{
"date": "2026-02-10",
"slug": "layout-system-refactor",
"title": "Layout System Refactor: Two-Pass Layout Like SwiftUI",
"preface": "This plan refactors TUIKit's layout system to use a proper two-pass layout algorithm similar to SwiftUI. The current system renders all children with the same `availableWidth`, causing layout bugs when views need to share horizontal space (e.g., TextField in HStack). The new system will measure views first, then distribute remaining space, ensuring predictable and correct layouts."
},
{
"date": "2026-02-10",
"slug": "navigation-split-view",
"title": "NavigationSplitView Integration",
"preface": "NavigationSplitView brings a two- or three-column navigation layout to TUIkit, enabling master-detail patterns common in terminal applications like file managers, music players, or database browsers. The view renders entirely within the content area between AppHeader and StatusBar, respecting existing layout constraints. Each column becomes its own focus section, allowing Tab navigation between sidebar, content, and detail areas. Column visibility can be controlled programmatically, and styles determine whether columns resize or overlay."
},
{
"date": "2026-02-09",
"slug": "securefield-component",
"title": "SecureField Component",
"preface": "This plan implements SecureField, a password input component for TUIKit. SecureField displays user input as bullet characters (●) instead of plain text, providing privacy for sensitive information like passwords and PINs. It shares most functionality with TextField (cursor navigation, text editing, focus handling) but masks the displayed content. The SwiftUI API is matched exactly, with init signatures using String bindings and optional prompts."
},
{
"date": "2026-02-09",
"slug": "slider-stepper",
"title": "Slider & Stepper Components",
"preface": "This plan implements Slider and Stepper, two essential numeric input controls for TUIKit. Both components allow users to adjust values using keyboard navigation. Slider displays a visual track with a thumb indicator, while Stepper shows increment/decrement arrows around a value. Both use consistent focus indicators (pulsing vertical bars) matching TextField. The existing `ProgressBarStyle` will be renamed to `TrackStyle` for reuse across ProgressView and Slider."
},
{
"date": "2026-02-09",
"slug": "textfield-component",
"title": "TextField Component",
"preface": "This plan implements TextField, an essential text input component for TUIKit. TextField provides an editable single-line text interface with cursor navigation, text editing, and SwiftUI-conformant API. Users can type, delete, and navigate within the field using standard keyboard controls. The component supports placeholder text (prompt), focus states with visual feedback, and the `.onSubmit()` modifier for form submission. This is a fundamental building block for forms, search fields, and any user text input."
},
{
"date": "2026-02-09",
"slug": "textfield-selection",
"title": "TextField Selection Support",
"preface": "This plan adds text selection to TextField and SecureField. Users can select text using Shift+Arrow keys, with Shift+Left/Right extending character-by-character and Shift+Up/Down selecting to start/end (since single-line fields have no vertical navigation). Selected text is visually highlighted and can be deleted with Backspace/Delete or replaced by typing. This requires extending the CSI parser to recognize modifier codes in escape sequences and adding selection state to TextFieldHandler."
},
{
"date": "2026-02-08",
"slug": "list-swiftui-api-parity",
"title": "List SwiftUI API Parity",
"preface": "TUIkit's List component gains SwiftUI API parity with Section grouping, badges, list styles, selection control, and alternating row backgrounds. Section enables hierarchical organization with headers and footers. The `.badge()` modifier displays counts or labels right-aligned in rows. ListStyle provides visual variants like `.plain` and `.insetGrouped`. Per-item selection can be disabled via `.selectionDisabled()`. The `.listRowSeparator()` modifier is implemented as a stub with a warning since separators are not visually supported in TUIkit."
},
{
"date": "2026-02-08",
"slug": "section-integration-phase2c",
"title": "Section Integration with List (List SwiftUI API Parity Phase 2c)",
"preface": "Phase 2c integrates Section headers/footers into List's row rendering while maintaining SwiftUI API parity. The key insight: Section headers/footers are non-selectable visual separators, while content items within sections are individually selectable and focusable. Current architecture treats entire Sections as single rows; this plan flattens Section structure into individual row types with focus navigation that skips non-selectable headers/footers. Alternating row colors now restart per section."
},
{
"date": "2026-02-07",
"slug": "astro-dashboard",
"title": "Astro Migration: Dashboard",
"preface": "This plan migrates the TUIKit Dashboard from Next.js 16 to Astro, building on the landing page migration. The dashboard is more interactive than the landing page: StatCards, ActivityHeatmap, CommitList, PlansCard, and AvatarMarquee all require client-side JavaScript. These become React Islands with appropriate hydration strategies. Data fetching via `useGitHubStats` and localStorage caching remain client-side. The result is a dashboard that loads faster while maintaining all interactivity including Framer Motion animations."
},
{
"date": "2026-02-07",
"slug": "astro-landing-page",
"title": "Astro Migration: Landing Page",
"preface": "This plan migrates the TUIKit landing page from Next.js 16 to Astro, leveraging Astro's Islands Architecture for zero-JS delivery of static content while preserving React components for interactive elements. The HeroTerminal with CRT effects, TerminalScreen animation, and theme system become React Islands. Static content (FeatureCards, CodePreview, footer) renders as pure Astro components with no client-side JavaScript. Expected outcome: faster initial load, smaller bundle, same visual experience."
},
{
"date": "2026-02-07",
"slug": "code-review-fixes",
"title": "Code Review Fixes: Swift 6 Concurrency and Architectural Improvements",
"preface": "A comprehensive code review identified critical issues with Swift 6 concurrency readiness, missing abstractions for testability, and minor architectural debt. This plan addresses all findings systematically: introducing `@MainActor` isolation for render-loop-only types, adding thread-safe synchronization where cross-thread access occurs, extracting a `TerminalProtocol` for testability, fixing ForEach's runtime crash, and consolidating duplicated focus handler code. The result is a codebase ready for Swift 6 strict concurrency mode with improved testability and maintainability."
},
{
"date": "2026-02-07",
"slug": "list-table-components",
"title": "List & Table Components",
"preface": "This plan implements the List and Table components in two phases. Phase 1 builds the shared `ItemListHandler` (navigation, selection, scrolling) and the `List` view. Phase 2 adds the `Table` view, reusing the handler and adding column alignment. Both components follow the RadioButtonGroup pattern: a handler class persisted via StateStorage, keyboard navigation within the component, and visual states for focused/selected items."
},
{
"date": "2026-02-07",
"slug": "mobile-performance",
"title": "Mobile Performance Optimization",
"preface": "The docs site (Landing Page + Dashboard) runs poorly on iPhone due to heavy JavaScript bundles (548KB), expensive CSS effects (22 backdrop-blur instances, 6 animated cloud blurs), and continuous animations (rain canvas, spinner lights, avatar marquee). This plan removes Framer Motion, disables expensive effects on mobile via CSS media queries, and optimizes hydration timing to achieve smooth 60fps scrolling on mobile devices."
},
{
"date": "2026-02-07",
"slug": "view-architecture-refactor",
"title": "View Architecture Refactor: 100% SwiftUI Conformity",
"preface": "This plan refactors TUIKit's view architecture to achieve 100% SwiftUI conformity. All public controls now use proper `body: some View` pattern instead of `body: Never` with direct `Renderable` conformance. Modifiers like `.foregroundStyle()` propagate through the entire hierarchy automatically. Performance optimizations were applied to FrameBuffer, Stack rendering, and ANSI string operations, resulting in 2-3x faster rendering. LazyVStack and LazyHStack were added for SwiftUI parity."
},
{
"date": "2026-02-06",
"slug": "containerization-composition",
"title": "Containerization: Composition NOT Inheritance in Swift",
"preface": "Composition replaces inheritance throughout the framework: Alert, Dialog, Panel, and Card all use the `renderContainer()` helper rather than inheriting from ContainerView. List and Table will follow the same pattern with `renderListWithFocus()` and `renderTableWithFocus()` helpers plus shared `FocusableItemListHandler`. This ensures consistency, maximizes code reuse, and keeps view definitions simple (plain structs) while rendering logic lives in testable helper functions. Utrue to SwiftUI/TUIKit's design philosophy."
},
{
"date": "2026-02-06",
"slug": "containerview-refactor",
"title": "ContainerView Refactoring Plan",
"preface": "ContainerView is being refactored from the broken `body: Never` + Renderable pattern to a proper View with `body: some View` that returns an internal `_ContainerViewCore`. This fix enables modifiers to work naturally and becomes the template for fixing List, Box, and other components. Once done, `.foregroundColor()`, `.padding()`, and other standard modifiers will compose correctly instead of being silently ignored."
},
{
"date": "2026-02-06",
"slug": "imp-containerview",
"title": "Implementation Plan: ContainerView Refactoring",
"preface": "ContainerView is fixed by extracting its 400+ lines of rendering logic into a private `_ContainerViewCore` struct, making `ContainerView` a simple public View with `body: some View` that creates the core. This restores modifier support, enables proper view composition, and establishes the correct pattern for refactoring List, Box, and all other components in the framework."
},
{
"date": "2026-02-06",
"slug": "imp-list-table",
"title": "Implementation Plan: List & Table (Refactored)",
"preface": "List and Table are both implemented using proper View architecture: public View with `body: some View`, private `_ListCore`/`_TableCore` containing all rendering logic. Both share focus and selection handling via `ItemListHandler`, which combines navigation and selection logic in a single reusable component. This establishes a consistent, reusable pattern for List and Table while serving as a reference for future components."
},
{
"date": "2026-02-06",
"slug": "imp-shared-handlers",
"title": "Implementation Plan: Shared Focus/Selection Handlers & Helpers",
"preface": "Shared focus/selection infrastructure is extracted so both List and Table reuse the same pieces: `FocusableItemListHandler` for keyboard navigation (Up/Down/Home/End), `SelectionStateManager<T>` for tracking selected values, `ItemStateRenderer` for styling based on focus/selection state, and `renderFocusableContainer()` helper that orchestrates layout + styling + scrolling."
},
{
"date": "2026-02-06",
"slug": "list-scrollable",
"title": "List (Scrollable)",
"preface": "List gives TUI apps the power of SwiftUI's List: arbitrary nested views, ForEach with dynamic content, optional selection binding via `.tag()`, and keyboard navigation (Up/Down/Home/End/PageUp/PageDown) with auto-scrolling. Focused item always visible, scroll indicators show bounds, selection updates on Enter. MVP focuses on core scrollable list without sections. They come later once the API is proven."
},
{
"date": "2026-02-06",
"slug": "list-table-shared-architecture",
"title": "List & Table: Shared Architecture Analysis",
"preface": "This analysis identifies the shared architecture between List and Table before implementation: both need focus management, keyboard navigation (Up/Down/Home/End), selection binding, scrolling, and item state rendering. Navigation logic and selection state are identical; rendering differs (List = vertical stack, Table = grid). Extract shared components (handlers, helpers, state managers) to eliminate duplication while letting each component specialize in layout."
},
{
"date": "2026-02-06",
"slug": "toggle",
"title": "Toggle Component",
"preface": "`Toggle` adds interactive on/off controls to TUI apps with SwiftUI API parity: `Binding<Bool>`, label as ViewBuilder or String, two styles (toggle slider `[●○]`/`[○●]` and checkbox `[x]`/`[ ]`). Space/Enter toggles the value. Focused indicators show via pulsing ● dot. Supports `.disabled()` modifier. Works perfectly alongside Button and Menu as part of the core input component set."
},
{
"date": "2026-02-05",
"slug": "flat-appearance",
"title": "Remove Block/Flat Appearances: Simplify to Border-Only Rendering",
"preface": "Block and Flat appearances are both removed (36 files changed). The framework now offers four border-based appearances: line, rounded, doubleLine, heavy. Uall using standard Unicode box-drawing characters. Surface color tokens are consolidated into `Palette` directly (no more `BlockPalette` protocol). Simplified architecture, eliminated half-block complexity that broke on many terminals, and gained universal compatibility using only ANSI backgrounds and standard borders."
},
{
"date": "2026-02-05",
"slug": "progress-view",
"title": "ProgressView: Determinate Progress Bar",
"preface": "`ProgressView` adds progress bars to TUI apps: horizontal bars that fill to a percentage (0100), with five Unicode styles (block, blockFine, shade, bar, dot), optional labels, and SwiftUI API parity. Supports `BinaryFloatingPoint` values with total, ViewBuilder label closures, and currentValueLabel for custom percentage display. Optional `.disabled()` modifier, proper width clamping, and edge-case handling for nil/negative/overflow values."
},
{
"date": "2026-02-05",
"slug": "render-performance-phase2",
"title": "Render Performance Phase 2: Memoization Activation & Debug Tooling",
"preface": "Subtree memoization is activated: environment snapshot comparison in `RenderLoop` automatically invalidates cache on theme/palette changes; core types (Text, Alignment, ContainerConfig, etc.) gain `Equatable` conformance; example app extracts view subcomponents and applies `.equatable()` for real cache benefit. Debug logging (via `TUIKIT_DEBUG_RENDER` env var) tracks cache hits/misses per identity. Between state changes, identical views now skip re-rendering entirely."
},
{
"date": "2026-02-05",
"slug": "social-lookup-optimization",
"title": "Social Lookup Script: Matching Algorithm Optimization",
"preface": "Social lookup script is optimized to eliminate false positives: instance validation via NodeInfo (confirms domains run ActivityPub software), expanded blocklist for link-in-bio services, leading-`@` requirement for Mastodon handles (rejects corporate emails), and preservation of `verified: true` for authoritative sources (GitHub profile, Keybase, manual overrides). Full refresh overwrites stale entries. 21 known Mastodon instances searched. Cleaner cache, no manual corrections needed."
},
{
"date": "2026-02-04",
"slug": "dashboard-avatar-marquee",
"title": "Plan: Dashboard Redesign - Avatar Marquee",
"preface": "A generic `AvatarMarquee` component now displays scrolling avatar lists (stargazers, contributors, etc.) with smooth infinite scroll, fade edges, and hover popovers. The marquee arrow targets the relevant stat card above (Stars, Contributors, etc.). Hover decelerates scroll smoothly; mouseleave accelerates it back. Generic TypeScript props make it reusable for any avatar collection. The Stargazers panel now uses this component, replacing a static grid."
},
{
"date": "2026-02-04",
"slug": "project-dashboard",
"title": "Project Dashboard: Live GitHub Metrics Page",
"preface": "A `/dashboard` page now displays live GitHub metrics: stat cards (commits, stars, forks, PRs, contributors, branches, tags, releases), a 52-week commit heatmap (GitHub-style), language breakdown bar, recent commits with expandable messages, and repo metadata. All data fetched client-side via GitHub REST API (no token required). Manual refresh button + rate limit display in footer. Phosphor-themed retro styling matches the landing page. Everything loads in parallel for snappy performance."
},
{
"date": "2026-02-04",
"slug": "stargazer-mastodon-lookup",
"title": "Stargazer Mastodon Lookup: Automatic Account Discovery",
"preface": "A scheduled GitHub Action discovers Mastodon accounts for stargazers: searches GitHub bios/blogs for Mastodon handles using regex, tries usernames on known instances, caches results in `mastodon-cache.json`, and merges with live stargazers at runtime. Dashboard popover shows the Mastodon icon + link. Incremental updates (every 2h) find new stargazers; weekly full refresh catches updated bios. (Later expanded to Twitter and Bluesky in the multi-platform social lookup plan.)"
},
{
"date": "2026-02-04",
"slug": "stargazer-social-lookup",
"title": "Stargazer Social Account Lookup: Multi-Platform Discovery",
"preface": "Multi-platform social lookup now discovers Mastodon, Twitter, and Bluesky accounts for stargazers: searches GitHub bios/blogs/profile fields, validates domains via NodeInfo, searches usernames on known instances, and caches results. Scheduled GitHub Action runs every 2h (incremental for new stargazers) and weekly (full refresh for updated bios). Dashboard popover shows social icons + links for all three platforms. Better validation eliminates false positives (corporate emails, link-in-bio services)."
},
{
"date": "2026-02-03",
"slug": "focus-sections-statusbar",
"title": "Focus Sections with StatusBar Cascading",
"preface": "Focus Sections enable multi-panel TUIs where Tab switches between named focusable areas and the StatusBar displays context-sensitive shortcuts for each. StatusBar items cascade from the active section up to parents (merge) or stop cleanly (replace for modals). A breathing ● dot in the section border pulses via a dedicated PulseTimer, providing clear visual feedback on which section is active. Cascading composition and per-section shortcuts make complex layouts intuitive."
},
{
"date": "2026-02-03",
"slug": "palette-consolidation",
"title": "Palette Consolidation: Replace 6 Palette Structs with SystemPalette.Preset Enum",
"preface": "Six palette files are consolidated into one: a `SystemPalette.Preset` enum with `.green`, `.amber`, `.red`, `.violet`, `.blue`, `.white` cases, backed by hand-tuned HSL parameters. All boilerplate (init, color generation, helpers) is shared; only the tuning data differs. Six files → one file, 545 LOC → 150 LOC. Convenience accessors like `BlockPalette.amber` still work, user-defined custom palettes still conform to `Palette` normally."
},
{
"date": "2026-02-03",
"slug": "subtree-memoization",
"title": "Render Pipeline Phase 5: Subtree Memoization",
"preface": "Subtree memoization caches rendered FrameBuffers by view identity: when a view conforms to `Equatable` and is wrapped in `.equatable()`, the framework compares the new view with the cached one. If equal (and available size hasn't changed), the cached FrameBuffer is reused. Uskipping the entire subtree's re-rendering. `RenderCache` stores buffers keyed by `ViewIdentity`, invalidated when `@State` changes or environment changes. Between state changes, identical views like Spinners skip rendering entirely."
},
{
"date": "2026-02-02",
"slug": "render-pipeline-optimization",
"title": "Render Pipeline Optimization",
"preface": "The render pipeline is optimized across four phases: (1) line-level diffing compares output with previous frame and only writes changed lines (eliminates ~90% of terminal writes), (2) output buffering batches all writes into one syscall, (3) caching eliminates repeated ioctl and regex evaluations on terminal size and FrameBuffer width, (4) architecture cleanup extracts ChildInfo for future subtree memoization. Spinner animations now run smoothly because unchanged regions don't get redrawn."
},
{
"date": "2026-02-02",
"slug": "spinner-view",
"title": "Spinner View",
"preface": "Spinners now animate loading states with three styles: rotating dots (braille), rotating line (ASCII), and bouncing Knight-Rider dot with fade trail. Each style runs at calibrated speed (110ms, 140ms, 100ms), uses time-based frame calculation (no drift), and triggers re-renders at ~25 FPS via lifecycle tasks. Simple API: `Spinner()`, `Spinner(\"Loading...\", style: .line)`, or `Spinner(\"...\", style: .bouncing, color: .cyan)`. Works everywhere with smooth, jitter-free animation."
},
{
"date": "2026-02-02",
"slug": "toast-view",
"title": "Toast View",
"preface": "Toast notifications appear and fade smoothly to communicate ephemeral messages (\"Saved!\", \"Error\", etc.) without blocking the UI. Multiple severity styles (info, success, warning, error) with themed colors, smooth fade-in/fade-out animation (~200ms each), auto-dismiss after a configurable duration, and positioning (top/bottom) via `.overlay()`. Later evolved into the Notification Service for greater power, but the core ephemeral message pattern remains."
},
{
"date": "2026-02-01",
"slug": "state-storage-identity",
"title": "State Storage Identity",
"preface": "State now survives **any** view reconstruction via structural identity: each view gets a stable key (its position in the tree), and state values live in external `StateStorage` indexed by that key. Self-hydrating `@State.init` checks the active context during `body` evaluation and retrieves persistent storage immediately. This foundation enables components like RadioButtonGroup, List, and any control with persistent local state to work reliably across all render passes."
}
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 475 KiB

-89
View File
@@ -1,89 +0,0 @@
#!/bin/bash
#
# TUIkit Xcode Template Installer
# Installs the TUIkit App template into Xcode's user templates directory.
#
# Usage:
# curl -fsSL https://tuikit.dev/install-template.sh | bash
#
# Or manually:
# ./install.sh
#
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
TEMPLATE_NAME="TUIkit App.xctemplate"
GITHUB_RAW_BASE="https://raw.githubusercontent.com/phranck/TUIkit/main/xcode-template"
TEMPLATE_DIR="$HOME/Library/Developer/Xcode/Templates/Project Templates/macOS/Application"
echo -e "${BLUE}"
echo "╔════════════════════════════════════════════════════╗"
echo "║ ║"
echo "║ TUIkit ║"
echo "║ Xcode Template Installer ║"
echo "║ ║"
echo "╚════════════════════════════════════════════════════╝"
echo -e "${NC}"
# Check if running on macOS
if [[ "$(uname)" != "Darwin" ]]; then
echo -e "${RED}Error: This installer only works on macOS.${NC}"
exit 1
fi
# Check if Xcode is installed
if ! command -v xcodebuild &> /dev/null; then
echo -e "${RED}Error: Xcode is not installed. Please install Xcode from the App Store.${NC}"
exit 1
fi
echo -e "${YELLOW}Installing TUIkit Xcode template...${NC}"
echo ""
# Create template directory
echo -e " ${BLUE}Creating template directory...${NC}"
mkdir -p "$TEMPLATE_DIR/$TEMPLATE_NAME/___FILEBASENAME___/Sources/___VARIABLE_productName___"
# Download TemplateInfo.plist
echo -e " ${BLUE}Downloading TemplateInfo.plist...${NC}"
curl -fsSL "$GITHUB_RAW_BASE/TUIkit%20App.xctemplate/TemplateInfo.plist" \
-o "$TEMPLATE_DIR/$TEMPLATE_NAME/TemplateInfo.plist"
# Download Package.swift
echo -e " ${BLUE}Downloading Package.swift...${NC}"
curl -fsSL "$GITHUB_RAW_BASE/TUIkit%20App.xctemplate/___FILEBASENAME___/Package.swift" \
-o "$TEMPLATE_DIR/$TEMPLATE_NAME/___FILEBASENAME___/Package.swift"
# Download main.swift
echo -e " ${BLUE}Downloading main.swift...${NC}"
curl -fsSL "$GITHUB_RAW_BASE/TUIkit%20App.xctemplate/___FILEBASENAME___/Sources/___VARIABLE_productName___/main.swift" \
-o "$TEMPLATE_DIR/$TEMPLATE_NAME/___FILEBASENAME___/Sources/___VARIABLE_productName___/main.swift"
# Download icon if it exists
if curl -fsSL --head "$GITHUB_RAW_BASE/TUIkit%20App.xctemplate/TemplateIcon.png" &> /dev/null; then
echo -e " ${BLUE}Downloading template icon...${NC}"
curl -fsSL "$GITHUB_RAW_BASE/TUIkit%20App.xctemplate/TemplateIcon.png" \
-o "$TEMPLATE_DIR/$TEMPLATE_NAME/TemplateIcon.png"
curl -fsSL "$GITHUB_RAW_BASE/TUIkit%20App.xctemplate/TemplateIcon@2x.png" \
-o "$TEMPLATE_DIR/$TEMPLATE_NAME/TemplateIcon@2x.png" 2>/dev/null || true
fi
echo ""
echo -e "${GREEN}Installation complete!${NC}"
echo ""
echo -e "The TUIkit App template is now available in Xcode:"
echo -e " ${BLUE}File > New > Project > macOS > TUIkit App${NC}"
echo ""
echo -e "To uninstall, run:"
echo -e " ${YELLOW}rm -rf \"$TEMPLATE_DIR/$TEMPLATE_NAME\"${NC}"
echo ""
echo -e "${BLUE}Happy coding with TUIkit!${NC}"
echo -e "Documentation: ${YELLOW}https://tuikit.dev${NC}"
echo ""
Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

-4
View File
@@ -1,4 +0,0 @@
User-agent: *
Allow: /
Sitemap: https://tuikit.dev/sitemap.xml
-11
View File
@@ -1,11 +0,0 @@
{
"name": "TUIkit — Terminal UI Framework for Swift",
"short_name": "TUIkit",
"icons": [
{ "src": "/favicon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/favicon-512.png", "sizes": "512x512", "type": "image/png" }
],
"theme_color": "#060a07",
"background_color": "#060a07",
"display": "standalone"
}
-13
View File
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://tuikit.dev</loc>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://docs.tuikit.dev/documentation/tuikit</loc>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
</urlset>
-353
View File
@@ -1,353 +0,0 @@
{
"generatedAt": "2026-02-13T16:39:54.364Z",
"entries": {
"patriksvensson": {
"login": "patriksvensson",
"mastodon": {
"handle": "@patriksvensson@mstdn.social",
"url": "https://mstdn.social/@patriksvensson",
"source": "github",
"verified": true
},
"twitter": {
"handle": "@firstdrafthell",
"url": "https://twitter.com/firstdrafthell",
"source": "keybase",
"verified": true
},
"bluesky": {
"handle": "@patriksvensson.se",
"url": "https://bsky.app/profile/patriksvensson.se",
"source": "github",
"verified": true
},
"updatedAt": "2026-02-08T06:33:11.072Z"
},
"lemonmojo": {
"login": "lemonmojo",
"mastodon": {
"handle": "@lemonmojo@mastodontech.de",
"url": "https://mastodontech.de/@lemonmojo",
"source": "github",
"verified": true
},
"twitter": {
"handle": "@lemonmojo",
"url": "https://x.com/lemonmojo",
"source": "github",
"verified": true
},
"updatedAt": "2026-02-08T06:33:32.608Z"
},
"dan-hart": {
"login": "dan-hart",
"mastodon": {
"handle": "@codedbydan@mas.to",
"url": "https://mas.to/@codedbydan",
"source": "blog",
"verified": true
},
"updatedAt": "2026-02-08T06:33:34.294Z"
},
"defagos": {
"login": "defagos",
"mastodon": {
"handle": "@defagos@mstdn.social",
"url": "https://mstdn.social/@defagos",
"source": "github",
"verified": true
},
"twitter": {
"handle": "@defagos",
"url": "https://x.com/defagos",
"source": "github",
"verified": true
},
"bluesky": {
"handle": "@defagos.bsky.social",
"url": "https://bsky.app/profile/defagos.bsky.social",
"source": "github",
"verified": true
},
"updatedAt": "2026-02-08T06:33:34.569Z"
},
"codebeauty": {
"login": "codebeauty",
"bluesky": {
"handle": "@designerdrug.net",
"url": "https://bsky.app/profile/designerdrug.net",
"source": "github",
"verified": true
},
"updatedAt": "2026-02-08T06:34:19.311Z"
},
"obrhoff": {
"login": "obrhoff",
"bluesky": {
"handle": "@obrhoff.de",
"url": "https://bsky.app/profile/obrhoff.de",
"source": "github",
"verified": true
},
"updatedAt": "2026-02-08T06:35:08.739Z"
},
"alladinian": {
"login": "alladinian",
"twitter": {
"handle": "@alladinian",
"url": "https://x.com/alladinian",
"source": "github",
"verified": true
},
"updatedAt": "2026-02-08T06:35:25.922Z"
},
"interstateone": {
"login": "interstateone",
"mastodon": {
"handle": "@interstateone@mastodon.social",
"url": "https://mastodon.social/@interstateone",
"source": "github",
"verified": true
},
"twitter": {
"handle": "@interstateone",
"url": "https://x.com/interstateone",
"source": "github",
"verified": true
},
"updatedAt": "2026-02-08T06:35:33.221Z"
},
"n0an": {
"login": "n0an",
"twitter": {
"handle": "@_antonnovoselov",
"url": "https://x.com/_antonnovoselov",
"source": "github",
"verified": true
},
"updatedAt": "2026-02-08T06:36:02.043Z"
},
"erdaltoprak": {
"login": "erdaltoprak",
"twitter": {
"handle": "@erdalxtoprak",
"url": "https://x.com/erdalxtoprak",
"source": "github",
"verified": true
},
"updatedAt": "2026-02-08T06:36:32.943Z"
},
"IvanShevy": {
"login": "IvanShevy",
"twitter": {
"handle": "@Ivan_Shevy",
"url": "https://x.com/Ivan_Shevy",
"source": "github",
"verified": true
},
"updatedAt": "2026-02-08T06:36:46.167Z"
},
"eliaskc": {
"login": "eliaskc",
"twitter": {
"handle": "@eliaskcarlson",
"url": "https://x.com/eliaskcarlson",
"source": "github",
"verified": true
},
"updatedAt": "2026-02-08T06:37:15.066Z"
},
"jessegrosjean": {
"login": "jessegrosjean",
"mastodon": {
"handle": "@jessegrosjean@mastodon.social",
"url": "https://mastodon.social/@jessegrosjean",
"source": "github",
"verified": true
},
"twitter": {
"handle": "@jessegrosjean",
"url": "https://x.com/jessegrosjean",
"source": "github",
"verified": true
},
"updatedAt": "2026-02-08T06:37:31.104Z"
},
"alpharover": {
"login": "alpharover",
"twitter": {
"handle": "@Alpha10six",
"url": "https://x.com/Alpha10six",
"source": "blog",
"verified": false
},
"updatedAt": "2026-02-08T06:38:00.402Z"
},
"travisbrigman": {
"login": "travisbrigman",
"twitter": {
"handle": "@recordingTravis",
"url": "https://x.com/recordingTravis",
"source": "github",
"verified": true
},
"updatedAt": "2026-02-08T06:38:14.211Z"
},
"olejnjak": {
"login": "olejnjak",
"twitter": {
"handle": "@olejnjak",
"url": "https://x.com/olejnjak",
"source": "github",
"verified": true
},
"updatedAt": "2026-02-08T06:38:29.148Z"
},
"RaffeYang": {
"login": "RaffeYang",
"twitter": {
"handle": "@RaffeYang",
"url": "https://x.com/RaffeYang",
"source": "github",
"verified": true
},
"updatedAt": "2026-02-08T06:38:42.661Z"
},
"dbolella": {
"login": "dbolella",
"twitter": {
"handle": "@dbolella",
"url": "https://x.com/dbolella",
"source": "github",
"verified": true
},
"updatedAt": "2026-02-08T06:38:57.360Z"
},
"gerzonc": {
"login": "gerzonc",
"twitter": {
"handle": "@gerzonzc",
"url": "https://x.com/gerzonzc",
"source": "github",
"verified": true
},
"updatedAt": "2026-02-08T06:39:24.555Z"
},
"ream88": {
"login": "ream88",
"twitter": {
"handle": "@ream88",
"url": "https://x.com/ream88",
"source": "github",
"verified": true
},
"updatedAt": "2026-02-08T06:39:52.946Z"
},
"harilvfs": {
"login": "harilvfs",
"mastodon": {
"handle": "@harilvfs@mastodon.social",
"url": "https://mastodon.social/@harilvfs",
"source": "username-match",
"verified": true
},
"updatedAt": "2026-02-08T06:40:24.555Z"
},
"fgrosjean": {
"login": "fgrosjean",
"twitter": {
"handle": "@fgrosjean",
"url": "https://x.com/fgrosjean",
"source": "github",
"verified": true
},
"updatedAt": "2026-02-08T06:40:53.364Z"
},
"yannouuuu": {
"login": "yannouuuu",
"twitter": {
"handle": "@y__rnd",
"url": "https://x.com/y__rnd",
"source": "github",
"verified": true
},
"updatedAt": "2026-02-08T06:41:07.552Z"
},
"Adem68": {
"login": "Adem68",
"twitter": {
"handle": "@AdemOzcanTR",
"url": "https://x.com/AdemOzcanTR",
"source": "github",
"verified": true
},
"updatedAt": "2026-02-08T06:41:23.596Z"
},
"erolando": {
"login": "erolando",
"twitter": {
"handle": "@erolando",
"url": "https://x.com/erolando",
"source": "github",
"verified": true
},
"updatedAt": "2026-02-08T06:41:51.765Z"
},
"keinEntwickler": {
"login": "keinEntwickler",
"twitter": {
"handle": "@keinEntwickler",
"url": "https://x.com/keinEntwickler",
"source": "github",
"verified": true
},
"bluesky": {
"handle": "@keinentwickler.bsky.social",
"url": "https://bsky.app/profile/keinentwickler.bsky.social",
"source": "username-match",
"verified": true
},
"updatedAt": "2026-02-08T06:42:20.907Z"
},
"Fogh": {
"login": "Fogh",
"twitter": {
"handle": "@f0gh",
"url": "https://x.com/f0gh",
"source": "github",
"verified": true
},
"updatedAt": "2026-02-08T06:43:11.711Z"
},
"uraimo": {
"login": "uraimo",
"twitter": {
"handle": "@uraimo",
"url": "https://x.com/uraimo",
"source": "github",
"verified": true
},
"updatedAt": "2026-02-08T06:43:14.159Z"
},
"bjarkehs": {
"login": "bjarkehs",
"twitter": {
"handle": "@bjarkehs",
"url": "https://x.com/bjarkehs",
"source": "github",
"verified": true
},
"updatedAt": "2026-02-08T06:43:29.256Z"
},
"ewilken": {
"login": "ewilken",
"mastodon": {
"handle": "@ewlkn@mastodon.social",
"url": "https://mastodon.social/@ewlkn",
"source": "github",
"verified": true
},
"updatedAt": "2026-02-09T01:23:38.751Z"
}
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 790 KiB

-678
View File
@@ -1,678 +0,0 @@
[
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1739664000
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1740268800
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1740873600
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1741478400
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1742079600
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1742684400
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1743289200
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1743894000
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1744498800
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1745103600
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1745708400
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1746313200
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1746918000
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1747522800
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1748127600
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1748732400
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1749337200
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1749942000
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1750546800
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1751151600
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1751756400
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1752361200
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1752966000
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1753570800
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1754175600
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1754780400
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1755385200
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1755990000
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1756594800
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1757199600
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1757804400
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1758409200
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1759014000
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1759618800
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1760223600
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1760828400
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1761433200
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1762038000
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1762646400
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1763251200
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1763856000
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1764460800
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1765065600
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1765670400
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1766275200
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1766880000
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1767484800
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1768089600
},
{
"days": [
0,
0,
0,
0,
0,
0,
0
],
"total": 0,
"week": 1768694400
},
{
"days": [
0,
0,
0,
49,
69,
68,
47
],
"total": 233,
"week": 1769299200
},
{
"days": [
10,
41,
32,
22,
42,
77,
61
],
"total": 285,
"week": 1769904000
},
{
"days": [
64,
76,
75,
34,
35,
40,
0
],
"total": 324,
"week": 1770508800
}
]
-59
View File
@@ -1,59 +0,0 @@
/**
* Prebuild script: runs before `astro build` via the `prebuild` npm script.
*
* 1. Generates terminal-data.ts from terminal-script.md
* 2. Counts Swift @Test and @Suite annotations to produce project-stats.json
* (consumed by astro.config.mjs as environment variables)
*/
import { parseTerminalScript } from "../src/lib/terminal-parser";
import { execSync } from "child_process";
import fs from "fs";
import path from "path";
// Terminal Data
const script = parseTerminalScript();
const terminalOutputPath = path.join(process.cwd(), "src", "components", "react", "terminal-data.ts");
const tsContent = `/**
* Auto-generated from terminal-script.md
* DO NOT EDIT THIS FILE DIRECTLY - Edit terminal-script.md instead
*/
import type { TerminalScript } from "../../lib/terminal-parser";
export const TERMINAL_SCRIPT: TerminalScript = ${JSON.stringify(script, null, 2)};
`;
fs.writeFileSync(terminalOutputPath, tsContent);
console.log("✓ Generated terminal-data.ts from terminal-script.md");
// Project Stats
const projectRoot = path.resolve(process.cwd(), "..");
const testsDir = path.join(projectRoot, "Tests");
/**
* Count occurrences of a pattern in all .swift files under a directory.
* Falls back to 0 if the directory doesn't exist (e.g. CI without Swift source).
*/
function countPattern(directory: string, pattern: string): number {
if (!fs.existsSync(directory)) return 0;
try {
const output = execSync(
`grep -r '${pattern}' --include='*.swift' "${directory}" | wc -l`,
{ encoding: "utf-8" }
);
return parseInt(output.trim(), 10) || 0;
} catch {
return 0;
}
}
const testCount = countPattern(testsDir, "@Test\\b");
const suiteCount = countPattern(testsDir, "@Suite\\b");
const statsPath = path.join(process.cwd(), "project-stats.json");
fs.writeFileSync(statsPath, JSON.stringify({ testCount, suiteCount }, null, 2));
console.log(`✓ Generated project-stats.json (${testCount} tests, ${suiteCount} suites)`);
-131
View File
@@ -1,131 +0,0 @@
/**
* Extracts plan data from .claude/plans/open/ and .claude/plans/done/ directories.
* Generates plans.json with all open and done plans.
*
* Runs via GitHub Actions (hourly) or manual npm script.
* Output: docs/public/data/plans.json
*/
import fs from "fs";
import path from "path";
interface PlanData {
date: string;
slug: string;
title: string;
preface: string;
status: "open" | "done";
}
/**
* Extract date from filename like "2026-02-06-list-scrollable.md"
*/
function extractDate(filename: string): string {
const match = filename.match(/^(\d{4}-\d{2}-\d{2})/);
return match ? match[1] : "";
}
/**
* Extract slug from filename like "2026-02-06-list-scrollable.md"
*/
function extractSlug(filename: string): string {
const match = filename.match(/^\d{4}-\d{2}-\d{2}-(.+)\.md$/);
return match ? match[1] : "";
}
/**
* Extract H1 title from markdown content
*/
function extractTitle(content: string): string {
const match = content.match(/^#\s+(.+)$/m);
return match ? match[1].trim() : "Untitled";
}
/**
* Extract first ## section (Preface) from markdown content.
* Returns the full text until the next ## section or end of file.
*/
function extractPreface(content: string): string {
// Find first ## section
const match = content.match(/^##\s+Preface\s*\n([\s\S]*?)(?=\n##\s|\Z)/m);
return match ? match[1].trim() : "";
}
/**
* Read all plan files from a directory
*/
function readPlansFromDir(dirPath: string, status: "open" | "done"): PlanData[] {
if (!fs.existsSync(dirPath)) {
console.warn(`Directory not found: ${dirPath}`);
return [];
}
const files = fs.readdirSync(dirPath).filter((f) => f.endsWith(".md"));
return files
.map((filename) => {
const filePath = path.join(dirPath, filename);
const content = fs.readFileSync(filePath, "utf-8");
return {
date: extractDate(filename),
slug: extractSlug(filename),
title: extractTitle(content),
preface: extractPreface(content),
status,
};
})
.filter((plan) => plan.date && plan.slug && plan.preface); // Skip invalid plans
}
/**
* Main execution
*/
function main() {
const projectRoot = path.resolve(process.cwd(), "..");
const openDir = path.join(projectRoot, ".claude", "plans", "open");
const doneDir = path.join(projectRoot, ".claude", "plans", "done");
// Read all plans
const openPlans = readPlansFromDir(openDir, "open");
const donePlans = readPlansFromDir(doneDir, "done");
// Sort by date (newest first)
const sortByDateDesc = (a: PlanData, b: PlanData) =>
new Date(b.date).getTime() - new Date(a.date).getTime();
openPlans.sort(sortByDateDesc);
donePlans.sort(sortByDateDesc);
// Build output with all plans
const output = {
generated: new Date().toISOString(),
open: openPlans.map(({ date, slug, title, preface }) => ({
date,
slug,
title,
preface,
})),
done: donePlans.map(({ date, slug, title, preface }) => ({
date,
slug,
title,
preface,
})),
};
// Ensure output directory exists
const outputDir = path.join(process.cwd(), "public", "data");
fs.mkdirSync(outputDir, { recursive: true });
// Write JSON
const outputPath = path.join(outputDir, "plans.json");
fs.writeFileSync(outputPath, JSON.stringify(output, null, 2));
console.log(
`✓ Generated plans.json (${openPlans.length} open, ${donePlans.length} done)`
);
console.log(` Location: ${outputPath}`);
}
main();
File diff suppressed because it is too large Load Diff
@@ -1,66 +0,0 @@
---
/**
* Animated cloudy background tinted to the active theme color.
*
* Uses CSS custom properties (--accent-glow, --accent-glow-secondary,
* --accent-glow-tertiary) so the clouds automatically recolor on theme switch.
* Each blob has its own animation timing, position, and intensity.
*
* Pure CSS: no JavaScript needed.
*/
interface CloudProps {
shape: "circle" | "ellipse";
innerVar: string;
innerOpacity: number;
outerVar?: string;
outerOpacity?: number;
}
function cloudGradient({ shape, innerVar, innerOpacity, outerVar, outerOpacity }: CloudProps): string {
const inner = `rgba(var(--accent-glow${innerVar}),${innerOpacity})`;
if (outerVar !== undefined && outerOpacity !== undefined) {
const outer = `rgba(var(--accent-glow${outerVar}),${outerOpacity})`;
return `radial-gradient(${shape}, ${inner} 0%, ${outer} 40%, transparent 70%)`;
}
return `radial-gradient(${shape}, ${inner} 0%, transparent 60%)`;
}
---
<div aria-hidden="true" class="cloud-container pointer-events-none fixed inset-0 -z-10 overflow-hidden">
<!-- Large cloud: top left -->
<div
class="cloud-element absolute -left-32 -top-32 h-[800px] w-[800px] rounded-full blur-[150px] transition-[background] duration-700"
style={`background: ${cloudGradient({ shape: "circle", innerVar: "", innerOpacity: 0.50, outerVar: "-secondary", outerOpacity: 0.20 })}; animation: cloud-drift 90s ease-in-out infinite;`}
/>
<!-- Medium cloud: top right -->
<div
class="cloud-element absolute -right-20 top-1/4 h-[700px] w-[700px] rounded-full blur-[130px] transition-[background] duration-700"
style={`background: ${cloudGradient({ shape: "circle", innerVar: "-secondary", innerOpacity: 0.40, outerVar: "-tertiary", outerOpacity: 0.15 })}; animation: cloud-drift-reverse 110s ease-in-out infinite;`}
/>
<!-- Accent cloud: center -->
<div
class="cloud-element absolute left-1/3 top-1/2 h-[600px] w-[600px] rounded-full blur-[120px] transition-[background] duration-700"
style={`background: ${cloudGradient({ shape: "circle", innerVar: "", innerOpacity: 0.35, outerVar: "-secondary", outerOpacity: 0.12 })}; animation: cloud-drift-slow 120s ease-in-out infinite;`}
/>
<!-- Bottom-left glow -->
<div
class="cloud-element absolute -bottom-40 -left-20 h-[700px] w-[700px] rounded-full blur-[140px] transition-[background] duration-700"
style={`background: ${cloudGradient({ shape: "circle", innerVar: "-tertiary", innerOpacity: 0.38, outerVar: "-secondary", outerOpacity: 0.14 })}; animation: cloud-drift-reverse 100s ease-in-out infinite; animation-delay: -30s;`}
/>
<!-- Wide upper cloud for depth -->
<div
class="cloud-element absolute left-1/2 -top-20 h-[500px] w-[900px] -translate-x-1/2 rounded-full blur-[150px] transition-[background] duration-700"
style={`background: ${cloudGradient({ shape: "ellipse", innerVar: "", innerOpacity: 0.30 })}; animation: cloud-drift-slow 130s ease-in-out infinite; animation-delay: -50s;`}
/>
<!-- Extra cloud: bottom right -->
<div
class="cloud-element absolute -bottom-20 -right-32 h-[600px] w-[600px] rounded-full blur-[130px] transition-[background] duration-700"
style={`background: ${cloudGradient({ shape: "circle", innerVar: "-secondary", innerOpacity: 0.32, outerVar: "", outerOpacity: 0.10 })}; animation: cloud-drift 105s ease-in-out infinite; animation-delay: -40s;`}
/>
</div>
@@ -1,23 +0,0 @@
---
/**
* A feature highlight card with icon, title, and description.
* Pure Astro component: no client-side JavaScript.
*/
import Icon from "../react/Icon";
interface Props {
icon: Parameters<typeof Icon>[0]["name"];
title: string;
description: string;
}
const { icon, title, description } = Astro.props;
---
<div class="group rounded-xl border border-border bg-frosted-glass p-6 backdrop-blur-xl transition-all duration-300 hover:border-accent/30">
<div class="mb-3 flex items-center gap-3">
<Icon name={icon} size={24} className="text-accent" />
<h3 class="text-xl font-semibold text-foreground">{title}</h3>
</div>
<p class="text-lg leading-relaxed text-muted">{description}</p>
</div>
@@ -1,28 +0,0 @@
---
/**
* Shared site footer used by all pages.
*
* Displays location info. Accepts optional children for page-specific
* additions (e.g. API rate limit display).
*/
interface Props {
class?: string;
}
const { class: className } = Astro.props;
---
<footer class:list={["border-t border-border bg-container-body/30 backdrop-blur-sm", className]}>
<div class="mx-auto flex max-w-6xl flex-col items-center gap-0.5 px-6 py-8 text-center">
<span class="text-base text-muted">
Made with &#10084;&#65039; in Bregenz
</span>
<span class="text-base text-muted">
at Lake Constance
</span>
<span class="text-base text-muted">
Austria
</span>
<slot />
</div>
</footer>
-285
View File
@@ -1,285 +0,0 @@
import { useState, useEffect } from "react";
import { createPortal } from "react-dom";
import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
const CODE = `import TUIkit
struct ContentView: View {
@State private var count = 0
@State private var selected: String?
var body: some View {
VStack {
Spacer()
Text("Welcome to TUIkit")
.bold()
.foregroundStyle(.palette.accent)
.padding(.bottom)
HStack {
Button("Increment") { count += 1 }
Text("Count: \\(count)")
}
Spacer()
List("Items", selection: $selected) {
ForEach(["Alpha", "Beta", "Gamma", "Delta"], id: \\.self) { item in
Text(item)
}
}
.frame(width: 21)
Spacer()
Spacer()
}
.padding()
.appHeader {
HStack {
Text("My TUIkit App").bold()
Spacer()
Text("v1.0")
}
}
.statusBarSystemItems(
theme: true,
appearance: true
)
}
}`;
/** A Swift code block with copy-to-clipboard, preview overlay, and minimal syntax highlighting. */
export default function CodePreview() {
const { copied, copy } = useCopyToClipboard();
const [showPreview, setShowPreview] = useState(false);
const [isVisible, setIsVisible] = useState(false);
// Handle ESC key to close preview
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && showPreview) {
closePreview();
}
};
if (showPreview) {
document.addEventListener("keydown", handleKeyDown);
// Trigger fade-in after mount
requestAnimationFrame(() => setIsVisible(true));
}
return () => document.removeEventListener("keydown", handleKeyDown);
}, [showPreview]);
const openPreview = () => {
setShowPreview(true);
};
const closePreview = () => {
setIsVisible(false);
// Wait for fade-out animation before removing from DOM
setTimeout(() => setShowPreview(false), 200);
};
// Render overlay in a portal to escape overflow:hidden
const overlay = showPreview && typeof document !== "undefined"
? createPortal(
<div
className={`fixed inset-0 z-50 flex items-center justify-center cursor-pointer transition-all duration-300 ease-out ${
isVisible ? "backdrop-blur-md" : "backdrop-blur-0"
}`}
onClick={closePreview}
>
<div
className={`relative max-w-6xl max-h-[95vh] p-4 transition-all duration-300 ease-out ${
isVisible ? "opacity-100 scale-100" : "opacity-0 scale-75"
}`}
onClick={(e) => e.stopPropagation()}
>
<div className="relative">
<img
src="/images/preview.png"
alt="Terminal preview of the code example"
className="w-[1174px] h-[877px] max-w-none"
/>
<div className="absolute bottom-0 left-0 right-0 flex justify-center">
<p className="text-lg text-foreground bg-black/30 px-6 py-3 rounded-lg">The executed code running in Terminal</p>
</div>
</div>
</div>
</div>,
document.body
)
: null;
return (
<>
{overlay}
<div
className="group relative w-full overflow-hidden rounded-xl border border-border bg-frosted-glass backdrop-blur-xl"
>
{/* Header bar */}
<div className="flex items-center justify-between border-b border-border px-4 py-2.5">
<div className="flex items-center gap-2">
<div className="flex gap-1.5">
<div className="h-3 w-3 rounded-full bg-[#ff5f57]" />
<div className="h-3 w-3 rounded-full bg-[#febc2e]" />
<div className="h-3 w-3 rounded-full bg-[#28c840]" />
</div>
<span className="ml-3 text-xs text-muted">MyApp.swift</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={openPreview}
className="rounded-md px-2.5 py-1 text-xs text-muted transition-colors hover:bg-foreground/5 hover:text-foreground focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background"
aria-label="Show terminal preview"
>
Preview
</button>
<button
onClick={() => copy(CODE)}
className="rounded-md px-2.5 py-1 text-xs text-muted transition-colors hover:bg-foreground/5 hover:text-foreground focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
{copied ? "Copied!" : "Copy"}
</button>
</div>
</div>
{/* Code content: max 21 lines visible, scroll for rest */}
<pre className="overflow-auto p-5 text-base leading-relaxed" style={{ maxHeight: "calc(21 * 1.625em + 2.5rem)" }}>
<code>
<Highlight code={CODE} showLineNumbers />
</code>
</pre>
</div>
</>
);
}
/** Syntax highlight color palette: One Dark inspired. */
const HIGHLIGHT = {
comment: "#6a737d",
decorator: "#d19a66",
string: "#98c379",
modifier: "#61afef",
keyword: "#c678dd",
type: "#e5c07b",
} as const;
/** Minimal Swift syntax highlighter: no external dependency. */
function Highlight({ code, showLineNumbers = false }: { code: string; showLineNumbers?: boolean }) {
const lines = code.split("\n");
const lineNumberWidth = String(lines.length).length;
return (
<>
{lines.map((line, lineIndex) => (
<span key={lineIndex} className="flex">
{showLineNumbers && (
<span
className="select-none pr-4 text-right text-muted/40"
style={{ minWidth: `${lineNumberWidth + 1}ch` }}
>
{lineIndex + 1}
</span>
)}
<span className="flex-1">
{tokenizeLine(line)}
{lineIndex < lines.length - 1 && "\n"}
</span>
</span>
))}
</>
);
}
function tokenizeLine(line: string) {
// Comments take precedence
const commentMatch = line.match(/^(.*?)(\/\/.*)$/);
if (commentMatch) {
const [, before, comment] = commentMatch;
return (
<>
{tokenizeSegment(before)}
<span style={{ color: HIGHLIGHT.comment }}>{comment}</span>
</>
);
}
return tokenizeSegment(line);
}
function tokenizeSegment(segment: string) {
// Build a combined regex for all token types
const combined =
/(@\w+)|("(?:[^"\\]|\\.)*")|(\\\.self)|(\.\w+)\(|\b(struct|var|some|func|import|let|return|if|else|for|in|while|switch|case|default|class|protocol|enum|init|self|true|false|nil|private|public|internal|isOn|label|shortcut|id)\b|\b(App|Scene|WindowGroup|VStack|HStack|Text|Button|View|String|Int|Bool|Never|Toggle|List|ForEach|State|StatusBarItem)\b/g;
const parts: React.ReactNode[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
// Reset pattern state
combined.lastIndex = 0;
while ((match = combined.exec(segment)) !== null) {
// Add text before match
if (match.index > lastIndex) {
parts.push(segment.slice(lastIndex, match.index));
}
if (match[1]) {
// Decorator (@main, @State)
parts.push(
<span key={match.index} style={{ color: HIGHLIGHT.decorator }}>
{match[1]}
</span>
);
} else if (match[2]) {
// String literal
parts.push(
<span key={match.index} style={{ color: HIGHLIGHT.string }}>
{match[2]}
</span>
);
} else if (match[3]) {
// KeyPath (\.self)
parts.push(
<span key={match.index} style={{ color: HIGHLIGHT.modifier }}>
{match[3]}
</span>
);
} else if (match[4]) {
// Modifier (.bold, .foregroundColor): add dot+name, then the ( back
parts.push(
<span key={match.index} style={{ color: HIGHLIGHT.modifier }}>
{match[4]}
</span>
);
parts.push("(");
} else if (match[5]) {
// Keyword
parts.push(
<span key={match.index} style={{ color: HIGHLIGHT.keyword }}>
{match[5]}
</span>
);
} else if (match[6]) {
// Type
parts.push(
<span key={match.index} style={{ color: HIGHLIGHT.type }}>
{match[6]}
</span>
);
}
lastIndex = combined.lastIndex;
}
// Remaining text
if (lastIndex < segment.length) {
parts.push(segment.slice(lastIndex));
}
return <>{parts}</>;
}
-500
View File
@@ -1,500 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { Howl } from "howler";
import TerminalScreen from "./TerminalScreen";
/** Lazy-loaded Howl factory to avoid bundling howler.js on initial load. */
let HowlClass: typeof Howl | null = null;
async function createHowl(options: { src: string[]; volume: number; loop?: boolean }): Promise<Howl> {
if (!HowlClass) {
const module = await import("howler");
HowlClass = module.Howl;
}
return new HowlClass(options);
}
/**
* CRT layer geometry: centralizes the repeated calc() strings used
* to position backing, content, glow, and glass layers over the logo.
*/
const CRT = {
/** Content area (terminal text). */
content: { top: "calc(14% + 2px)", left: "21%", width: "58%", height: "45%" },
/** Backing surface (black fill behind the transparent logo center). */
backing: { top: "calc(14% + 2px - 13px)", left: "calc(21% - 10px)", width: "calc(58% + 20px)", height: "calc(45% + 20px)", borderRadius: "31px" },
/** Glow overlay (edge vignette + scanline sweep). */
glow: { top: "calc(14% + 2px - 18px)", left: "calc(21% - 15px)", width: "calc(58% + 30px)", height: "calc(45% + 30px)", borderRadius: "31px" },
} as const;
/**
* CRT power-on animation timing.
* The vertical deflection coils need a moment to reach full amplitude,
* so the image starts as a bright horizontal line and expands vertically.
* Less dramatic than power-off: no visible dot phase.
*/
const CRT_EXPAND_VERTICAL_MS = 300;
/**
* CRT power-off animation timing.
* Real CRTs collapse the image vertically to a bright horizontal line,
* then horizontally to a glowing dot, which slowly fades as the phosphor
* afterglow decays.
*/
const CRT_COLLAPSE_VERTICAL_MS = 150;
const CRT_COLLAPSE_HORIZONTAL_MS = 200;
const CRT_AFTERGLOW_MS = 600;
const CRT_SHUTDOWN_TOTAL_MS =
CRT_COLLAPSE_VERTICAL_MS + CRT_COLLAPSE_HORIZONTAL_MS + CRT_AFTERGLOW_MS;
/** Delay before boot spin loop starts (slightly before boot audio ends). */
const SPIN_START_DELAY_MS = 19900;
/** Delay before random seek sounds begin (after boot finishes). */
const SEEK_START_DELAY_MS = 20300;
/**
* Interactive hero terminal with power-on animation.
*
* Initially shows the CRT logo at 320x320. When the user clicks the red
* power button, the logo zooms to center screen at double size, the
* background dims, and the full terminal boot sequence begins.
*
* Uses CSS `transform: scale()` for the zoom animation so the element
* animates smoothly from its inline position to viewport center.
*/
export default function HeroTerminal() {
const [powered, setPowered] = useState(false);
const [zoomed, setZoomed] = useState(false);
const [mounted, setMounted] = useState(false);
/**
* CRT boot phase: null (not booting), or a phase number.
* 1 = horizontal line (scaleY~0), 2 = expanding vertically to full image.
*/
const [bootPhase, setBootPhase] = useState<1 | 2 | null>(null);
/**
* CRT shutdown phase: null (not shutting down), or a phase number.
* 1 = vertical collapse (scaleY -> 0), 2 = horizontal collapse (scaleX -> 0),
* 3 = afterglow dot fading out.
*/
const [shutdownPhase, setShutdownPhase] = useState<1 | 2 | 3 | null>(null);
/** Guards against rapid double-clicks bypassing the `powered` state check. */
const poweringOnRef = useRef(false);
const containerRef = useRef<HTMLDivElement>(null);
/** Offset to translate the element from its inline position to viewport center. */
const [centerOffset, setCenterOffset] = useState({ x: 0, y: 0 });
/** Audio references for hard drive sounds. */
const powerOnAudioRef = useRef<Howl | null>(null);
const bootAudioRef = useRef<Howl | null>(null);
const spinAudioRef = useRef<Howl | null>(null);
const powerOffAudioRef = useRef<Howl | null>(null);
/** Reusable seek sound: avoids creating new Howl instances per seek. */
const seekAudioRef = useRef<Howl | null>(null);
/** Tracks all pending setTimeout handles for cleanup on power-off/unmount. */
const pendingTimersRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
/** Helper: schedule a timeout and track it for cleanup. */
const scheduleTimer = useCallback((callback: () => void, delayMs: number) => {
const handle = setTimeout(() => {
pendingTimersRef.current.delete(handle);
callback();
}, delayMs);
pendingTimersRef.current.add(handle);
return handle;
}, []);
/** Helper: clear all pending timers. */
const clearAllTimers = useCallback(() => {
for (const handle of pendingTimersRef.current) clearTimeout(handle);
pendingTimersRef.current.clear();
}, []);
/** Whether the remaining (non-critical) audio has been loaded. */
const remainingAudioLoadedRef = useRef(false);
/** Eagerly preload Howler.js + critical sounds (power-on, boot) on mount. */
useEffect(() => {
let cancelled = false;
(async () => {
if (!HowlClass) {
const module = await import("howler");
HowlClass = module.Howl;
}
if (cancelled) return;
// Only preload if not already created (e.g. by a quick power-on click)
if (!powerOnAudioRef.current) {
powerOnAudioRef.current = new HowlClass({ src: ["/sounds/power-on.mp3"], volume: 0.3, preload: true });
}
if (!bootAudioRef.current) {
bootAudioRef.current = new HowlClass({ src: ["/sounds/hard-drive-boot.m4a"], volume: 0.6, preload: true });
}
})();
return () => { cancelled = true; };
}, []);
/** Lazy-load remaining audio (spin, power-off, seek) on first power-on click. */
const ensureRemainingAudioLoaded = useCallback(async () => {
if (remainingAudioLoadedRef.current) return;
remainingAudioLoadedRef.current = true;
const [spin, powerOff, seek] = await Promise.all([
createHowl({ src: ["/sounds/hard-drive-spin.m4a"], volume: 0.6, loop: true }),
createHowl({ src: ["/sounds/hard-drive-power-off.m4a"], volume: 0.6 }),
createHowl({ src: ["/sounds/hard-drive-seek1.m4a"], volume: 0.4 }),
]);
spinAudioRef.current = spin;
powerOffAudioRef.current = powerOff;
seekAudioRef.current = seek;
}, []);
// Cleanup audio on unmount
useEffect(() => {
return () => {
clearAllTimers();
[powerOnAudioRef, bootAudioRef, spinAudioRef, powerOffAudioRef, seekAudioRef].forEach(ref => {
if (ref.current) {
ref.current.stop();
ref.current.unload();
}
});
};
}, [clearAllTimers]);
// Handle client-side hydration for power button and detect phone-sized screens
const [isPhone, setIsPhone] = useState(false);
useEffect(() => {
setMounted(true);
// Check if phone (< 768px): tablets and larger keep the power button
const checkPhone = () => setIsPhone(window.innerWidth < 768);
checkPhone();
window.addEventListener("resize", checkPhone);
return () => window.removeEventListener("resize", checkPhone);
}, []);
/** Computes the translation needed to center the element in the viewport. */
const computeCenterOffset = useCallback(() => {
const element = containerRef.current;
if (!element) return { x: 0, y: 0 };
const rect = element.getBoundingClientRect();
const elementCenterX = rect.left + rect.width / 2;
const elementCenterY = rect.top + rect.height / 2;
const viewportCenterX = window.innerWidth / 2;
const viewportCenterY = window.innerHeight / 2;
return {
x: viewportCenterX - elementCenterX,
y: viewportCenterY - elementCenterY,
};
}, []);
/** Power on: CRT raster expansion, then play boot sound, start spin loop, add random seeks. */
const handlePowerOn = useCallback(() => {
if (powered || poweringOnRef.current) return;
poweringOnRef.current = true;
// Play critical sounds immediately (preloaded on mount)
if (powerOnAudioRef.current) {
powerOnAudioRef.current.seek(0);
powerOnAudioRef.current.play();
}
if (bootAudioRef.current) {
bootAudioRef.current.seek(0);
bootAudioRef.current.play();
}
// Lazy-load remaining sounds (spin, power-off, seek) without blocking
ensureRemainingAudioLoaded();
// Start with horizontal line, then expand vertically
setBootPhase(1);
setPowered(true);
setCenterOffset(computeCenterOffset());
// Phase 2: vertical expansion (CSS transition handles the animation)
// Use requestAnimationFrame to ensure phase 1 is painted first
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setBootPhase(2);
});
});
// Clear boot phase after expansion completes
scheduleTimer(() => setBootPhase(null), CRT_EXPAND_VERTICAL_MS + 50);
// Start gapless spin loop slightly before boot ends for seamless transition
scheduleTimer(() => {
spinAudioRef.current?.play();
}, SPIN_START_DELAY_MS);
// Recursive seek scheduling: each invocation picks a fresh random delay.
// All timeouts go through scheduleTimer so clearAllTimers catches them.
const scheduleNextSeek = () => {
scheduleTimer(() => {
seekAudioRef.current?.seek(0);
seekAudioRef.current?.play();
// 30% chance for a second seek shortly after
if (Math.random() < 0.3) {
scheduleTimer(() => {
seekAudioRef.current?.seek(0);
seekAudioRef.current?.play();
}, 200 + Math.random() * 200);
}
scheduleNextSeek();
}, 15000 + Math.random() * 10000);
};
// Start seek loop after boot finishes
scheduleTimer(scheduleNextSeek, SEEK_START_DELAY_MS);
// Zoom after 200ms delay
scheduleTimer(() => setZoomed(true), 200);
}, [powered, computeCenterOffset, scheduleTimer, ensureRemainingAudioLoaded]);
/** Power off: run CRT shutdown animation, then zoom back and kill power. */
const handlePowerOff = useCallback(() => {
if (shutdownPhase !== null) return; // Already shutting down
clearAllTimers();
poweringOnRef.current = false;
// Stop all running sounds
powerOnAudioRef.current?.stop();
bootAudioRef.current?.stop();
spinAudioRef.current?.stop();
// Play power-off sound
if (powerOffAudioRef.current) {
powerOffAudioRef.current.seek(0);
powerOffAudioRef.current.play();
}
// Phase 1: Vertical collapse
setShutdownPhase(1);
// Phase 2: Horizontal collapse
scheduleTimer(() => setShutdownPhase(2), CRT_COLLAPSE_VERTICAL_MS);
// Phase 3: Afterglow dot
scheduleTimer(
() => setShutdownPhase(3),
CRT_COLLAPSE_VERTICAL_MS + CRT_COLLAPSE_HORIZONTAL_MS,
);
// Complete: kill power immediately (screen is already black),
// then zoom back. Order matters: powered must be false before
// shutdownPhase clears, otherwise the content flashes back briefly.
scheduleTimer(() => {
setPowered(false);
setShutdownPhase(null);
setZoomed(false);
}, CRT_SHUTDOWN_TOTAL_MS);
}, [shutdownPhase, clearAllTimers, scheduleTimer]);
/** Close on Escape key. */
useEffect(() => {
if (!zoomed) return;
const handleKey = (event: KeyboardEvent) => {
if (event.key === "Escape") handlePowerOff();
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [zoomed, handlePowerOff]);
return (
<>
{/* Dimming overlay: behind the zoomed terminal */}
<div
className="fixed inset-0 z-[100] bg-black/80 backdrop-blur-sm transition-opacity duration-500"
style={{
opacity: zoomed ? 1 : 0,
pointerEvents: zoomed ? "auto" : "none",
}}
onClick={handlePowerOff}
/>
{/* Terminal container: uses transform for smooth zoom from inline position */}
<div
ref={containerRef}
className="relative transition-all duration-500 ease-in-out"
style={{
width: 320,
height: 320,
zIndex: zoomed ? 200 : 1,
transform: zoomed
? `translate(${centerOffset.x}px, ${centerOffset.y}px) scale(2)`
: "translate(0, 0) scale(1)",
}}
>
{/* Layer 1: Black backing surface: behind the transparent frame center */}
<div
className="absolute"
style={{
...CRT.backing,
background: "#000",
zIndex: 1,
}}
/>
{/* Layer 2: Terminal content: above backing, below glow.
Boot-up: starts as bright horizontal line (scaleY~0), expands vertically.
Shutdown: collapses vertically -> horizontally -> afterglow dot. */}
<div
className="pointer-events-none absolute overflow-hidden"
style={{
...CRT.content,
zIndex: 2,
/* Boot animation */
...(bootPhase === 1 ? {
transform: "scaleY(0.008)",
filter: `brightness(3) drop-shadow(0 0 6px rgba(var(--accent-glow), 0.9))`,
} : bootPhase === 2 ? {
transform: "scaleY(1)",
transition: `transform ${CRT_EXPAND_VERTICAL_MS}ms ease-out, filter ${CRT_EXPAND_VERTICAL_MS}ms ease-out`,
filter: "brightness(1)",
} : {}),
/* Shutdown animation (overrides boot if both somehow active) */
...(shutdownPhase === 1 ? {
transform: "scaleY(0.005)",
transition: `transform ${CRT_COLLAPSE_VERTICAL_MS}ms ease-in`,
filter: `brightness(2.5) drop-shadow(0 0 8px rgba(var(--accent-glow), 0.8))`,
} : shutdownPhase === 2 ? {
transform: "scaleY(0.005) scaleX(0)",
transition: `transform ${CRT_COLLAPSE_HORIZONTAL_MS}ms ease-in`,
filter: `brightness(2.5) drop-shadow(0 0 8px rgba(var(--accent-glow), 0.8))`,
} : shutdownPhase === 3 ? {
transform: "scale(0)",
} : {}),
}}
>
<TerminalScreen powered={powered} />
</div>
{/* CRT afterglow dot: bright phosphor dot that fades after the image collapses */}
{shutdownPhase === 3 && (
<div
className="pointer-events-none absolute"
style={{
...CRT.content,
zIndex: 2,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<div
style={{
width: 6,
height: 6,
borderRadius: "50%",
background: `rgba(var(--accent-glow), 0.9)`,
boxShadow: `0 0 12px 6px rgba(var(--accent-glow), 0.6), 0 0 30px 12px rgba(var(--accent-glow), 0.3)`,
animation: `crt-afterglow ${CRT_AFTERGLOW_MS}ms ease-out forwards`,
}}
/>
</div>
)}
{/* Layer 3: CRT edge glow + scanline sweep: above content, below frame.
Inner glow simulates the edge darkening of a real CRT monitor.
Only visible when powered on and not shutting down. */}
<div
className="pointer-events-none absolute"
style={{
...CRT.glow,
boxShadow: powered && !shutdownPhase
? "inset 0 0 16px 7px rgba(var(--accent-glow), 0.42), inset 0 0 38px 14px rgba(var(--accent-glow), 0.15)"
: "none",
overflow: "hidden",
zIndex: 3,
transition: `box-shadow ${CRT_COLLAPSE_VERTICAL_MS}ms ease-out`,
}}
>
{/* Scanline sweep: cathode ray with sharp bottom edge, trailing upward */}
{powered && (
<div
style={{
position: "absolute",
left: 0,
top: 0,
width: "100%",
height: "150px",
animation: "crt-scanline-move 15s linear infinite",
}}
>
{/* Trailing glow above - blurred */}
<div
style={{
position: "absolute",
left: 0,
top: 0,
width: "100%",
height: "100%",
background:
"linear-gradient(to bottom, transparent 0%, rgba(var(--accent-glow), 0.01) 40%, rgba(var(--accent-glow), 0.02) 70%, rgba(var(--accent-glow), 0.04) 90%, rgba(var(--accent-glow), 0.05) 100%)",
filter: "blur(3px)",
}}
/>
{/* Sharp bottom edge - no blur */}
<div
style={{
position: "absolute",
left: 0,
bottom: 0,
width: "100%",
height: "2px",
background: "rgba(var(--accent-glow), 0.04)",
}}
/>
</div>
)}
</div>
{/* Layer 4: CRT glass sheen: permanent specular highlight on curved glass */}
<div
className="pointer-events-none absolute"
style={{
...CRT.backing,
background: [
/* Diagonal specular highlight: light reflecting off convex glass with theme tint */
"linear-gradient(135deg, rgba(var(--accent-glow), 0.15) 0%, rgba(var(--accent-glow), 0.05) 35%, transparent 60%)",
/* Soft edge vignette: darkens toward edges like curved glass */
"radial-gradient(ellipse 80% 80% at 48% 45%, rgba(var(--accent-glow), 0.03) 0%, rgba(60,60,70,0.4) 100%)",
].join(", "),
zIndex: 4,
}}
/>
{/* CRT Monitor frame: on top of everything, transparent center reveals content */}
<img
src="/tuikit-logo.png"
alt="TUIkit Logo"
width={640}
height={640}
className="relative h-full w-full rounded-3xl"
style={{ objectFit: "contain", zIndex: 5 }}
/>
{/* Red power button: positioned over the physical button in the logo.
Disabled on phones (< 768px) to prevent the zoomed view on small screens. */}
{mounted && !isPhone && (
<button
onClick={powered ? handlePowerOff : handlePowerOn}
className="absolute z-10 cursor-pointer rounded-none bg-transparent p-0 transition-all duration-300"
style={{
/* Button position on the CRT monitor (bottom-left area). */
bottom: zoomed ? "24.9%" : "24.6%",
left: zoomed ? "24.1%" : "23.8%",
width: zoomed ? "3.9%" : "4.5%",
height: zoomed ? "3.9%" : "4.5%",
/* Glow effect when powered on. */
boxShadow: powered
? "0 0 6px 2px rgba(255, 50, 50, 0.8), 0 0 14px 5px rgba(255, 50, 50, 0.4)"
: "none",
}}
aria-label={powered ? "Power off terminal" : "Power on terminal"}
title={powered ? "Power off" : "Power on"}
/>
)}
</div>
</>
);
}
-126
View File
@@ -1,126 +0,0 @@
import {
IconTerminal2,
IconBrush,
IconKeyboardFilled,
IconStack2Filled,
IconBoltFilled,
IconFileTextFilled,
IconEyeFilled,
IconArrowsExchange,
IconCircleCheckFilled,
IconBookFilled,
IconCode,
IconChevronRight,
IconClockFilled,
IconCalendarFilled,
IconRefresh,
IconChartBar,
IconHash,
IconStarFilled,
IconGitPullRequest,
IconGitMerge,
IconGitBranch,
IconTagFilled,
IconUsers,
IconPackage,
IconList,
IconServer,
IconMessageFilled,
IconMenu2,
IconX,
IconCopy,
IconBrandSwift,
IconBrandMastodon,
IconBrandX,
IconBrandBluesky,
IconBrandGithubFilled,
} from "@tabler/icons-react";
import type { Icon as TablerIcon } from "@tabler/icons-react";
import { siXcode } from "simple-icons";
/** Map icon names to Tabler Icons components (filled where available). */
const tablerIcons: Record<string, TablerIcon> = {
terminal: IconTerminal2,
paintbrush: IconBrush,
keyboard: IconKeyboardFilled,
stack: IconStack2Filled,
bolt: IconBoltFilled,
document: IconFileTextFilled,
eye: IconEyeFilled,
arrows: IconArrowsExchange,
checkmark: IconCircleCheckFilled,
book: IconBookFilled,
code: IconCode,
chevronRight: IconChevronRight,
clock: IconClockFilled,
calendar: IconCalendarFilled,
refresh: IconRefresh,
chart: IconChartBar,
numberCircle: IconHash,
star: IconStarFilled,
pullRequest: IconGitPullRequest,
merge: IconGitMerge,
branch: IconGitBranch,
tag: IconTagFilled,
person2: IconUsers,
shippingbox: IconPackage,
listBullet: IconList,
serverRack: IconServer,
issue: IconMessageFilled,
line3Horizontal: IconMenu2,
xmark: IconX,
copy: IconCopy,
swift: IconBrandSwift,
mastodon: IconBrandMastodon,
twitter: IconBrandX,
bluesky: IconBrandBluesky,
github: IconBrandGithubFilled,
} as const;
/** Simple Icons for brand logos not in Tabler. */
const simpleIcons = {
xcode: siXcode,
} as const;
export type IconName = keyof typeof tablerIcons | keyof typeof simpleIcons;
interface IconProps {
name: IconName;
size?: number;
className?: string;
}
/** Decorative icon wrapper: hidden from screen readers since adjacent text conveys meaning. */
export default function Icon({ name, size = 24, className }: IconProps) {
// Check Simple Icons first
if (name in simpleIcons) {
const icon = simpleIcons[name as keyof typeof simpleIcons];
return (
<span aria-hidden="true" className={className}>
<svg
role="img"
viewBox="0 0 24 24"
width={size}
height={size}
fill="currentColor"
>
<path d={icon.path} />
</svg>
</span>
);
}
// Fall back to Tabler Icons
const TablerIconComponent = tablerIcons[name];
if (!TablerIconComponent) {
console.warn(`Icon "${name}" not found`);
return null;
}
return (
<span aria-hidden="true" className={className}>
<TablerIconComponent size={size} stroke={1.5} />
</span>
);
}
@@ -1,30 +0,0 @@
import { IconBrandSwift, IconCopy, IconCheck } from "@tabler/icons-react";
import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
const VERSION = import.meta.env.PUBLIC_TUIKIT_VERSION ?? "0.1.0";
const PACKAGE_LINE = `.package(url: "https://github.com/phranck/TUIkit.git", from: "${VERSION}")`;
/** SPM package dependency badge with copy-to-clipboard. */
export default function PackageBadge() {
const { copied, copy } = useCopyToClipboard();
return (
<div className="flex w-full items-center justify-between gap-2 rounded-full border border-border bg-container-body/50 px-4 py-2 text-muted backdrop-blur-sm">
<code className="font-mono text-lg text-glow" style={{ color: "var(--foreground)" }}>
{PACKAGE_LINE}
</code>
<button
onClick={() => copy(PACKAGE_LINE)}
aria-label="Copy to clipboard"
className="ml-1 rounded-md p-1.5 text-muted transition-colors hover:bg-foreground/10 hover:text-foreground focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background shrink-0"
>
{copied ? (
<IconCheck size={20} className="text-accent" />
) : (
<IconCopy size={20} />
)}
</button>
</div>
);
}
-152
View File
@@ -1,152 +0,0 @@
import { useEffect, useRef } from "react";
/** A single raindrop with position, speed, and visual properties. */
interface Raindrop {
/** Horizontal position in pixels. */
xPos: number;
/** Vertical position in pixels. */
yPos: number;
/** Fall speed in pixels per frame. */
speed: number;
/** Streak length in pixels. */
length: number;
/** Opacity (0-1). */
opacity: number;
/** Wind drift in pixels per frame (positive = right). */
drift: number;
/** Stroke width in pixels (fixed per drop to avoid per-frame jitter). */
strokeWidth: number;
}
/** Number of simultaneous raindrops. */
const DROP_COUNT = 120;
/**
* Full-screen rain overlay rendered on a canvas element.
*
* Draws subtle, semi-transparent diagonal streaks that fall continuously,
* tinted to the current theme color via CSS custom properties. The canvas
* covers the entire viewport and is pointer-events-none so it never
* interferes with page interaction.
*/
export default function RainOverlay() {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
// Skip canvas animation entirely for users who prefer reduced motion.
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
// Skip on mobile devices (expensive on iOS Safari).
if (window.matchMedia("(max-width: 768px)").matches) return;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let animationId: number;
let lastFrameTime = 0;
/** Target ~30fps (33ms between frames) to halve GPU/CPU usage. */
const FRAME_INTERVAL_MS = 33;
/** Resize canvas to fill viewport. */
const resize = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
};
resize();
window.addEventListener("resize", resize);
/** Create a raindrop with random properties. */
const createDrop = (startAtTop = false): Raindrop => ({
xPos: Math.random() * (canvas.width + 100) - 50,
yPos: startAtTop ? -Math.random() * canvas.height : Math.random() * canvas.height,
speed: 2 + Math.random() * 4,
length: 15 + Math.random() * 25,
opacity: 0.1 + Math.random() * 0.18,
drift: (Math.random() - 0.5) * 0.4,
strokeWidth: 0.8 + Math.random() * 0.5,
});
/** Initialize drop pool: spread across the full viewport. */
const drops: Raindrop[] = Array.from({ length: DROP_COUNT }, () => createDrop(false));
/**
* Reads the current --accent-glow CSS variable (R, G, B triplet)
* so rain color follows the active theme.
*/
const readThemeColor = (): string => {
const raw = getComputedStyle(document.documentElement)
.getPropertyValue("--accent-glow")
.trim();
return raw || "102, 255, 102";
};
let themeColor = readThemeColor();
/** Watch for theme changes via data-theme attribute on <html>. */
const observer = new MutationObserver(() => {
themeColor = readThemeColor();
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme"],
});
/** Animation loop: clear, update, draw. Capped at ~30fps. */
const frame = (timestamp: number) => {
if (timestamp - lastFrameTime < FRAME_INTERVAL_MS) {
animationId = requestAnimationFrame(frame);
return;
}
lastFrameTime = timestamp;
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const drop of drops) {
/* Draw streak. */
ctx.beginPath();
ctx.moveTo(drop.xPos, drop.yPos);
ctx.lineTo(
drop.xPos + drop.drift * (drop.length / drop.speed),
drop.yPos + drop.length
);
ctx.strokeStyle = `rgba(${themeColor}, ${drop.opacity})`;
ctx.lineWidth = drop.strokeWidth;
ctx.stroke();
/* Move drop. */
drop.yPos += drop.speed;
drop.xPos += drop.drift;
/* Reset when off screen. */
if (drop.yPos > canvas.height + drop.length) {
drop.xPos = Math.random() * (canvas.width + 100) - 50;
drop.yPos = -drop.length;
drop.speed = 2 + Math.random() * 4;
drop.opacity = 0.1 + Math.random() * 0.18;
}
}
animationId = requestAnimationFrame(frame);
};
animationId = requestAnimationFrame(frame);
return () => {
cancelAnimationFrame(animationId);
observer.disconnect();
window.removeEventListener("resize", resize);
};
}, []);
return (
<canvas
ref={canvasRef}
aria-hidden="true"
className="pointer-events-none fixed inset-0"
style={{ zIndex: 0 }}
/>
);
}
-162
View File
@@ -1,162 +0,0 @@
import { useState } from "react";
import Icon from "./Icon";
import ThemeSwitcher from "./ThemeSwitcher";
import { ThemeProvider } from "./ThemeProvider";
/** Identifies which page is currently active in the navigation. */
export type ActivePage = "home" | "dashboard";
interface SiteNavProps {
/** Which nav item to highlight as active. */
activePage?: ActivePage;
}
/** Navigation link definition. */
interface NavLink {
href: string;
label: string;
icon?: Parameters<typeof Icon>[0]["name"];
external?: boolean;
/** If set, this link is rendered as active text (not a link) when matching. */
page?: ActivePage;
}
const NAV_LINKS: NavLink[] = [
{ href: "/dashboard", label: "Dashboard", icon: "chart", page: "dashboard" },
{ href: "/documentation/tuikit", label: "Docs", icon: "book" },
{ href: "https://github.com/phranck/TUIkit", label: "GitHub", icon: "github", external: true },
];
/**
* Navigation bar content (requires ThemeProvider wrapper).
*/
function SiteNavContent({ activePage }: SiteNavProps) {
const [menuOpen, setMenuOpen] = useState(false);
return (
<nav aria-label="Main navigation" className="fixed top-0 z-50 w-full border-b border-border/50 bg-background/80 backdrop-blur-xl">
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-3 sm:px-6 sm:py-4">
{/* Logo + brand */}
<div className="flex items-center gap-2 sm:gap-3">
<img
src="/tuikit-logo.png"
alt="TUIkit Logo"
width={28}
height={28}
className="rounded-lg sm:h-8 sm:w-8"
/>
{activePage === "home" ? (
<span className="text-xl font-semibold text-foreground sm:text-2xl">TUIkit</span>
) : (
<a href="/" className="text-xl font-semibold text-foreground transition-colors hover:text-accent sm:text-2xl">
TUIkit
</a>
)}
</div>
{/* Desktop nav links */}
<div className="hidden items-center gap-4 sm:flex sm:gap-6">
{NAV_LINKS.map((link) => {
const isActive = link.page === activePage;
if (isActive) {
return (
<span
key={link.href}
className="flex items-center gap-1.5 text-base text-foreground sm:text-lg"
aria-current="page"
>
{link.icon && <Icon name={link.icon} size={20} className="text-current" />}
{link.label}
</span>
);
}
return (
<a
key={link.href}
href={link.href}
{...(link.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
className="flex items-center gap-1.5 text-base text-muted transition-colors hover:text-foreground sm:text-lg"
>
{link.icon && <Icon name={link.icon} size={20} className="text-current" />}
{link.label}
</a>
);
})}
<div className="ml-2 border-l border-border pl-4">
<ThemeSwitcher />
</div>
</div>
{/* Mobile: theme switcher + hamburger */}
<div className="flex items-center gap-3 sm:hidden">
<ThemeSwitcher />
<button
type="button"
onClick={() => setMenuOpen(!menuOpen)}
className="flex h-9 w-9 items-center justify-center rounded-lg text-muted transition-colors hover:bg-accent/10 hover:text-foreground"
aria-label={menuOpen ? "Close menu" : "Open menu"}
aria-expanded={menuOpen}
>
<Icon name={menuOpen ? "xmark" : "line3Horizontal"} size={20} />
</button>
</div>
</div>
{/* Mobile menu dropdown */}
{menuOpen && (
<div className="border-t border-border/50 bg-background/95 px-4 py-4 backdrop-blur-xl sm:hidden">
<div className="flex flex-col gap-3">
{NAV_LINKS.map((link) => {
const isActive = link.page === activePage;
if (isActive) {
return (
<span
key={link.href}
className="flex items-center gap-2 rounded-lg bg-accent/10 px-3 py-2 text-base text-foreground"
aria-current="page"
>
{link.icon && <Icon name={link.icon} size={20} className="text-accent" />}
{link.label}
</span>
);
}
return (
<a
key={link.href}
href={link.href}
{...(link.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
onClick={() => setMenuOpen(false)}
className="flex items-center gap-2 rounded-lg px-3 py-2 text-base text-muted transition-colors hover:bg-accent/5 hover:text-foreground"
>
{link.icon && <Icon name={link.icon} size={20} className="text-current" />}
{link.label}
</a>
);
})}
</div>
</div>
)}
</nav>
);
}
/**
* Shared site navigation bar used by all pages.
*
* Renders the TUIkit logo as a home link, navigation items with optional
* active state, and the theme switcher. Fixed at the top with backdrop blur.
* On mobile, shows a hamburger menu that expands to show links.
*
* Wraps content in ThemeProvider for theme switching.
*/
export default function SiteNav({ activePage }: SiteNavProps) {
return (
<ThemeProvider>
<SiteNavContent activePage={activePage} />
</ThemeProvider>
);
}
-153
View File
@@ -1,153 +0,0 @@
import { useEffect, useRef, useState } from "react";
/** Movement behavior of a spinner light. */
type DriftMode = "static" | "inward";
/** A single distant light with position, lifecycle, and optional drift. */
interface SpinnerLight {
/** Unique key for React reconciliation. */
id: number;
/** Horizontal start position as viewport percentage. */
xPercent: number;
/** Vertical start position as viewport percentage. */
yPercent: number;
/** Total visible duration in ms (fade in + hold + fade out). */
duration: number;
/** Size of the light dot in pixels. */
size: number;
/** Peak opacity (0-1). */
peakOpacity: number;
/** Whether this light drifts toward center or stays put. */
driftMode: DriftMode;
/** Horizontal drift toward center in viewport percentage (only for "inward"). */
driftX: number;
/** Vertical drift toward center in viewport percentage (only for "inward"). */
driftY: number;
/** Pre-computed boxShadow string to avoid recalculation on every render. */
boxShadow: string;
}
/** How many lights can be visible simultaneously. */
const MAX_VISIBLE = 8;
/** Interval range for spawning a new light (ms). */
const SPAWN_MIN_MS = 800;
const SPAWN_MAX_MS = 3000;
/** Duration range for a single light's lifecycle (ms). */
const DURATION_MIN_MS = 3000;
const DURATION_MAX_MS = 8000;
/** Viewport center target for inward drift. */
const CENTER_X = 50;
const CENTER_Y = 40;
/** How much of the distance toward center to cover (0-1). */
const DRIFT_FACTOR = 0.35;
/**
* Distant spinner lights that appear randomly across the viewport,
* pulse softly, then fade out and reappear elsewhere.
*
* Some lights drift slowly toward the viewport center while shrinking,
* creating the illusion of flying into the scene: like distant Blade
* Runner spinner craft approaching through rain and haze.
*/
export default function SpinnerLights() {
const [lights, setLights] = useState<SpinnerLight[]>([]);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const removalTimersRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
const nextIdRef = useRef(0);
useEffect(() => {
// Skip ambient light spawning for users who prefer reduced motion.
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
// Skip on mobile devices (box-shadow animations are expensive).
if (window.matchMedia("(max-width: 768px)").matches) return;
/** Spawn a new light at a random position. */
const spawnLight = () => {
const xStart = 5 + Math.random() * 90;
const yStart = 5 + Math.random() * 70;
/* ~60% of lights drift inward, the rest pulse in place. */
const driftMode: DriftMode = Math.random() < 0.6 ? "inward" : "static";
/* Calculate drift vector toward center in px. */
const viewW = window.innerWidth;
const viewH = window.innerHeight;
const towardCenterX = ((CENTER_X - xStart) / 100) * viewW * DRIFT_FACTOR;
const towardCenterY = ((CENTER_Y - yStart) / 100) * viewH * DRIFT_FACTOR;
const size = driftMode === "inward" ? 3 + Math.random() * 3 : 2 + Math.random() * 3;
const light: SpinnerLight = {
id: nextIdRef.current++,
xPercent: xStart,
yPercent: yStart,
duration: DURATION_MIN_MS + Math.random() * (DURATION_MAX_MS - DURATION_MIN_MS),
size,
peakOpacity: 0.3 + Math.random() * 0.5,
driftMode,
driftX: driftMode === "inward" ? towardCenterX : 0,
driftY: driftMode === "inward" ? towardCenterY : 0,
boxShadow: `0 0 ${size * 2}px ${size}px rgba(var(--accent-glow), 0.4), 0 0 ${size * 6}px ${size * 2}px rgba(var(--accent-glow), 0.12)`,
};
setLights((prev) => {
const trimmed = prev.length >= MAX_VISIBLE ? prev.slice(1) : prev;
return [...trimmed, light];
});
/* Remove after its lifecycle completes. Timer is tracked for cleanup. */
const removalTimer = setTimeout(() => {
removalTimersRef.current.delete(removalTimer);
setLights((prev) => prev.filter((entry) => entry.id !== light.id));
}, light.duration);
removalTimersRef.current.add(removalTimer);
/* Schedule next spawn. */
const nextDelay = SPAWN_MIN_MS + Math.random() * (SPAWN_MAX_MS - SPAWN_MIN_MS);
timeoutRef.current = setTimeout(spawnLight, nextDelay);
};
/* Initial spawn after short delay. */
timeoutRef.current = setTimeout(spawnLight, 1000);
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
const timers = removalTimersRef.current;
for (const timer of timers) clearTimeout(timer);
timers.clear();
};
}, []);
return (
<div aria-hidden="true" className="pointer-events-none fixed inset-0 -z-5 overflow-hidden">
{lights.map((light) => {
const isInward = light.driftMode === "inward";
return (
<div
key={light.id}
className="spinner-light absolute rounded-full"
style={{
left: `${light.xPercent}%`,
top: `${light.yPercent}%`,
width: light.size,
height: light.size,
background: "rgba(var(--accent-glow), 0.9)",
boxShadow: light.boxShadow,
animation: isInward
? `spinner-pulse-inward ${light.duration}ms ease-in-out forwards`
: `spinner-pulse ${light.duration}ms ease-in-out forwards`,
"--peak-opacity": light.peakOpacity,
"--drift-x": `${light.driftX}px`,
"--drift-y": `${light.driftY}px`,
} as React.CSSProperties}
/>
);
})}
</div>
);
}
@@ -1,29 +0,0 @@
import Icon from "./Icon";
import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
const INSTALL_COMMAND = 'curl -fsSL https://raw.githubusercontent.com/phranck/TUIkit/main/project-template/install.sh | bash';
/** CLI installer badge with copy-to-clipboard. */
export default function TemplateBadge() {
const { copied, copy } = useCopyToClipboard();
return (
<div className="flex w-full items-center justify-between gap-2 rounded-full border border-border bg-container-body/50 px-4 py-2 text-muted backdrop-blur-sm">
<code className="font-mono text-base text-glow" style={{ color: "var(--foreground)" }}>
{INSTALL_COMMAND}
</code>
<button
onClick={() => copy(INSTALL_COMMAND)}
aria-label="Copy to clipboard"
className="ml-1 rounded-md p-1.5 text-muted transition-colors hover:bg-foreground/10 hover:text-foreground focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background shrink-0"
>
{copied ? (
<Icon name="checkmark" size={20} className="text-accent" />
) : (
<Icon name="copy" size={20} />
)}
</button>
</div>
);
}
@@ -1,687 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { TERMINAL_SCRIPT } from "./terminal-data";
import type { BootStep, SchoolStep, JoshuaStep, TerminalEntry } from "../../lib/terminal-parser";
/**
* Parse simple HTML-like tags in terminal text for formatting.
* Supports: <b>, <u>, <s>, <i>
*/
function parseTerminalFormatting(text: string): React.ReactNode[] {
const parts: React.ReactNode[] = [];
let lastIndex = 0;
let partKey = 0;
// If no tags found, return plain text
if (!text.includes('<')) {
return [text];
}
// Match <b>, <u>, <s>, <i> tags
// Use [\s\S] instead of . to match any character including newlines
// Use non-greedy match to capture content including spaces
const regex = /<(b|u|s|i)>([\s\S]+?)<\/\1>/g;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
// Add text before tag
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}
// Add formatted text
const tag = match[1];
const content = match[2];
switch (tag) {
case 'b':
parts.push(<strong key={partKey++} className="font-bold">{content}</strong>);
break;
case 'u':
parts.push(<span key={partKey++} className="underline">{content}</span>);
break;
case 's':
parts.push(<span key={partKey++} className="line-through">{content}</span>);
break;
case 'i':
parts.push(<em key={partKey++} className="italic">{content}</em>);
break;
}
lastIndex = regex.lastIndex;
}
// Add remaining text
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return parts.length > 0 ? parts : [text];
}
/**
* Truncate a string to a maximum visible length, preserving HTML tags.
* Tags like <u>, </u>, <b>, </b> etc. are not counted toward the visible length.
* If truncation happens inside a tag pair, the closing tag is appended to keep
* the markup well-formed so parseTerminalFormatting can match it.
*/
function truncateToVisibleLength(text: string, maxVisible: number): string {
// No tags? Simple truncation.
if (!text.includes('<')) {
return text.length > maxVisible ? text.slice(0, maxVisible) : text;
}
let result = "";
let visibleCount = 0;
let index = 0;
const openTags: string[] = []; // stack of open tag names
while (index < text.length && visibleCount < maxVisible) {
if (text[index] === '<') {
// Find end of tag
const closeIndex = text.indexOf('>', index);
if (closeIndex === -1) break;
const tag = text.slice(index, closeIndex + 1);
result += tag;
// Track open/close tags
const openMatch = tag.match(/^<([busi])>$/);
const closeMatch = tag.match(/^<\/([busi])>$/);
if (openMatch) {
openTags.push(openMatch[1]);
} else if (closeMatch) {
openTags.pop();
}
index = closeIndex + 1;
} else {
result += text[index];
visibleCount++;
index++;
}
}
// Close any open tags that were cut off
for (let tagIdx = openTags.length - 1; tagIdx >= 0; tagIdx--) {
result += `</${openTags[tagIdx]}>`;
}
return result;
}
/**
* Pool of classic UNIX terminal interactions.
* Each entry has a prompt, a command typed character-by-character,
* and output lines that appear instantly after "execution".
* ~50 entries for 3+ minutes without repeats.
*/
const INTERACTIONS: TerminalEntry[] = TERMINAL_SCRIPT.unixCommands;
/** Maximum visible columns and rows on the CRT screen area. */
const COLS = 37;
const ROWS = 9;
/** Cursor blink interval in ms (classic terminal feel). */
const CURSOR_BLINK_MS = 530;
/** Per-character delay range for system "typewriter" output (ms). */
const SYSTEM_TYPE_MIN_MS = 40;
const SYSTEM_TYPE_MAX_MS = 70;
/** Fade-in duration when terminal powers on (ms). */
const FADE_IN_DURATION_MS = 6000;
/** Delay between output lines during command playback (ms). */
const OUTPUT_LINE_DELAY_MS = 120;
/** Glitch scheduling range (ms). */
const GLITCH_INITIAL_MIN_MS = 2000;
const GLITCH_INITIAL_MAX_MS = 3000;
const GLITCH_INTERVAL_MIN_MS = 3000;
const GLITCH_INTERVAL_MAX_MS = 5000;
/** Glitch reset delay range (ms). */
const GLITCH_RESET_MIN_MS = 50;
const GLITCH_RESET_MAX_MS = 70;
/** Load configuration from parsed script. */
const { config } = TERMINAL_SCRIPT;
const INITIAL_CURSOR_DELAY_MS = config.initialCursorDelay;
const TYPE_MIN_MS = config.typeMin;
const TYPE_MAX_MS = config.typeMax;
const PAUSE_AFTER_OUTPUT_MS = config.pauseAfterOutput;
const PAUSE_BEFORE_OUTPUT_MS = config.pauseBeforeOutput;
const SCHOOL_TRIGGER_SEC = config.schoolTrigger;
const JOSHUA_TRIGGER_SEC = config.joshuaTrigger;
// Load sequences from parsed script (types imported from terminal-parser)
const BOOT_SEQUENCE: BootStep[] = TERMINAL_SCRIPT.bootSequence;
const SCHOOL_SEQUENCE: SchoolStep[] = TERMINAL_SCRIPT.schoolSequence;
const JOSHUA_SEQUENCE: JoshuaStep[] = TERMINAL_SCRIPT.joshuaSequence;
// Component
interface TerminalScreenProps {
/** Whether the terminal is powered on. When false, shows static welcome text. */
powered: boolean;
}
/**
* Simulated terminal session rendered inside the CRT logo.
*
* When `powered` is false, displays a static "Welcome to TUIkit" message
* with a blinking cursor. When powered on, runs the boot sequence, then
* cycles through terminal interactions, with the Joshua easter egg after
* 23 seconds.
*/
export default function TerminalScreen({ powered }: TerminalScreenProps) {
const [lines, setLines] = useState<{key: number, text: string}[]>([]);
const [cursorVisible, setCursorVisible] = useState(true);
const [terminalOpacity, setTerminalOpacity] = useState(0);
const usedIndicesRef = useRef<Set<number>>(new Set());
const linesRef = useRef<{key: number, text: string}[]>([]);
const lineKeyCounterRef = useRef(0);
const lineRefsRef = useRef<(HTMLDivElement | null)[]>([]);
const abortRef = useRef<AbortController | null>(null);
const sessionTimeRef = useRef<number>(0);
const schoolPlayedRef = useRef(false);
const joshuaPlayedRef = useRef(false);
const pickInteraction = useCallback((): TerminalEntry => {
const used = usedIndicesRef.current;
if (used.size >= INTERACTIONS.length) {
used.clear();
}
let index: number;
do {
index = Math.floor(Math.random() * INTERACTIONS.length);
} while (used.has(index));
used.add(index);
return INTERACTIONS[index];
}, []);
const pushLine = useCallback((text: string) => {
const entry = { key: lineKeyCounterRef.current++, text };
const updated = [...linesRef.current, entry];
const trimmed = updated.length > ROWS ? updated.slice(updated.length - ROWS) : updated;
linesRef.current = trimmed;
// Keep lineRefsRef in sync to prevent stale DOM refs from accumulating.
lineRefsRef.current = lineRefsRef.current.slice(-ROWS);
setLines(trimmed);
}, []);
const updateLastLine = useCallback((text: string) => {
const updated = [...linesRef.current];
updated[updated.length - 1] = { ...updated[updated.length - 1], text };
linesRef.current = updated;
setLines([...updated]);
}, []);
const clearScreen = useCallback(() => {
linesRef.current = [];
lineRefsRef.current = [];
setLines([]);
}, []);
/** Cursor blink. */
useEffect(() => {
const interval = setInterval(() => {
setCursorVisible((prev) => !prev);
}, CURSOR_BLINK_MS);
return () => clearInterval(interval);
}, []);
/** Fade in entire terminal over 6 seconds when powered on.
* Cleanup resets opacity to 0 when powered off (avoids setState-in-effect). */
useEffect(() => {
if (!powered) return;
const startTime = Date.now();
const duration = FADE_IN_DURATION_MS;
let rafId: number;
const fadeIn = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
setTerminalOpacity(progress);
if (progress < 1) {
rafId = requestAnimationFrame(fadeIn);
}
};
rafId = requestAnimationFrame(fadeIn);
return () => {
if (rafId) cancelAnimationFrame(rafId);
setTerminalOpacity(0);
};
}, [powered]);
/** Reset terminal state when powered off.
* Cleanup handles abort + state reset so no setState lives in the effect body. */
useEffect(() => {
if (!powered) return;
// Capture ref values inside effect to satisfy exhaustive-deps rule.
const usedIndices = usedIndicesRef.current;
return () => {
/* Abort any running animation. */
if (abortRef.current) {
abortRef.current.abort();
abortRef.current = null;
}
/* Clear terminal state */
linesRef.current = [];
lineRefsRef.current = [];
setLines([]);
schoolPlayedRef.current = false;
joshuaPlayedRef.current = false;
usedIndices.clear();
};
}, [powered]);
/** Main animation loop: only runs when powered. */
useEffect(() => {
if (!powered) return;
const controller = new AbortController();
abortRef.current = controller;
const signal = controller.signal;
const sleep = (ms: number) =>
new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, ms);
signal.addEventListener("abort", () => {
clearTimeout(timer);
reject(new DOMException("Aborted", "AbortError"));
});
});
/**
* Types text character-by-character, but handles HTML tags specially:
* - Opening and closing tags appear instantly as a pair
* - Only the visible text content is typed character-by-character
*/
const typeSystem = async (text: string) => {
pushLine("");
// If no HTML tags, just type normally
if (!text.includes('<')) {
for (let charIdx = 0; charIdx < text.length; charIdx++) {
updateLastLine(text.slice(0, charIdx + 1));
await sleep(SYSTEM_TYPE_MIN_MS + Math.random() * (SYSTEM_TYPE_MAX_MS - SYSTEM_TYPE_MIN_MS));
}
return;
}
// Match paired tags with their content: <tag>content</tag>
const pairedTagRegex = /<([busi])>([\s\S]*?)<\/\1>/g;
const segments: Array<{type: 'plain' | 'wrapped', tag?: string, content: string}> = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = pairedTagRegex.exec(text)) !== null) {
// Add plain text before this tag pair
if (match.index > lastIndex) {
segments.push({type: 'plain', content: text.slice(lastIndex, match.index)});
}
// Add the content inside tags (we'll wrap it during typing)
segments.push({type: 'wrapped', tag: match[1], content: match[2]});
lastIndex = pairedTagRegex.lastIndex;
}
// Add remaining plain text
if (lastIndex < text.length) {
segments.push({type: 'plain', content: text.slice(lastIndex)});
}
// Type segments
let displayText = "";
for (const segment of segments) {
if (segment.type === 'plain') {
// Plain text: type char-by-char
const charDelay = () => SYSTEM_TYPE_MIN_MS + Math.random() * (SYSTEM_TYPE_MAX_MS - SYSTEM_TYPE_MIN_MS);
for (let i = 0; i < segment.content.length; i++) {
displayText += segment.content[i];
updateLastLine(displayText);
await sleep(charDelay());
}
} else {
// Wrapped text: show opening/closing tags instantly, type content char-by-char
const openTag = `<${segment.tag}>`;
const closeTag = `</${segment.tag}>`;
// Insert opening and closing tags instantly
displayText += openTag + closeTag;
updateLastLine(displayText);
// Now type the content character-by-character BETWEEN the tags
const beforeClose = displayText.slice(0, -closeTag.length);
const charDelay = () => SYSTEM_TYPE_MIN_MS + Math.random() * (SYSTEM_TYPE_MAX_MS - SYSTEM_TYPE_MIN_MS);
for (let i = 0; i < segment.content.length; i++) {
const typed = beforeClose + segment.content.slice(0, i + 1) + closeTag;
updateLastLine(typed);
await sleep(charDelay());
}
displayText = beforeClose + segment.content + closeTag;
}
}
};
/**
* Simulates a human typing at a physical keyboard.
*
* Varies timing per character to mimic real keystrokes:
* - Short bursts of fast typing (50-80ms) for familiar sequences
* - Thinking pauses (300-600ms) after spaces and punctuation
* - Occasional mid-word hesitation (200-350ms, ~15% chance)
* - Slightly faster within a word, slower at boundaries
*/
const typeUser = async (text: string, prefix = "") => {
pushLine(prefix);
for (let charIdx = 0; charIdx < text.length; charIdx++) {
updateLastLine(prefix + text.slice(0, charIdx + 1));
const char = text[charIdx];
const nextChar = text[charIdx + 1];
let delay: number;
if (char === " " || char === "." || char === "," || char === "?") {
/* Pause after word boundary or punctuation: thinking time. */
delay = 250 + Math.random() * 350;
} else if (nextChar === " " || charIdx === text.length - 1) {
/* Slightly slower on last char of a word: finger lifting. */
delay = 100 + Math.random() * 120;
} else if (Math.random() < 0.15) {
/* Occasional mid-word hesitation: hunting for the right key. */
delay = 180 + Math.random() * 170;
} else {
/* Fast burst within a word. */
delay = 45 + Math.random() * 55;
}
await sleep(delay);
}
};
const animateCounter = async (prefix: string, target: number, suffix: string) => {
pushLine(prefix + "0" + suffix);
const steps = 18;
for (let step = 1; step <= steps; step++) {
const value = Math.round((target / steps) * step);
updateLastLine(prefix + value + suffix);
await sleep(50 + Math.random() * 30);
}
updateLastLine(prefix + target + suffix);
};
const printWithDots = async (text: string, dotCount: number) => {
pushLine(text);
for (let dot = 0; dot < dotCount; dot++) {
await sleep(300 + Math.random() * 200);
updateLastLine(text + ".".repeat(dot + 1));
}
};
const playBoot = async () => {
for (const step of BOOT_SEQUENCE) {
if (signal.aborted) return;
switch (step.type) {
case "instant":
pushLine(step.text ?? "");
break;
case "type":
await typeSystem(step.text ?? "");
break;
case "counter":
await animateCounter(step.prefix ?? "", step.target ?? 0, step.suffix ?? "");
break;
case "dots":
await printWithDots(step.text ?? "", step.dotCount ?? 3);
break;
case "clear":
clearScreen();
break;
case "pause":
break;
}
if (step.delayAfter) await sleep(step.delayAfter);
}
};
/** Rapid barrage of random hex/data to simulate the WOPR handshake. */
const playBarrage = async () => {
const chars = "0123456789ABCDEF.:/<>[]{}#@!$%&*";
const frames = 30;
for (let frame = 0; frame < frames; frame++) {
if (signal.aborted) return;
clearScreen();
const lineCount = Math.floor(Math.random() * 3) + ROWS - 2;
for (let row = 0; row < lineCount; row++) {
const len = Math.floor(Math.random() * (COLS - 4)) + 8;
let line = "";
for (let col = 0; col < len; col++) {
line += chars[Math.floor(Math.random() * chars.length)];
}
pushLine(line);
}
await sleep(60 + Math.random() * 40);
}
};
const playSchool = async () => {
for (const step of SCHOOL_SEQUENCE) {
if (signal.aborted) return;
switch (step.type) {
case "clear":
clearScreen();
break;
case "system":
if (step.text === "") {
pushLine("");
} else {
await typeSystem(step.text ?? "");
}
break;
case "user":
await typeUser(step.text ?? "", "");
break;
case "inline":
// Prompt and user input on same line
await typeUser(step.text ?? "", step.prompt ?? "");
break;
case "pause":
break;
}
if (step.delayAfter) await sleep(step.delayAfter);
}
};
const playJoshua = async () => {
for (const step of JOSHUA_SEQUENCE) {
if (signal.aborted) return;
switch (step.type) {
case "clear":
clearScreen();
break;
case "barrage":
await playBarrage();
break;
case "system":
if (step.text === "") {
pushLine("");
} else {
await typeSystem(step.text ?? "");
}
break;
case "user":
await typeUser(step.text ?? "", "> ");
break;
case "pause":
break;
}
if (step.delayAfter) await sleep(step.delayAfter);
}
};
const runLoop = async () => {
try {
/* Show only prompt with blinking cursor before boot starts. */
pushLine("> ");
await sleep(INITIAL_CURSOR_DELAY_MS);
/* Clear and start boot sequence */
clearScreen();
await playBoot();
/* Start session timer for scenes. */
sessionTimeRef.current = Date.now();
while (!signal.aborted) {
const elapsed = (Date.now() - sessionTimeRef.current) / 1000;
/* Trigger school computer scene after 12 seconds of UNIX commands */
if (!schoolPlayedRef.current && elapsed >= SCHOOL_TRIGGER_SEC) {
schoolPlayedRef.current = true;
await playSchool();
sessionTimeRef.current = Date.now(); // Reset timer for next scene
continue;
}
/* Trigger Joshua/WOPR scene after another 12 seconds of UNIX commands */
if (schoolPlayedRef.current && !joshuaPlayedRef.current && elapsed >= JOSHUA_TRIGGER_SEC) {
joshuaPlayedRef.current = true;
await playJoshua();
sessionTimeRef.current = Date.now(); // Reset timer, continue normal loop
continue;
}
const entry = pickInteraction();
const promptPrefix = `${entry.prompt} `;
pushLine(promptPrefix);
for (let charIdx = 0; charIdx < entry.command.length; charIdx++) {
const partial = promptPrefix + entry.command.slice(0, charIdx + 1);
updateLastLine(partial);
const delay = TYPE_MIN_MS + Math.random() * (TYPE_MAX_MS - TYPE_MIN_MS);
await sleep(delay);
}
await sleep(PAUSE_BEFORE_OUTPUT_MS);
for (const outputLine of entry.output) {
pushLine(outputLine);
await sleep(OUTPUT_LINE_DELAY_MS);
}
await sleep(PAUSE_AFTER_OUTPUT_MS);
}
} catch {
/* AbortError: powered off or unmounted. */
}
};
runLoop();
return () => {
controller.abort();
abortRef.current = null;
};
}, [powered, pickInteraction, pushLine, updateLastLine, clearScreen]);
/**
* CRT scanline glitch: randomly shifts multiple text lines
* horizontally in independent directions for a few frames,
* simulating an unstable electron beam. Each glitched line
* gets its own random offset. Fires every 3-8 seconds.
*/
useEffect(() => {
if (!powered) return;
// Skip glitch effect for users who prefer reduced motion.
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
let timeout: ReturnType<typeof setTimeout>;
let resetTimeout: ReturnType<typeof setTimeout>;
const triggerGlitch = () => {
const lineElements = lineRefsRef.current.filter((el): el is HTMLDivElement => el !== null);
if (lineElements.length === 0) {
timeout = setTimeout(triggerGlitch, GLITCH_INTERVAL_MIN_MS + Math.random() * (GLITCH_INTERVAL_MAX_MS - GLITCH_INTERVAL_MIN_MS));
return;
}
const glitched: HTMLDivElement[] = [];
/* Glitch 2-5 random lines, each with its own direction and intensity. */
const count = 2 + Math.floor(Math.random() * 4);
const indices = new Set<number>();
while (indices.size < Math.min(count, lineElements.length)) {
indices.add(Math.floor(Math.random() * lineElements.length));
}
for (const idx of indices) {
const element = lineElements[idx];
const shift = (Math.random() - 0.5) * 16;
element.style.transform = `translateX(${shift}px)`;
element.style.transition = "none";
glitched.push(element);
}
/* Reset after 50-120ms. */
resetTimeout = setTimeout(() => {
for (const element of glitched) {
element.style.transition = "transform 0.05s";
element.style.transform = "translateX(0)";
}
}, GLITCH_RESET_MIN_MS + Math.random() * (GLITCH_RESET_MAX_MS - GLITCH_RESET_MIN_MS));
timeout = setTimeout(triggerGlitch, GLITCH_INTERVAL_MIN_MS + Math.random() * (GLITCH_INTERVAL_MAX_MS - GLITCH_INTERVAL_MIN_MS));
};
timeout = setTimeout(triggerGlitch, GLITCH_INITIAL_MIN_MS + Math.random() * (GLITCH_INITIAL_MAX_MS - GLITCH_INITIAL_MIN_MS));
return () => {
clearTimeout(timeout);
clearTimeout(resetTimeout);
};
}, [powered]);
/* Powered off: no content, just dark glass. */
if (!powered) return null;
return (
<div
className="pointer-events-none overflow-hidden"
style={{
width: "100%",
height: "100%",
padding: "4px 6px",
opacity: terminalOpacity,
transition: "none", // Use requestAnimationFrame instead of CSS transition
}}
>
<div
className="flex flex-col justify-start items-start text-glow"
style={{
fontFamily: "WarText, monospace",
fontSize: "13px",
lineHeight: "1.2",
color: "var(--foreground)",
}}
>
{lines.map((entry, index) => {
const displayLine = truncateToVisibleLength(entry.text, COLS);
const formatted = parseTerminalFormatting(displayLine);
const isEmpty = entry.text === "";
return (
<div
key={entry.key}
ref={(element) => { lineRefsRef.current[index] = element; }}
className="whitespace-pre overflow-hidden"
>
{isEmpty ? "\u00A0" : formatted}
{index === lines.length - 1 && cursorVisible && (
<span className="opacity-80">_</span>
)}
</div>
);
})}
</div>
</div>
);
}
-107
View File
@@ -1,107 +0,0 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
/** Available phosphor themes matching TUIkit's built-in palettes. */
export const themes = ["green", "amber", "red", "violet", "blue", "white"] as const;
export type Theme = (typeof themes)[number];
interface ThemeContextValue {
/** Current theme, or null while hydrating (before localStorage is read). */
theme: Theme | null;
setTheme: (theme: Theme) => void;
}
const ThemeContext = createContext<ThemeContextValue>({
theme: null,
setTheme: () => {},
});
const STORAGE_KEY = "tuikit-theme";
/** Type guard: validates that a string is a known theme name. */
function isTheme(value: string | null): value is Theme {
return !!value && (themes as readonly string[]).includes(value);
}
/**
* Reads the persisted theme from localStorage (set by the blocking script
* in BaseLayout.astro), then checks the DOM attribute as fallback.
* Returns "green" on first visit or during SSR.
*
* NOTE: The valid theme list here must match the blocking script in BaseLayout.astro.
*/
function getInitialTheme(): Theme {
if (typeof window !== "undefined") {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (isTheme(stored)) return stored;
} catch { /* localStorage unavailable */ }
const attr = document.documentElement.getAttribute("data-theme");
if (isTheme(attr)) return attr;
}
return "green";
}
/** Provides theme state and applies it to the document root via data-theme attribute. */
export function ThemeProvider({ children }: { children: ReactNode }) {
/**
* Start with null on both server and client to avoid hydration mismatch.
* The blocking script in BaseLayout.astro already sets data-theme on <html> before
* first paint, so there is no FOUC. React state catches up in useEffect below.
*/
const [theme, setThemeState] = useState<Theme | null>(null);
/**
* After hydration, read the actual theme from DOM/localStorage.
* This is a legitimate hydration-sync pattern: the server cannot know
* which theme the user has stored in localStorage, so we must read it
* on the client and update React state to match reality.
*/
useEffect(() => {
const actual = getInitialTheme();
setThemeState(actual);
document.documentElement.setAttribute("data-theme", actual);
}, []);
const setTheme = useCallback((next: Theme) => {
setThemeState(next);
document.documentElement.setAttribute("data-theme", next);
try { localStorage.setItem(STORAGE_KEY, next); } catch { /* quota exceeded or blocked */ }
}, []);
/** Cycle to the next theme when "t" is pressed (skip if user is typing in an input). */
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (event.key !== "t") return;
if (theme === null) return;
const target = event.target;
if (target instanceof HTMLElement) {
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) return;
}
const currentIndex = themes.indexOf(theme);
const nextIndex = (currentIndex + 1) % themes.length;
setTheme(themes[nextIndex]);
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [theme, setTheme]);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
/** Hook to access the current theme and setter. */
export function useTheme() {
return useContext(ThemeContext);
}
@@ -1,58 +0,0 @@
import { useTheme, themes, type Theme } from "./ThemeProvider";
/**
* Fixed foreground color per theme: used for the color dots so each dot
* always shows its own theme color regardless of the currently active theme.
* Values mirror the --foreground CSS variables in global.css.
*/
const themeColors: Record<Theme, string> = {
green: "#33ff33",
amber: "#ffaa00",
red: "#ff4444",
violet: "#bb77ff",
blue: "#00aaff",
white: "#e8e8e8",
};
/** Theme labels for accessibility. */
const themeLabels: Record<Theme, string> = {
green: "Green",
amber: "Amber",
red: "Red",
violet: "Violet",
blue: "Blue",
white: "White",
};
/** Compact theme switcher with colored dots and active indicator. */
export default function ThemeSwitcher() {
const { theme, setTheme } = useTheme();
return (
<div className="flex items-center gap-1.5">
{themes.map((themeOption) => {
const isActive = theme === themeOption;
return (
<button
key={themeOption}
onClick={() => setTheme(themeOption)}
aria-label={`${themeLabels[themeOption]} theme`}
aria-pressed={isActive}
className="group relative flex h-7 w-7 cursor-pointer items-center justify-center rounded-full transition-transform hover:scale-110 focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<span
className={`block h-3 w-3 rounded-full ${isActive ? "animate-[theme-pulse_3s_ease-in-out_infinite]" : "transition-all"}`}
style={{
backgroundColor: themeColors[themeOption],
boxShadow: isActive
? `0 0 8px ${themeColors[themeOption]}, 0 0 20px ${themeColors[themeOption]}90, 0 0 36px ${themeColors[themeOption]}40`
: "none",
opacity: isActive ? 1 : 0.35,
}}
/>
</button>
);
})}
</div>
);
}
@@ -1,307 +0,0 @@
import { useEffect, useRef, useState } from "react";
import type { WeeklyActivity } from "../../../hooks/useGitHubStats";
import { useHoverPopover } from "../../../hooks/useHoverPopover";
import HoverPopover from "./HoverPopover";
import Icon from "../Icon";
interface ActivityHeatmapProps {
/** 52 weeks of commit activity data. */
weeks: WeeklyActivity[];
/** Whether data is still loading. */
loading?: boolean;
}
const DAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
/** Pixel gap between cells. */
const CELL_GAP = 3;
/** Width of the day-label column including right padding. */
const LABEL_WIDTH = 32;
/** Minimum cell size to prevent cells from becoming invisible. */
const MIN_CELL_SIZE = 8;
/** Fixed cell size for mobile (scroll mode). */
const MOBILE_CELL_SIZE = 10;
/** Container width threshold below which we switch to scroll mode. */
const SCROLL_MODE_THRESHOLD = 600;
/** A full year of weekly data. */
const WEEKS_PER_YEAR = 52;
/** Seconds in one week. */
const SECONDS_PER_WEEK = 7 * 86400;
/**
* Formats a Unix timestamp (seconds) + day offset into a readable date.
*/
function formatDate(weekTimestamp: number, dayIndex: number): string {
const date = new Date((weekTimestamp + dayIndex * 86400) * 1000);
return date.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", year: "numeric" });
}
/**
* Builds month labels with pixel offsets relative to the cell grid.
*/
function buildMonthLabels(weeks: WeeklyActivity[], cellSize: number): { label: string; offset: number }[] {
const labels: { label: string; offset: number }[] = [];
let lastMonth = -1;
for (let idx = 0; idx < weeks.length; idx++) {
const date = new Date(weeks[idx].week * 1000);
const month = date.getMonth();
if (month !== lastMonth) {
labels.push({
label: MONTH_NAMES[month],
offset: idx * (cellSize + CELL_GAP),
});
lastMonth = month;
}
}
return labels;
}
/** Data displayed in the hover popover. */
interface HeatmapHover {
date: string;
count: number;
}
/** Maps intensity level (04) to a Tailwind opacity class. */
const OPACITY_MAP: Record<number, string> = {
0: "opacity-[0.06]",
1: "opacity-[0.25]",
2: "opacity-[0.45]",
3: "opacity-[0.7]",
4: "opacity-100",
};
/** Returns an intensity level (04) for a commit count relative to the maximum. */
function intensityLevel(count: number, maxCommits: number): number {
if (count === 0) return 0;
const ratio = count / maxCommits;
if (ratio <= 0.25) return 1;
if (ratio <= 0.5) return 2;
if (ratio <= 0.75) return 3;
return 4;
}
/**
* Pads the weeks array to a full year (52 weeks) by prepending empty weeks.
* If the array already has 52+ entries it is returned unchanged.
*/
function padToFullYear(weeks: WeeklyActivity[]): WeeklyActivity[] {
if (weeks.length >= WEEKS_PER_YEAR) return weeks;
const emptyDays = [0, 0, 0, 0, 0, 0, 0];
const missing = WEEKS_PER_YEAR - weeks.length;
const earliestTimestamp = weeks.length > 0 ? weeks[0].week : Math.floor(Date.now() / 1000);
const padding: WeeklyActivity[] = Array.from({ length: missing }, (_, idx) => ({
week: earliestTimestamp - (missing - idx) * SECONDS_PER_WEEK,
total: 0,
days: emptyDays,
}));
return [...padding, ...weeks];
}
/**
* Calculates the cell size that fills the available container width.
* Formula: floor((availableWidth - LABEL_WIDTH - (colCount - 1) * CELL_GAP) / colCount)
*/
function computeCellSize(containerWidth: number, colCount: number): number {
const availableForCells = containerWidth - LABEL_WIDTH - (colCount - 1) * CELL_GAP;
return Math.max(MIN_CELL_SIZE, Math.floor(availableForCells / colCount));
}
/**
* A GitHub-style commit activity heatmap showing 52 weeks of daily commit counts.
*
* Cell size is dynamically calculated to fill the available container width.
* Layout: day labels on the left, a column-flow grid of square cells on the right.
* Month labels are positioned above the grid using pixel offsets.
*/
export default function ActivityHeatmap({ weeks, loading = false }: ActivityHeatmapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [cellSize, setCellSize] = useState(0);
const [scrollMode, setScrollMode] = useState(false);
const { hover, popover, show: showPopover, hide: hidePopover, cancelHide } = useHoverPopover<HeatmapHover>();
const fullYear = padToFullYear(weeks);
const colCount = fullYear.length;
useEffect(() => {
const container = containerRef.current;
if (!container || colCount === 0) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const width = entry.contentRect.width;
if (width < SCROLL_MODE_THRESHOLD) {
// Mobile: fixed cell size, horizontal scroll
setScrollMode(true);
setCellSize(MOBILE_CELL_SIZE);
} else {
// Desktop: compute cell size to fill available space
setScrollMode(false);
setCellSize(computeCellSize(width, colCount));
}
}
});
observer.observe(container);
// Initial measurement
const width = container.clientWidth;
if (width < SCROLL_MODE_THRESHOLD) {
setScrollMode(true);
setCellSize(MOBILE_CELL_SIZE);
} else {
setScrollMode(false);
setCellSize(computeCellSize(width, colCount));
}
return () => observer.disconnect();
}, [colCount, loading]);
if (loading) {
return (
<div className="rounded-xl border border-border bg-frosted-glass p-6 backdrop-blur-xl">
<h3 className="mb-4 flex items-center gap-3 text-xl font-semibold text-foreground">
<Icon name="calendar" size={24} className="text-accent" />
Commit Activity
</h3>
<div className="h-32 w-full rounded-md bg-accent/10 animate-skeleton" />
</div>
);
}
const maxCommits = Math.max(1, ...fullYear.flatMap((week) => week.days));
const monthLabels = buildMonthLabels(fullYear, cellSize);
function handleMouseEnter(event: React.MouseEvent<HTMLDivElement>, weekTimestamp: number, dayIdx: number, count: number) {
const cell = event.currentTarget;
const wrapper = cell.closest("[data-heatmap-grid]");
if (!wrapper) return;
const wrapperRect = wrapper.getBoundingClientRect();
const cellRect = cell.getBoundingClientRect();
showPopover(
{ date: formatDate(weekTimestamp, dayIdx), count },
cellRect.left - wrapperRect.left + cellRect.width / 2,
cellRect.top - wrapperRect.top,
);
}
const gridHeight = 7 * cellSize + 6 * CELL_GAP;
return (
<div ref={containerRef} className="rounded-xl border border-border bg-frosted-glass p-6 backdrop-blur-xl">
<h3 className="mb-4 flex items-center gap-3 text-xl font-semibold text-foreground">
<Icon name="calendar" size={24} className="text-accent" />
Commit Activity
</h3>
{cellSize > 0 && (
<div className="relative" data-heatmap-grid onMouseLeave={hidePopover}>
{/* Scrollable wrapper for mobile */}
<div className={scrollMode ? "overflow-x-auto pb-2" : ""}>
{/* Month labels: absolutely positioned above the cell grid */}
<div className="relative h-5" style={{ marginLeft: LABEL_WIDTH, minWidth: scrollMode ? colCount * (cellSize + CELL_GAP) : undefined }}>
{monthLabels.map(({ label, offset }) => (
<span
key={`${label}-${offset}`}
className="absolute bottom-0 text-xs text-muted"
style={{ left: offset }}
>
{label}
</span>
))}
</div>
{/* Day labels (left) + Cell grid (right) */}
<div className="flex" style={{ minWidth: scrollMode ? LABEL_WIDTH + colCount * (cellSize + CELL_GAP) : undefined }}>
{/* Day labels */}
<div
className={`flex shrink-0 flex-col ${scrollMode ? "sticky left-0 z-10 bg-frosted-glass" : ""}`}
style={{
width: LABEL_WIDTH,
height: gridHeight,
justifyContent: "space-around",
}}
>
{DAY_LABELS.map((day) => (
<span
key={day}
className="text-right text-xs leading-none text-muted"
style={{ height: cellSize, lineHeight: `${cellSize}px`, paddingRight: 6 }}
>
{day}
</span>
))}
</div>
{/* Cell grid: column-flow: 7 rows, columns auto-created per week */}
<div
className="grid"
style={{
gridTemplateRows: `repeat(7, ${cellSize}px)`,
gridAutoFlow: "column",
gridAutoColumns: `${cellSize}px`,
gap: CELL_GAP,
}}
>
{fullYear.map((week) =>
week.days.map((count, dayIdx) => {
const level = intensityLevel(count, maxCommits);
return (
<div
key={`${week.week}-${dayIdx}`}
className={`rounded-sm bg-accent ${OPACITY_MAP[level]} transition-opacity hover:ring-1 hover:ring-accent/60`}
style={{ width: cellSize, height: cellSize }}
onMouseEnter={(event) => count > 0 && handleMouseEnter(event, week.week, dayIdx, count)}
onMouseLeave={hidePopover}
/>
);
})
)}
</div>
</div>
</div>
{/* Popover */}
<HoverPopover
visible={!!hover}
x={popover?.x ?? 0}
y={popover?.y ?? 0}
offsetY={-11}
minWidth="10rem"
onMouseEnter={cancelHide}
onMouseLeave={hidePopover}
>
<p className="whitespace-nowrap text-center text-sm font-medium text-foreground">{popover?.data.date}</p>
<p className="whitespace-nowrap text-center text-sm text-muted">
<span className="font-bold text-accent">{popover?.data.count ?? 0}</span> commit{popover?.data.count !== 1 ? "s" : ""}
</p>
</HoverPopover>
</div>
)}
{/* Legend */}
<div className="mt-6 flex items-center justify-center gap-1.5 text-xs text-muted">
<span>Less</span>
{[0, 1, 2, 3, 4].map((level) => (
<div
key={level}
className={`rounded-sm bg-accent ${OPACITY_MAP[level]}`}
style={{ width: 11, height: 11 }}
/>
))}
<span>More</span>
</div>
</div>
);
}
@@ -1,477 +0,0 @@
import { useRef, useEffect, useCallback, useState, useMemo, type ReactNode } from "react";
import HoverPopover from "./HoverPopover";
/** Avatar size in pixels. */
const AVATAR_SIZE = 72;
/** Gap between avatars in pixels. */
const AVATAR_GAP = 24;
/** Base scroll speed in pixels per frame. */
const SCROLL_SPEED = 0.8;
/** Interpolation factor for smooth speed changes (0-1, lower = smoother). */
const SPEED_LERP = 0.06;
/** Fade gradient for edge masks. */
const FADE_GRADIENT = "linear-gradient(to right, transparent 0%, black 15%, black 85%, transparent 100%)";
/** Padding above/below avatars + title line height. */
const VERTICAL_PADDING = 34;
interface AvatarMarqueeProps<T> {
/** Array of items to display as avatars. */
items: T[];
/** Extract avatar URL from item. */
getAvatarUrl: (item: T) => string;
/** Extract display label from item (shown in popover). */
getLabel: (item: T) => string;
/** Extract profile URL from item (link destination). */
getProfileUrl: (item: T) => string;
/** Optional custom popover content renderer. */
renderPopover?: (item: T) => ReactNode;
/** Whether the marquee is visible/expanded. */
open: boolean;
/** Title displayed in the top border line. */
title?: string;
/** Callback when marquee requests to close (e.g., ESC key). */
onClose?: () => void;
}
interface HoverState<T> {
item: T;
x: number;
y: number;
}
/**
* A horizontally scrolling marquee of avatars with smooth hover interaction.
*
* Features:
* - Infinite scroll from right to left
* - Fade-in on right edge, fade-out on left edge
* - Smooth deceleration on hover, acceleration on leave
* - Popover with custom content on hover
*/
export default function AvatarMarquee<T>({
items,
getAvatarUrl,
getLabel,
getProfileUrl,
renderPopover,
open,
title,
onClose,
}: AvatarMarqueeProps<T>) {
const containerRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Animation state (refs to avoid re-renders)
const scrollPosRef = useRef(0);
const speedRef = useRef(1);
const targetSpeedRef = useRef(1);
const animationRef = useRef<number | null>(null);
// Drag/swipe state
const isDraggingRef = useRef(false);
const dragStartXRef = useRef(0);
const dragScrollStartRef = useRef(0);
const hasDraggedRef = useRef(false); // Track if drag exceeded threshold (to prevent click)
const velocityRef = useRef(0); // Momentum velocity
const lastDragXRef = useRef(0);
const lastDragTimeRef = useRef(0);
const momentumRef = useRef<number | null>(null); // Momentum animation frame
// Hover state for popover
const [hover, setHover] = useState<HoverState<T> | null>(null);
// Track if expand animation is complete (to delay scroll animation)
const [isExpanded, setIsExpanded] = useState(false);
// Track if we're in the process of closing (animation stopping)
const [isClosing, setIsClosing] = useState(false);
// Memoize duplicated items and widths to avoid recreating on every render
const duplicatedItems = useMemo(() => [...items, ...items, ...items], [items]);
const singleSetWidth = useMemo(() => items.length * (AVATAR_SIZE + AVATAR_GAP), [items.length]);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
}
};
}, []);
// Animation loop - only runs when fully expanded
useEffect(() => {
if (!isExpanded || items.length === 0) {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
animationRef.current = null;
}
return;
}
const animate = () => {
// Interpolate speed toward target (smooth brake/accelerate)
speedRef.current += (targetSpeedRef.current - speedRef.current) * SPEED_LERP;
// Update scroll position
scrollPosRef.current += SCROLL_SPEED * speedRef.current;
// Reset when one full set has scrolled (seamless loop)
if (scrollPosRef.current >= singleSetWidth) {
scrollPosRef.current -= singleSetWidth;
}
// Apply transform to track
if (trackRef.current) {
trackRef.current.style.transform = `translateX(-${scrollPosRef.current}px)`;
}
animationRef.current = requestAnimationFrame(animate);
};
animationRef.current = requestAnimationFrame(animate);
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
animationRef.current = null;
}
};
}, [isExpanded, items.length, singleSetWidth]);
// Height animation for open/close
useEffect(() => {
const wrapper = wrapperRef.current;
const container = containerRef.current;
if (!wrapper || !container) return;
if (open && !isClosing) {
// Measure actual content height
const contentHeight = container.scrollHeight + 24; // container + title line (~20px) + borders
wrapper.style.height = `${contentHeight}px`;
wrapper.style.opacity = "1";
// Wait for transition to complete before starting scroll animation
const onTransitionEnd = () => {
wrapper.style.overflow = "visible";
setIsExpanded(true);
};
wrapper.addEventListener("transitionend", onTransitionEnd, { once: true });
return () => wrapper.removeEventListener("transitionend", onTransitionEnd);
} else if (!open || isClosing) {
setIsExpanded(false);
wrapper.style.overflow = "hidden";
wrapper.style.height = "0px";
wrapper.style.opacity = "0";
setIsClosing(false);
}
}, [open, isClosing]);
const handleMouseEnter = useCallback((item: T, event: React.MouseEvent<HTMLAnchorElement>) => {
// Cancel any pending hide
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
hideTimeoutRef.current = null;
}
const target = event.currentTarget;
const container = containerRef.current;
if (!container) return;
const containerRect = container.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
// Calculate position relative to container
const relativeX = targetRect.left - containerRect.left + targetRect.width / 2;
const containerWidth = containerRect.width;
// Only hide popover if avatar is completely in the invisible zone (beyond fade edges)
const edgeMargin = containerWidth * 0.05;
if (relativeX < edgeMargin || relativeX > containerWidth - edgeMargin) {
return; // Don't show popover at extreme edges
}
// Brake auto-scroll only when showing a popover for a visible avatar
targetSpeedRef.current = 0; // Brake
setHover({
item,
x: relativeX,
y: targetRect.top - containerRect.top,
});
}, []);
const handleMouseLeave = useCallback((event?: React.MouseEvent) => {
// If leaving to a child (popover), do nothing here: the popover handlers manage hide.
if (event) {
const related = event.relatedTarget as Node | null;
const popoverRoot = wrapperRef.current?.querySelector('.hover-popover-root') as Node | null;
if (related && popoverRoot && popoverRoot.contains(related)) {
return;
}
}
// Delay hiding to allow mouse to reach popover
hideTimeoutRef.current = setTimeout(() => {
targetSpeedRef.current = 1; // Accelerate
setHover(null);
}, 150);
}, []);
const handlePopoverEnter = useCallback(() => {
// Cancel hide when entering popover
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
hideTimeoutRef.current = null;
}
}, []);
const handlePopoverLeave = useCallback(() => {
targetSpeedRef.current = 1; // Accelerate
setHover(null);
}, []);
// Momentum animation for iOS-like deceleration
const startMomentum = useCallback(() => {
if (momentumRef.current) {
cancelAnimationFrame(momentumRef.current);
}
const friction = 0.95; // Deceleration factor
const minVelocity = 0.5; // Stop when velocity is below this
const animateMomentum = () => {
velocityRef.current *= friction;
if (Math.abs(velocityRef.current) < minVelocity) {
velocityRef.current = 0;
momentumRef.current = null;
targetSpeedRef.current = 1; // Resume auto-scroll
return;
}
let newPos = scrollPosRef.current + velocityRef.current;
// Wrap around for seamless loop
while (newPos < 0) newPos += singleSetWidth;
while (newPos >= singleSetWidth) newPos -= singleSetWidth;
scrollPosRef.current = newPos;
if (trackRef.current) {
trackRef.current.style.transform = `translateX(-${scrollPosRef.current}px)`;
}
momentumRef.current = requestAnimationFrame(animateMomentum);
};
momentumRef.current = requestAnimationFrame(animateMomentum);
}, [singleSetWidth]);
// Drag/swipe handlers
const handleDragStart = useCallback((clientX: number) => {
// Cancel any ongoing momentum
if (momentumRef.current) {
cancelAnimationFrame(momentumRef.current);
momentumRef.current = null;
}
isDraggingRef.current = true;
hasDraggedRef.current = false;
dragStartXRef.current = clientX;
dragScrollStartRef.current = scrollPosRef.current;
lastDragXRef.current = clientX;
lastDragTimeRef.current = performance.now();
velocityRef.current = 0;
targetSpeedRef.current = 0; // Stop auto-scroll during drag
}, []);
const handleDragMove = useCallback((clientX: number) => {
if (!isDraggingRef.current) return;
const deltaX = dragStartXRef.current - clientX;
// Mark as dragged if moved more than 5px (prevents accidental drag on click)
if (Math.abs(deltaX) > 5) {
hasDraggedRef.current = true;
}
// Calculate velocity for momentum
const now = performance.now();
const dt = now - lastDragTimeRef.current;
if (dt > 0) {
velocityRef.current = (lastDragXRef.current - clientX) / dt * 16; // Normalize to ~60fps
}
lastDragXRef.current = clientX;
lastDragTimeRef.current = now;
let newPos = dragScrollStartRef.current + deltaX;
// Wrap around for seamless loop
while (newPos < 0) newPos += singleSetWidth;
while (newPos >= singleSetWidth) newPos -= singleSetWidth;
scrollPosRef.current = newPos;
if (trackRef.current) {
trackRef.current.style.transform = `translateX(-${scrollPosRef.current}px)`;
}
}, [singleSetWidth]);
const handleDragEnd = useCallback(() => {
if (!isDraggingRef.current) return;
isDraggingRef.current = false;
// Start momentum if velocity is significant
if (Math.abs(velocityRef.current) > 1) {
startMomentum();
} else {
targetSpeedRef.current = 1; // Resume auto-scroll
}
}, [startMomentum]);
// Prevent click if dragged
const handleLinkClick = useCallback((e: React.MouseEvent) => {
if (hasDraggedRef.current) {
e.preventDefault();
e.stopPropagation();
}
}, []);
// Mouse drag handlers
const onMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
handleDragStart(e.clientX);
}, [handleDragStart]);
const onMouseMove = useCallback((e: React.MouseEvent) => {
handleDragMove(e.clientX);
}, [handleDragMove]);
const onMouseUp = useCallback(() => {
handleDragEnd();
}, [handleDragEnd]);
// Touch handlers
const onTouchStart = useCallback((e: React.TouchEvent) => {
handleDragStart(e.touches[0].clientX);
}, [handleDragStart]);
const onTouchMove = useCallback((e: React.TouchEvent) => {
handleDragMove(e.touches[0].clientX);
}, [handleDragMove]);
const onTouchEnd = useCallback(() => {
handleDragEnd();
}, [handleDragEnd]);
if (items.length === 0) return null;
return (
<div
ref={wrapperRef}
className="relative transition-[height,opacity] duration-300 ease-in-out"
style={{ height: 0, opacity: 0, overflow: "hidden" }}
>
{/* Top border with centered title */}
<div className="flex items-center gap-4">
<div
className="h-px flex-1 bg-border"
style={{
maskImage: "linear-gradient(to right, transparent 0%, black 20%)",
WebkitMaskImage: "linear-gradient(to right, transparent 0%, black 20%)",
}}
/>
{title && (
<span className="text-sm font-medium text-muted">{title}</span>
)}
<div
className="h-px flex-1 bg-border"
style={{
maskImage: "linear-gradient(to left, transparent 0%, black 20%)",
WebkitMaskImage: "linear-gradient(to left, transparent 0%, black 20%)",
}}
/>
</div>
{/* Marquee container with fade masks */}
<div
ref={containerRef}
className="relative cursor-grab overflow-hidden py-4 active:cursor-grabbing"
style={{
maskImage: FADE_GRADIENT,
WebkitMaskImage: FADE_GRADIENT,
}}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
onMouseLeave={onMouseUp}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
>
{/* Scrolling track */}
<div
ref={trackRef}
className="flex items-center"
style={{ gap: AVATAR_GAP, willChange: "transform" }}
>
{duplicatedItems.map((item, index) => (
<a
key={`${getLabel(item)}-${index}`}
href={getProfileUrl(item)}
target="_blank"
rel="noopener noreferrer"
className="group flex-shrink-0"
onClick={handleLinkClick}
onMouseEnter={(e) => handleMouseEnter(item, e)}
onMouseLeave={handleMouseLeave}
>
<img
src={`${getAvatarUrl(item)}&s=128`}
alt={getLabel(item)}
width={AVATAR_SIZE}
height={AVATAR_SIZE}
className="avatar-tinted rounded-full ring-1 ring-border transition-[ring,box-shadow] duration-200 ease-out group-hover:ring-2 group-hover:ring-accent/60"
loading="lazy"
/>
</a>
))}
</div>
</div>
{/* Popover - positioned relative to wrapper, outside masked container */}
<div className="pointer-events-none absolute inset-x-0 top-0" style={{ height: AVATAR_SIZE + VERTICAL_PADDING }}>
<HoverPopover
visible={!!hover}
x={hover?.x ?? 0}
y={(hover?.y ?? 0) + 16}
minWidth="140px"
onMouseEnter={handlePopoverEnter}
onMouseLeave={handlePopoverLeave}
>
{hover && renderPopover ? (
renderPopover(hover.item)
) : (
<p className="whitespace-nowrap text-center text-sm font-medium text-foreground">
{hover ? getLabel(hover.item) : ""}
</p>
)}
</HoverPopover>
</div>
{/* Bottom border - apply same fade mask */}
<div
className="h-px bg-border"
style={{
maskImage: FADE_GRADIENT,
WebkitMaskImage: FADE_GRADIENT,
}}
/>
</div>
);
}
@@ -1,279 +0,0 @@
import { useEffect, useRef, useState } from "react";
import type { CommitEntry } from "../../../hooks/useGitHubStats";
import Icon from "../Icon";
/** Number of commits shown before the "show more" toggle. */
const INITIAL_COUNT = 8;
interface CommitListProps {
/** List of recent commits to display. */
commits: CommitEntry[];
/** Whether data is still loading. */
loading?: boolean;
}
/**
* Formats an ISO date string into separate date and time strings.
*
* Returns `{ date: "Feb 03", time: "14:32" }` for stacked display.
*/
function formatDateParts(isoDate: string): { date: string; time: string } {
const parsed = new Date(isoDate);
const month = parsed.toLocaleDateString("en-US", { month: "short" });
const day = parsed.getDate().toString().padStart(2, "0");
const hours = parsed.getHours().toString().padStart(2, "0");
const minutes = parsed.getMinutes().toString().padStart(2, "0");
return { date: `${month} ${day}`, time: `${hours}:${minutes}` };
}
/**
* Animated collapsible container.
*
* Measures content height via ResizeObserver so it correctly tracks
* changes from nested expansions (e.g. commit bodies opening inside).
*/
function AnimatedCollapse({ expanded, children }: { expanded: boolean; children: React.ReactNode }) {
const contentRef = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState(0);
useEffect(() => {
const element = contentRef.current;
if (!element) return;
if (!expanded) {
setHeight(0);
return;
}
// Set initial height
setHeight(element.scrollHeight);
// Watch for content size changes (e.g. nested commit body expanding)
const observer = new ResizeObserver(() => {
setHeight(element.scrollHeight);
});
observer.observe(element);
return () => observer.disconnect();
}, [expanded]);
return (
<div
className="overflow-hidden transition-all duration-300 ease-in-out"
style={{ maxHeight: expanded ? height : 0, opacity: expanded ? 1 : 0 }}
>
<div ref={contentRef}>
{children}
</div>
</div>
);
}
/** A single commit row with date/time, title, disclosure chevron, and SHA (desktop only). */
function CommitRow({
commit,
isBodyExpanded,
onToggleBody,
}: {
commit: CommitEntry;
isBodyExpanded: boolean;
onToggleBody: () => void;
}) {
const hasBody = commit.body !== null;
const { date, time } = formatDateParts(commit.date);
return (
<li className="animate-fade-slide-in py-2.5 first:pt-0 last:pb-0">
<div className="flex items-center gap-2 min-w-0 sm:gap-3">
{/* Date/time: left, stacked with icon */}
<div className="shrink-0 flex items-start gap-1 sm:gap-1.5">
<Icon name="clock" size={20} className="text-muted/50" />
<div className="flex flex-col items-end font-mono text-[10px] leading-tight tabular-nums sm:text-xs">
<span className="text-muted/70">{date}</span>
<span className="text-muted/50">{time}</span>
</div>
</div>
{/* Disclosure chevron + title: center */}
<div className="flex items-center gap-1 min-w-0 flex-1 overflow-hidden sm:gap-1.5">
{hasBody ? (
<button
onClick={onToggleBody}
className="shrink-0 flex h-5 w-5 items-center justify-center rounded text-muted transition-colors hover:text-foreground hover:bg-accent/10"
aria-label={isBodyExpanded ? "Collapse commit body" : "Expand commit body"}
>
<span className={`transition-transform duration-200 ${isBodyExpanded ? "rotate-90" : ""}`}>
<Icon name="chevronRight" size={20} />
</span>
</button>
) : (
<span className="w-5 shrink-0" />
)}
<span className="block truncate text-sm text-foreground/90 sm:text-base">{commit.title}</span>
</div>
{/* SHA: hidden on mobile, visible on sm+ */}
<a
href={commit.url}
target="_blank"
rel="noopener noreferrer"
className="hidden shrink-0 font-mono text-sm text-accent/70 transition-colors hover:text-accent sm:block"
>
{commit.sha}
</a>
</div>
{/* Expandable body */}
{hasBody && (
<AnimatedCollapse expanded={isBodyExpanded}>
<pre className="mt-2 ml-6 whitespace-pre-wrap break-words rounded-lg border border-border/20 bg-background/40 px-3 py-2 font-mono text-xs leading-relaxed text-muted/80 sm:ml-7 sm:px-4 sm:py-3 sm:text-sm">
{commit.body}
</pre>
</AnimatedCollapse>
)}
</li>
);
}
/**
* Displays the most recent commits with title, expandable body, and date/time + SHA.
*
* Initially shows 8 commits. A chevron at the bottom toggles the remaining commits
* with a smooth animation. All expand/collapse actions are animated.
*
* When new commits arrive (e.g. from auto-refresh), they smoothly slide in from
* the top while existing commits animate to their new positions.
*/
export default function CommitList({ commits, loading = false }: CommitListProps) {
const [expandedSha, setExpandedSha] = useState<Set<string>>(new Set());
const [showAll, setShowAll] = useState(false);
function toggleExpanded(sha: string) {
setExpandedSha((prev) => {
const next = new Set(prev);
if (next.has(sha)) {
next.delete(sha);
} else {
next.add(sha);
}
return next;
});
}
const commitsWithBody = commits.filter((commit) => commit.body !== null);
const allBodiesExpanded = commitsWithBody.length > 0 && commitsWithBody.every((commit) => expandedSha.has(commit.sha));
function toggleAll() {
if (allBodiesExpanded) {
setExpandedSha(new Set());
} else {
setExpandedSha(new Set(commitsWithBody.map((commit) => commit.sha)));
// Also show all commits so hidden bodies become visible
if (!showAll && commits.length > INITIAL_COUNT) {
setShowAll(true);
}
}
}
if (loading) {
return (
<div className="overflow-hidden rounded-xl border border-border bg-frosted-glass p-6 backdrop-blur-xl">
<h3 className="mb-4 flex items-center gap-3 text-xl font-semibold text-foreground">
<Icon name="listBullet" size={24} className="text-accent" />
<span className="whitespace-nowrap">Commits</span>
</h3>
<div className="flex flex-col gap-3">
{Array.from({ length: INITIAL_COUNT }).map((_, idx) => (
<div key={idx} className="flex items-center gap-3">
<div className="h-5 flex-1 rounded-md bg-accent/10 animate-skeleton" />
<div className="h-8 w-20 shrink-0 rounded-md bg-accent/10 animate-skeleton" />
</div>
))}
</div>
</div>
);
}
if (commits.length === 0) {
return (
<div className="overflow-hidden rounded-xl border border-border bg-frosted-glass p-6 backdrop-blur-xl">
<h3 className="mb-4 flex items-center gap-3 text-xl font-semibold text-foreground">
<Icon name="listBullet" size={24} className="text-accent" />
Recent Commits
</h3>
<p className="text-lg text-muted">No commits found.</p>
</div>
);
}
const initialCommits = commits.slice(0, INITIAL_COUNT);
const extraCommits = commits.slice(INITIAL_COUNT);
const hasMore = extraCommits.length > 0;
return (
<div className="overflow-hidden rounded-xl border border-border bg-frosted-glass p-4 backdrop-blur-xl sm:p-6">
<div className="mb-3 flex items-center justify-between sm:mb-4">
<h3 className="mb-4 flex items-center gap-3 text-xl font-semibold text-foreground">
<Icon name="listBullet" size={24} className="text-accent" />
Recent Commits
</h3>
{commitsWithBody.length > 0 && (
<button
onClick={toggleAll}
title={allBodiesExpanded ? "Collapse all" : "Expand all"}
className="flex items-center gap-1 rounded-lg px-2 py-1 text-sm text-muted transition-colors hover:bg-accent/5 hover:text-foreground sm:gap-1.5 sm:px-3"
>
<span className={`transition-transform duration-200 ${allBodiesExpanded ? "rotate-90" : ""}`}>
<Icon name="chevronRight" size={20} />
</span>
<span className="hidden sm:inline">{allBodiesExpanded ? "Collapse all" : "Expand all"}</span>
</button>
)}
</div>
{/* Always-visible commits */}
<ul className="flex flex-col divide-y divide-border/30">
{initialCommits.map((commit) => (
<CommitRow
key={commit.sha}
commit={commit}
isBodyExpanded={expandedSha.has(commit.sha)}
onToggleBody={() => toggleExpanded(commit.sha)}
/>
))}
</ul>
{/* Extra commits (collapsible) */}
{hasMore && (
<AnimatedCollapse expanded={showAll}>
<ul className="flex flex-col divide-y divide-border/30 border-t border-border/30">
{extraCommits.map((commit) => (
<CommitRow
key={commit.sha}
commit={commit}
isBodyExpanded={expandedSha.has(commit.sha)}
onToggleBody={() => toggleExpanded(commit.sha)}
/>
))}
</ul>
</AnimatedCollapse>
)}
{/* Show more / less toggle */}
{hasMore && (
<button
onClick={() => setShowAll((prev) => !prev)}
className="mt-4 flex w-full items-center justify-center gap-2 rounded-lg py-2 text-sm text-muted transition-colors hover:bg-accent/5 hover:text-foreground"
aria-label={showAll ? "Show fewer commits" : "Show all commits"}
>
<span className={`transition-transform duration-300 ${showAll ? "rotate-180" : ""}`}>
<Icon name="chevronRight" size={20} className="rotate-90" />
</span>
<span>{showAll ? "Show less" : `Show ${extraCommits.length} more`}</span>
</button>
)}
</div>
);
}
@@ -1,198 +0,0 @@
import { useState, useCallback, useEffect } from "react";
import { useGitHubStatsCache } from "../../../hooks/useGitHubStatsCache";
import Icon from "../Icon";
import StatCard from "./StatCard";
import StargazersPanel from "./StargazersPanel";
import ActivityHeatmap from "./ActivityHeatmap";
import PlansCard from "./PlansCard";
import LanguageBar from "./LanguageBar";
import CommitList from "./CommitList";
import RepoInfo from "./RepoInfo";
/**
* Formats a relative time string like "2 min ago" or "just now".
*
* Uses simple second/minute thresholds: no need for Intl.RelativeTimeFormat
* since the maximum age before auto-refresh is 5 minutes.
*/
function formatTimeAgo(timestampMs: number): string {
const seconds = Math.floor((Date.now() - timestampMs) / 1000);
if (seconds < 5) return "just now";
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (remainingSeconds === 0) return `${minutes} min ago`;
return `${minutes} min ${remainingSeconds}s ago`;
}
/**
* Formats a countdown string like "3:12" from a future timestamp.
*
* Returns "now" if the target is in the past or within 1 second.
*/
function formatCountdown(targetMs: number): string {
const remainingSeconds = Math.max(0, Math.floor((targetMs - Date.now()) / 1000));
if (remainingSeconds <= 0) return "now";
const minutes = Math.floor(remainingSeconds / 60);
const seconds = remainingSeconds % 60;
return `${minutes}:${String(seconds).padStart(2, "0")}`;
}
/**
* Dashboard content: displays live GitHub metrics for the TUIKit repository.
*
* Data is cached in localStorage for 5 minutes. Page reloads within that window
* serve cached data without hitting the GitHub API. A background timer
* auto-refreshes every 5 minutes. An animated refresh icon appears during loading.
*/
export default function DashboardContent() {
const {
lastFetchedAt,
nextRefreshAt,
isFromCache,
isRefreshing,
...stats
} = useGitHubStatsCache();
const [showStargazers, setShowStargazers] = useState(false);
// Tick every second to update the "last updated" and countdown displays
const [, setTick] = useState(0);
useEffect(() => {
const timer = setInterval(() => setTick((prev) => prev + 1), 1000);
return () => clearInterval(timer);
}, []);
// Preload stargazer avatar images in background so the panel opens instantly
useEffect(() => {
if (!stats.stargazers || stats.stargazers.length === 0) return;
const head = document.head || document.getElementsByTagName('head')[0];
const existing = new Set(Array.from(head.querySelectorAll('link[rel="preload"][as="image"]')).map((l) => (l as HTMLLinkElement).href));
stats.stargazers.slice(0, 100).forEach((s) => {
const url = s.avatarUrl + '&s=128';
if (existing.has(url)) return;
const link = document.createElement('link');
link.rel = 'preload';
link.as = 'image';
link.href = url;
head.appendChild(link);
});
}, [stats.stargazers]);
const toggleStargazers = useCallback(() => setShowStargazers((prev) => !prev), []);
const closeStargazers = useCallback(() => setShowStargazers(false), []);
return (
<>
{/* Header with loading indicator */}
<div className="mb-6 flex flex-col items-center text-center sm:mb-10 sm:flex-row sm:items-center sm:justify-between sm:text-left">
<div>
<h1 className="text-3xl font-bold text-foreground sm:text-4xl">Project Dashboard</h1>
<p className="mt-1 text-base text-muted sm:text-lg">
Live metrics · <a href="https://github.com/phranck/TUIkit" target="_blank" rel="noopener noreferrer" className="text-accent transition-colors hover:text-foreground">phranck/TUIkit</a>
</p>
</div>
{/* Refresh icon: fades in while refreshing, spins */}
{isRefreshing && (
<div
className="mt-3 flex items-center justify-center animate-fade-scale-in sm:mt-0"
aria-label="Refreshing data"
>
<span className="animate-spin-slow text-muted">
<Icon name="refresh" size={20} />
</span>
</div>
)}
</div>
{/* Error state */}
{stats.error && (
<div className="mb-8 rounded-xl border border-red-500/30 bg-red-500/10 p-4 text-base text-red-400">
<strong>Error:</strong> {stats.error}
<span className="ml-3 text-muted/60">Will retry automatically</span>
</div>
)}
{/* Stat cards: row 1 */}
<div className="mb-4 grid grid-cols-2 gap-4 md:grid-cols-4">
<StatCard id="stat-card-stars" label="Stars" value={stats.stars} icon="star" loading={stats.loading} onClick={toggleStargazers} active={showStargazers} />
<StatCard id="stat-card-contributors" label="Contributors" value={stats.contributors} icon="person2" loading={stats.loading} />
<StatCard label="Forks" value={stats.forks} icon="branch" loading={stats.loading} />
<StatCard label="Releases" value={stats.releases} icon="shippingbox" loading={stats.loading} />
</div>
{/* Stargazers panel: expands between the two rows */}
<div className={showStargazers ? "mb-4" : ""}>
<StargazersPanel
stargazers={stats.stargazers}
totalStars={stats.stars}
open={showStargazers}
onClose={closeStargazers}
/>
</div>
{/* Stat cards: row 2 */}
<div className="mb-8 grid grid-cols-2 gap-4 md:grid-cols-4">
<StatCard label="Commits" value={stats.totalCommits} icon="numberCircle" loading={stats.loading} />
<StatCard label="Open Issues" value={stats.openIssues} icon="issue" loading={stats.loading} />
<StatCard label="Open PRs" value={stats.openPRs} icon="pullRequest" loading={stats.loading} />
<StatCard label="Merged PRs" value={stats.mergedPRs} icon="merge" loading={stats.loading} />
</div>
{/* Activity heatmap: hidden on mobile */}
<div className="mb-8 hidden sm:block">
<ActivityHeatmap weeks={stats.weeklyActivity} loading={stats.loading} />
</div>
{/* Plans Card */}
<div className="mb-8">
<PlansCard />
</div>
{/* Languages + Repo Info + Commits */}
<div className="mb-8 grid gap-8 lg:grid-cols-[1fr_2fr]">
<div className="flex flex-col gap-8">
<LanguageBar languages={stats.languages} loading={stats.loading} />
<RepoInfo
createdAt={stats.createdAt}
license={stats.license}
size={stats.size}
defaultBranch={stats.defaultBranch}
pushedAt={stats.pushedAt}
loading={stats.loading}
/>
</div>
<CommitList commits={stats.recentCommits} loading={stats.loading} />
</div>
{/* Footer: cache status + rate limit */}
<div className="flex flex-col items-center gap-2 font-mono text-xs text-muted/60 lg:flex-row lg:justify-between lg:text-sm">
<div className="flex flex-wrap items-center justify-center gap-x-2 gap-y-1 text-center lg:justify-start lg:text-left">
{lastFetchedAt && (
<>
<span>
Updated {formatTimeAgo(lastFetchedAt)}
{isFromCache && (
<span className="ml-1 rounded bg-white/5 px-1 py-0.5 text-[10px] text-muted/40 lg:ml-1.5 lg:px-1.5 lg:text-xs">
cached
</span>
)}
</span>
{nextRefreshAt && (
<span className="text-muted/40">
· Next in {formatCountdown(nextRefreshAt)}
</span>
)}
</>
)}
</div>
{stats.rateLimit && (
<div className="text-center lg:text-right">
API rate limit: {stats.rateLimit.remaining}/{stats.rateLimit.limit} remaining
</div>
)}
</div>
</>
);
}
@@ -1,75 +0,0 @@
import type { ReactNode } from "react";
interface HoverPopoverProps {
/** Whether the popover is currently visible. */
visible: boolean;
/** Horizontal center position relative to the positioned parent. */
x: number;
/** Top edge position relative to the positioned parent. */
y: number;
/** Vertical offset from y (negative = above). */
offsetY?: number;
/** Minimum width of the popover. */
minWidth?: string;
/** Content rendered inside the popover bubble. */
children: ReactNode;
/** Called when mouse enters the popover (to cancel hide). */
onMouseEnter?: () => void;
/** Called when mouse leaves the popover. */
onMouseLeave?: () => void;
}
/**
* A floating popover with arrow that fades in/out at a given position.
*
* Must be placed inside a `position: relative` container.
* Centers horizontally on `x` and positions above `y` by default.
*/
export default function HoverPopover({
visible,
x,
y,
offsetY = -10,
minWidth,
children,
onMouseEnter,
onMouseLeave,
}: HoverPopoverProps) {
return (
<div
className="absolute z-20"
style={{
left: x,
top: y + offsetY,
transform: "translateX(-50%) translateY(-100%)",
pointerEvents: visible ? "auto" : "none",
}}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{/* Invisible bridge area to help mouse travel from avatar to popover */}
<div
className="absolute left-1/2 -translate-x-1/2 w-16"
style={{ height: Math.abs(offsetY) + 20, bottom: -Math.abs(offsetY) - 10 }}
/>
<div
className="rounded-lg border border-border px-4 py-2 shadow-lg shadow-black/30"
style={{
minWidth,
backgroundColor: "var(--container-body)",
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(15%)",
transition: "opacity 200ms ease-out, transform 200ms ease-out",
}}
>
{children}
<div
className="absolute left-1/2 -bottom-[7px] h-3 w-3 -translate-x-1/2 rotate-45 border-b border-r border-border"
style={{ backgroundColor: "var(--container-body)" }}
/>
</div>
</div>
);
}
@@ -1,102 +0,0 @@
import type { LanguageBreakdown } from "../../../hooks/useGitHubStats";
import Icon from "../Icon";
interface LanguageBarProps {
/** Language byte counts from the GitHub API. */
languages: LanguageBreakdown;
/** Whether data is still loading. */
loading?: boolean;
}
/**
* Color palette for up to 6 languages, using theme-consistent colors.
*
* Swift gets the accent color, others get progressively muted tones.
*/
const LANG_COLORS = [
"bg-accent",
"bg-accent-secondary",
"bg-muted",
"bg-border",
"bg-foreground/30",
"bg-foreground/15",
];
/**
* A horizontal stacked bar showing the language breakdown of the repository.
*
* Each segment is proportional to the byte count. A legend below the bar lists
* each language with its percentage.
*/
export default function LanguageBar({ languages, loading = false }: LanguageBarProps) {
if (loading) {
return (
<div className="rounded-xl border border-border bg-frosted-glass p-6 backdrop-blur-xl">
<h3 className="mb-4 flex items-center gap-3 text-xl font-semibold text-foreground">
<Icon name="code" size={24} className="text-accent" />
Languages
</h3>
<div className="h-4 w-full rounded-full bg-accent/10 animate-skeleton" />
<div className="mt-3 flex gap-4">
<div className="h-4 w-16 rounded-md bg-accent/10 animate-skeleton" />
<div className="h-4 w-12 rounded-md bg-accent/10 animate-skeleton" />
</div>
</div>
);
}
const entries = Object.entries(languages).sort((entryA, entryB) => entryB[1] - entryA[1]);
const totalBytes = entries.reduce((sum, [, bytes]) => sum + bytes, 0);
if (totalBytes === 0) {
return (
<div className="rounded-xl border border-border bg-frosted-glass p-6 backdrop-blur-xl">
<h3 className="mb-4 flex items-center gap-3 text-xl font-semibold text-foreground">
<Icon name="code" size={24} className="text-accent" />
Languages
</h3>
<p className="text-lg text-muted">No language data available.</p>
</div>
);
}
return (
<div className="rounded-xl border border-border bg-frosted-glass p-6 backdrop-blur-xl">
<h3 className="mb-4 flex items-center gap-3 text-xl font-semibold text-foreground">
<Icon name="code" size={24} className="text-accent" />
Languages
</h3>
{/* Stacked bar */}
<div className="flex h-4 w-full overflow-hidden rounded-full">
{entries.map(([lang, bytes], idx) => {
const pct = (bytes / totalBytes) * 100;
return (
<div
key={lang}
className={`${LANG_COLORS[idx % LANG_COLORS.length]} transition-all duration-300`}
style={{ width: `${Math.max(pct, 0.5)}%` }}
title={`${lang}: ${pct.toFixed(1)}%`}
/>
);
})}
</div>
{/* Legend */}
<div className="mt-3 flex flex-wrap gap-x-5 gap-y-1">
{entries.map(([lang, bytes], idx) => {
const pct = (bytes / totalBytes) * 100;
return (
<div key={lang} className="flex items-center gap-1.5 text-base text-muted">
<span className={`inline-block h-3 w-3 rounded-sm ${LANG_COLORS[idx % LANG_COLORS.length]}`} />
<span>{lang}</span>
<span className="text-foreground/60">{pct < 0.1 ? "<0.1" : pct.toFixed(1)}%</span>
</div>
);
})}
</div>
</div>
);
}
@@ -1,211 +0,0 @@
import { useState, useRef, useEffect } from "react";
import { usePlansCache } from "../../../hooks/usePlansCache";
import ReactMarkdown from "react-markdown";
import Icon from "../Icon";
/**
* Animated collapsible container.
* Measures content height via ref so it correctly animates open/close.
*/
function AnimatedCollapse({ expanded, children }: { expanded: boolean; children: React.ReactNode }) {
const contentRef = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState(0);
useEffect(() => {
const element = contentRef.current;
if (!element) return;
if (!expanded) {
setHeight(0);
return;
}
setHeight(element.scrollHeight);
const observer = new ResizeObserver(() => {
setHeight(element.scrollHeight);
});
observer.observe(element);
return () => observer.disconnect();
}, [expanded]);
return (
<div
className="overflow-hidden transition-all duration-300 ease-in-out"
style={{ maxHeight: expanded ? height : 0, opacity: expanded ? 1 : 0 }}
>
<div ref={contentRef}>
{children}
</div>
</div>
);
}
/** Number of plans shown before expanding. */
const COLLAPSED_COUNT = 1;
/** Maximum number of plans to display per section. */
const MAX_PLANS = 6;
interface Plan {
date: string;
slug: string;
title: string;
preface: string;
}
/**
* Renders a plan item with date, title, and preface (with markdown support).
*/
function PlanItem({ plan, isDone }: { plan: Plan; isDone: boolean }) {
const [year, month, day] = plan.date.split("-");
return (
<div className="border-l-2 border-accent/30 pl-4 py-3">
{/* Date + Title */}
<div className="flex items-baseline gap-2">
<span className="text-sm font-mono text-muted/60">{year}-{month}-{day}</span>
<h4 className="text-lg font-semibold text-foreground">{plan.title}</h4>
</div>
{/* Preface with markdown rendering */}
<div className="mt-2 text-lg text-muted prose prose-lg max-w-none [&_strong]:font-semibold [&_strong]:text-foreground [&_em]:italic [&_em]:text-muted [&_code]:bg-background/50 [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-accent [&_a]:text-accent [&_a]:underline [&_a:hover]:no-underline">
<ReactMarkdown
components={{
p: ({ children }) => <p className="m-0 leading-relaxed">{children}</p>,
strong: ({ children }) => <strong>{children}</strong>,
em: ({ children }) => <em>{children}</em>,
code: ({ children }) => <code>{children}</code>,
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
),
}}
>
{plan.preface}
</ReactMarkdown>
</div>
</div>
);
}
/**
* Collapsible section with header badge and expand/collapse toggle.
*/
function PlansSection({
title,
plans: allPlans,
isDone,
}: {
title: string;
plans: Plan[];
isDone: boolean;
}) {
const [expanded, setExpanded] = useState(false);
const plans = allPlans.slice(0, MAX_PLANS);
const hasMore = plans.length > COLLAPSED_COUNT;
const hiddenCount = plans.length - COLLAPSED_COUNT;
return (
<div>
{/* Section header with toggle */}
<button
onClick={() => hasMore && setExpanded(!expanded)}
className={`mb-3 inline-flex items-center gap-2 rounded bg-muted px-3 py-1 text-xs font-bold uppercase tracking-wider text-background ${hasMore ? "cursor-pointer hover:bg-muted/80" : "cursor-default"}`}
disabled={!hasMore}
>
{hasMore && (
<span className={`transition-transform duration-200 ${expanded ? "rotate-90" : ""}`}>
<Icon name="chevronRight" size={20} />
</span>
)}
{title}
{hasMore && !expanded && (
<span className="font-normal opacity-70">+{hiddenCount}</span>
)}
</button>
{/* Always visible plans */}
<div className="space-y-4">
{plans.slice(0, COLLAPSED_COUNT).map((plan) => (
<PlanItem key={plan.slug} plan={plan} isDone={isDone} />
))}
</div>
{/* Animated extra plans */}
{hasMore && (
<AnimatedCollapse expanded={expanded}>
<div className="space-y-4 pt-4">
{plans.slice(COLLAPSED_COUNT).map((plan) => (
<PlanItem key={plan.slug} plan={plan} isDone={isDone} />
))}
</div>
</AnimatedCollapse>
)}
</div>
);
}
/**
* Plans Card: displays top 5 open and top 5 done plans from plans.json.
* Includes markdown rendering for prefaces (bold, italics, code, links).
* Each section is collapsible, showing 2 plans by default.
*/
export default function PlansCard() {
const { data, loading, error, isFromCache } = usePlansCache();
if (loading) {
return (
<div className="rounded-xl border border-border bg-frosted-glass p-6 backdrop-blur-xl">
<h3 className="mb-4 flex items-center gap-3 text-xl font-semibold text-foreground">
<Icon name="document" size={24} className="text-accent" />
Development Plans
</h3>
<div className="space-y-3">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-16 w-full animate-skeleton rounded bg-accent/10" />
))}
</div>
</div>
);
}
if (error || !data) {
return (
<div className="rounded-xl border border-red-500/30 bg-red-500/10 p-6 backdrop-blur-xl text-sm text-red-400">
<strong>Error loading plans:</strong> {error || "No data"}
</div>
);
}
return (
<div className="rounded-xl border border-border bg-frosted-glass p-6 backdrop-blur-xl">
{/* Header */}
<h3 className="mb-4 flex items-center gap-3 text-xl font-semibold text-foreground">
<Icon name="document" size={24} className="text-accent" />
Development Plans
</h3>
{/* Open Plans Section */}
{data.open.length > 0 && (
<div className="mb-6">
<PlansSection title="Open" plans={data.open} isDone={false} />
</div>
)}
{/* Divider */}
{data.open.length > 0 && data.done.length > 0 && (
<div className="my-6 border-t border-border/10" />
)}
{/* Done Plans Section */}
{data.done.length > 0 && (
<PlansSection title="Recently Completed" plans={data.done} isDone={true} />
)}
</div>
);
}
@@ -1,112 +0,0 @@
import Icon from "../Icon";
interface RepoInfoProps {
createdAt: string;
license: string | null;
size: number;
defaultBranch: string;
pushedAt: string;
loading?: boolean;
}
/**
* Formats a file size from kilobytes to a human-readable string.
*
* GitHub reports repo size in KB. This converts to KB, MB, or GB as appropriate.
*/
function formatSize(sizeKB: number): string {
if (sizeKB < 1024) return `${sizeKB} KB`;
if (sizeKB < 1048576) return `${(sizeKB / 1024).toFixed(1)} MB`;
return `${(sizeKB / 1048576).toFixed(1)} GB`;
}
/**
* Formats an ISO date string into a human-readable relative time.
*
* Produces strings like "3 hours ago" or "2 days ago".
*/
function relativeTime(isoDate: string): string {
if (!isoDate) return "—";
const now = Date.now();
const then = new Date(isoDate).getTime();
const diffSeconds = Math.floor((now - then) / 1000);
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
if (diffSeconds < 60) return rtf.format(-diffSeconds, "second");
if (diffSeconds < 3600) return rtf.format(-Math.floor(diffSeconds / 60), "minute");
if (diffSeconds < 86400) return rtf.format(-Math.floor(diffSeconds / 3600), "hour");
if (diffSeconds < 2592000) return rtf.format(-Math.floor(diffSeconds / 86400), "day");
if (diffSeconds < 31536000) return rtf.format(-Math.floor(diffSeconds / 2592000), "month");
return rtf.format(-Math.floor(diffSeconds / 31536000), "year");
}
/**
* Formats an ISO date string into a short date (e.g., "Jan 28, 2026").
*/
function formatDate(isoDate: string): string {
if (!isoDate) return "—";
return new Date(isoDate).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
/** A single metadata row with label and value. */
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between gap-4 border-b border-border/30 py-2 last:border-0">
<span className="text-base text-muted">{label}</span>
<span className="text-base font-medium text-foreground">{value}</span>
</div>
);
}
/**
* Displays repository metadata in a clean key-value layout.
*
* Shows creation date, license, size, default branch, and last push time.
*/
export default function RepoInfo({
createdAt,
license,
size,
defaultBranch,
pushedAt,
loading = false,
}: RepoInfoProps) {
if (loading) {
return (
<div className="rounded-xl border border-border bg-frosted-glass p-6 backdrop-blur-xl">
<h3 className="mb-4 flex items-center gap-3 text-xl font-semibold text-foreground">
<Icon name="serverRack" size={24} className="text-accent" />
Repository
</h3>
<div className="flex flex-col gap-2">
{Array.from({ length: 5 }).map((_, idx) => (
<div key={idx} className="h-5 w-full rounded-md bg-accent/10 animate-skeleton" />
))}
</div>
</div>
);
}
return (
<div className="rounded-xl border border-border bg-frosted-glass p-6 backdrop-blur-xl">
<h3 className="mb-4 flex items-center gap-3 text-xl font-semibold text-foreground">
<Icon name="serverRack" size={24} className="text-accent" />
Repository
</h3>
<div className="flex flex-col">
<InfoRow label="Created" value={formatDate(createdAt)} />
<InfoRow label="License" value={license ?? "None"} />
<InfoRow label="Size" value={formatSize(size)} />
<InfoRow label="Default Branch" value={defaultBranch || "—"} />
<InfoRow label="Last Push" value={relativeTime(pushedAt)} />
</div>
</div>
);
}
@@ -1,66 +0,0 @@
import type { Stargazer } from "../../../hooks/useGitHubStats";
import Icon from "../Icon";
interface StargazerPopoverContentProps {
user: Stargazer;
}
/**
* Popover content for a stargazer, showing username and social links.
*/
export default function StargazerPopoverContent({ user }: StargazerPopoverContentProps) {
const hasSocial = user.mastodon || user.twitter || user.bluesky;
return (
<div className="flex flex-col items-center gap-1.5">
<p className="whitespace-nowrap text-center text-sm font-medium text-foreground">
{user.login}
</p>
{hasSocial && (
<div className="flex flex-col items-start gap-1">
{user.mastodon && (
<a
href={user.mastodon.url}
target="_blank"
rel="noopener noreferrer"
className="pointer-events-auto flex items-center gap-1.5 text-muted hover:text-accent transition-colors text-xs"
onClick={(e) => e.stopPropagation()}
title={user.mastodon.handle}
>
<Icon name="mastodon" size={20} />
<span>Mastodon</span>
</a>
)}
{user.bluesky && (
<a
href={user.bluesky.url}
target="_blank"
rel="noopener noreferrer"
className="pointer-events-auto flex items-center gap-1.5 text-muted hover:text-accent transition-colors text-xs"
onClick={(e) => e.stopPropagation()}
title={user.bluesky.handle}
>
<Icon name="bluesky" size={20} />
<span>Bluesky</span>
</a>
)}
{user.twitter && (
<a
href={user.twitter.url}
target="_blank"
rel="noopener noreferrer"
className="pointer-events-auto flex items-center gap-1.5 text-muted hover:text-accent transition-colors text-xs"
onClick={(e) => e.stopPropagation()}
title={user.twitter.handle}
>
<Icon name="twitter" size={20} />
<span>Twitter</span>
</a>
)}
</div>
)}
</div>
);
}
@@ -1,43 +0,0 @@
import { useCallback } from "react";
import type { Stargazer } from "../../../hooks/useGitHubStats";
import AvatarMarquee from "./AvatarMarquee";
import StargazerPopoverContent from "./StargazerPopoverContent";
interface StargazersPanelProps {
/** List of users who starred the repository. */
stargazers: Stargazer[];
/** Total number of stars (may exceed stargazers.length due to API pagination). */
totalStars: number;
/** Controls the expand/collapse animation. */
open: boolean;
/** Callback when panel requests to close. */
onClose?: () => void;
}
/**
* Horizontally scrolling marquee of stargazer avatars.
*
* Displays between the two StatCard rows when the Stars card is clicked.
* Uses the generic AvatarMarquee component with stargazer-specific popover content.
*/
export default function StargazersPanel({ stargazers, open, onClose }: StargazersPanelProps) {
const getAvatarUrl = useCallback((s: Stargazer) => s.avatarUrl, []);
const getLabel = useCallback((s: Stargazer) => s.login, []);
const getProfileUrl = useCallback((s: Stargazer) => s.profileUrl, []);
const renderPopover = useCallback((s: Stargazer) => <StargazerPopoverContent user={s} />, []);
return (
<AvatarMarquee
items={stargazers}
getAvatarUrl={getAvatarUrl}
getLabel={getLabel}
getProfileUrl={getProfileUrl}
renderPopover={renderPopover}
open={open}
title="Stargazers"
onClose={onClose}
/>
);
}
@@ -1,73 +0,0 @@
import type { IconName } from "../Icon";
import Icon from "../Icon";
interface StatCardProps {
/** The stat label shown next to the icon. */
label: string;
/** The numeric value to display. */
value: number;
/** SF Symbol icon name displayed next to the label. */
icon: IconName;
/** Whether data is still loading (shows skeleton). */
loading?: boolean;
/** Optional click handler: makes the card interactive. */
onClick?: () => void;
/** Whether this card is currently in active/expanded state. */
active?: boolean;
/** Optional ID for targeting (e.g., for arrow positioning). */
id?: string;
}
/**
* A single metric card with icon + label on top and the number below.
*
* On mobile: stacked vertically (icon+label top, number bottom center).
* On larger screens: horizontal layout (icon+label left, number right).
*
* When `onClick` is provided, renders as a `<button>` with native keyboard
* and focus support. Otherwise renders as a static `<div>`.
*/
export default function StatCard({ label, value, icon, loading = false, onClick, active = false, id }: StatCardProps) {
const interactive = !!onClick;
const baseClasses = "flex w-full flex-col items-center gap-2 rounded-xl border p-4 backdrop-blur-xl transition-all duration-300 sm:flex-row sm:items-center sm:justify-between sm:gap-3 sm:p-5";
const stateClasses = active
? "border-accent/50 bg-accent/10"
: "border-border bg-frosted-glass hover:border-accent/30";
const interactiveClasses = interactive
? "cursor-pointer hover:bg-accent/5 hover:scale-[1.02] active:scale-[0.98]"
: "";
const className = `${baseClasses} ${stateClasses} ${interactiveClasses}`;
const content = loading ? (
<div className="flex w-full flex-col items-center gap-2 sm:flex-row sm:justify-between">
<div className="flex items-center gap-2">
<div className="h-5 w-5 rounded-md bg-accent/10 animate-skeleton sm:h-6 sm:w-6" />
<div className="h-4 w-14 rounded-md bg-accent/10 animate-skeleton sm:h-5 sm:w-16" />
</div>
<div className="h-7 w-12 rounded-md bg-accent/10 animate-skeleton sm:h-8 sm:w-14" />
</div>
) : (
<>
<p className="flex items-center gap-1.5 text-sm text-muted sm:gap-2 sm:text-lg">
<Icon name={icon} size={20} className="text-accent" />
<span className="whitespace-nowrap">{label}</span>
</p>
<p className="text-2xl font-bold text-foreground text-glow tabular-nums sm:text-3xl">
{value.toLocaleString()}
</p>
</>
);
if (interactive) {
return (
<button type="button" id={id} onClick={onClick} className={className}>
{content}
</button>
);
}
return <div id={id} className={className}>{content}</div>;
}
-779
View File
@@ -1,779 +0,0 @@
/**
* Auto-generated from terminal-script.md
* DO NOT EDIT THIS FILE DIRECTLY - Edit terminal-script.md instead
*/
import type { TerminalScript } from "../../lib/terminal-parser";
export const TERMINAL_SCRIPT: TerminalScript = {
"config": {
"initialCursorDelay": 1500,
"schoolTrigger": 12,
"joshuaTrigger": 12,
"typeMin": 40,
"typeMax": 80,
"pauseBeforeOutput": 400,
"pauseAfterOutput": 1200
},
"bootSequence": [
{
"type": "instant",
"text": "<b>BIOS</b> v3.21 (C) 1984",
"delayAfter": 2000
},
{
"type": "instant",
"text": "CPU: MC68020 @ 16MHz",
"delayAfter": 1800
},
{
"type": "counter",
"prefix": "Memory Test: ",
"target": 4096,
"suffix": "K OK",
"delayAfter": 1800
},
{
"type": "instant",
"text": "",
"delayAfter": 1200
},
{
"type": "dots",
"text": "Detecting drives",
"dotCount": 3,
"delayAfter": 1600
},
{
"type": "instant",
"text": "hd0: 72MB CDC Wren",
"delayAfter": 1400
},
{
"type": "instant",
"text": "fd0: 1.2MB floppy",
"delayAfter": 1200
},
{
"type": "instant",
"text": "",
"delayAfter": 1600
},
{
"type": "instant",
"text": "Booting from hd(0,0)...",
"delayAfter": 3200
},
{
"type": "clear"
},
{
"type": "pause",
"delayAfter": 1200
},
{
"type": "instant",
"text": "<b>UNIX System V</b> Release 3.2",
"delayAfter": 1400
},
{
"type": "instant",
"text": "Copyright (C) 1984 AT&T",
"delayAfter": 1200
},
{
"type": "instant",
"text": "All Rights Reserved",
"delayAfter": 2000
},
{
"type": "instant",
"text": "",
"delayAfter": 1000
},
{
"type": "type",
"text": "Loading kernel modules",
"delayAfter": 1200
},
{
"type": "instant",
"text": "<b>[ok]</b> tty",
"delayAfter": 900
},
{
"type": "instant",
"text": "<b>[ok]</b> hd",
"delayAfter": 800
},
{
"type": "instant",
"text": "<b>[ok]</b> lp",
"delayAfter": 750
},
{
"type": "instant",
"text": "<b>[ok]</b> inet",
"delayAfter": 900
},
{
"type": "instant",
"text": "<b>[ok]</b> pty",
"delayAfter": 1600
},
{
"type": "instant",
"text": "",
"delayAfter": 1000
},
{
"type": "dots",
"text": "Starting services",
"dotCount": 3,
"delayAfter": 1400
},
{
"type": "instant",
"text": "syslogd <b>[ok]</b>",
"delayAfter": 1100
},
{
"type": "instant",
"text": "inetd <b>[ok]</b>",
"delayAfter": 900
},
{
"type": "instant",
"text": "cron <b>[ok]</b>",
"delayAfter": 1000
},
{
"type": "instant",
"text": "telnetd <b>[ok]</b>",
"delayAfter": 1800
},
{
"type": "instant",
"text": "uucpd <b>[ok]</b>",
"delayAfter": 700
},
{
"type": "instant",
"text": "ftpd <b>[ok]</b>",
"delayAfter": 900
},
{
"type": "instant",
"text": "",
"delayAfter": 1200
},
{
"type": "instant",
"text": "pandora login: operator",
"delayAfter": 1800
},
{
"type": "instant",
"text": "Password: ********",
"delayAfter": 1400
},
{
"type": "instant",
"text": "",
"delayAfter": 1000
},
{
"type": "instant",
"text": "Last login: Fri Jan 30 on ttyp0",
"delayAfter": 1200
},
{
"type": "instant",
"text": "from 10.0.1.5",
"delayAfter": 1400
},
{
"type": "instant",
"text": "",
"delayAfter": 800
},
{
"type": "instant",
"text": "*** <u>AUTHORIZED USE ONLY</u> ***",
"delayAfter": 1800
},
{
"type": "instant",
"text": "",
"delayAfter": 1600
},
{
"type": "clear"
},
{
"type": "pause",
"delayAfter": 1000
}
],
"schoolSequence": [
{
"type": "clear"
},
{
"type": "pause",
"delayAfter": 1500
},
{
"type": "system",
"text": "CRYSTAL SPRINGS HIGH SCHOOL",
"delayAfter": 800
},
{
"type": "system",
"text": "ADMINISTRATIVE SYSTEM",
"delayAfter": 1200
},
{
"type": "system",
"text": "",
"delayAfter": 800
},
{
"type": "inline",
"prompt": "USER: ",
"text": "DABNEY",
"delayAfter": 1800
},
{
"type": "inline",
"prompt": "PASSWORD: ",
"text": "PENCIL",
"delayAfter": 2000
},
{
"type": "system",
"text": "",
"delayAfter": 800
},
{
"type": "system",
"text": "LOGGED IN AS: MR. DABNEY",
"delayAfter": 1600
},
{
"type": "system",
"text": "",
"delayAfter": 1200
},
{
"type": "system",
"text": "1. STUDENT RECORDS",
"delayAfter": 400
},
{
"type": "system",
"text": "2. GRADE REPORTS",
"delayAfter": 400
},
{
"type": "system",
"text": "3. ATTENDANCE",
"delayAfter": 400
},
{
"type": "system",
"text": "4. LOGOUT",
"delayAfter": 1400
},
{
"type": "system",
"text": "",
"delayAfter": 800
},
{
"type": "inline",
"prompt": "SELECT: ",
"text": "2",
"delayAfter": 1800
},
{
"type": "system",
"text": "",
"delayAfter": 1000
},
{
"type": "inline",
"prompt": "STUDENT NAME: ",
"text": "LIGHTMAN",
"delayAfter": 1800
},
{
"type": "system",
"text": "",
"delayAfter": 1200
},
{
"type": "system",
"text": "LIGHTMAN, DAVID",
"delayAfter": 1000
},
{
"type": "system",
"text": "GRADE 11 ID: 4471829",
"delayAfter": 1400
},
{
"type": "system",
"text": "",
"delayAfter": 800
},
{
"type": "system",
"text": "<u>SUBJECT GRADE </u>",
"delayAfter": 400
},
{
"type": "system",
"text": "BIOLOGY F",
"delayAfter": 400
},
{
"type": "system",
"text": "ENGLISH D",
"delayAfter": 400
},
{
"type": "system",
"text": "HISTORY D",
"delayAfter": 400
},
{
"type": "system",
"text": "PHYS ED C",
"delayAfter": 400
},
{
"type": "system",
"text": "MATHEMATICS F",
"delayAfter": 1800
},
{
"type": "system",
"text": "",
"delayAfter": 1000
},
{
"type": "inline",
"prompt": "CHANGE GRADE (Y/N): ",
"text": "Y",
"delayAfter": 1400
},
{
"type": "inline",
"prompt": "SUBJECT: ",
"text": "BIOLOGY",
"delayAfter": 1600
},
{
"type": "inline",
"prompt": "NEW GRADE: ",
"text": "A",
"delayAfter": 1400
},
{
"type": "system",
"text": "GRADE UPDATED.",
"delayAfter": 1800
},
{
"type": "system",
"text": "",
"delayAfter": 1200
},
{
"type": "inline",
"prompt": "STUDENT NAME: ",
"text": "MACK",
"delayAfter": 1800
},
{
"type": "system",
"text": "",
"delayAfter": 1200
},
{
"type": "system",
"text": "MACK, JENNIFER",
"delayAfter": 1000
},
{
"type": "system",
"text": "GRADE 11 ID: 4472156",
"delayAfter": 1400
},
{
"type": "system",
"text": "",
"delayAfter": 800
},
{
"type": "system",
"text": "<u>SUBJECT GRADE </u>",
"delayAfter": 400
},
{
"type": "system",
"text": "BIOLOGY C",
"delayAfter": 400
},
{
"type": "system",
"text": "ENGLISH B",
"delayAfter": 400
},
{
"type": "system",
"text": "HISTORY B",
"delayAfter": 400
},
{
"type": "system",
"text": "PHYS ED A",
"delayAfter": 400
},
{
"type": "system",
"text": "MATHEMATICS F",
"delayAfter": 1800
},
{
"type": "system",
"text": "",
"delayAfter": 1000
},
{
"type": "inline",
"prompt": "CHANGE GRADE (Y/N): ",
"text": "Y",
"delayAfter": 1400
},
{
"type": "inline",
"prompt": "SUBJECT: ",
"text": "BIOLOGY",
"delayAfter": 1600
},
{
"type": "inline",
"prompt": "NEW GRADE: ",
"text": "A",
"delayAfter": 1400
},
{
"type": "system",
"text": "GRADE UPDATED.",
"delayAfter": 1800
},
{
"type": "system",
"text": "",
"delayAfter": 1200
},
{
"type": "inline",
"prompt": "CONTINUE (Y/N): ",
"text": "N",
"delayAfter": 1400
},
{
"type": "system",
"text": "",
"delayAfter": 800
},
{
"type": "system",
"text": "LOGGING OUT...",
"delayAfter": 1600
},
{
"type": "clear"
},
{
"type": "pause",
"delayAfter": 2000
}
],
"joshuaSequence": [],
"unixCommands": [
{
"prompt": "$",
"command": "ls -la",
"output": [
"drwxr-xr-x 12 root",
"-rw-r--r-- 1 .profile",
"-rw------- 1 .runcom",
"drwx------ 3 .rhost"
]
},
{
"prompt": "$",
"command": "ls /etc",
"output": [
"hosts passwd",
"inittab shadow",
"fstab group",
"rc2.d motd"
]
},
{
"prompt": "$",
"command": "ls -l /var/adm",
"output": [
"-rw-r----- syslog 47K",
"-rw-r----- sulog 8K",
"-rw-r----- messages 12K",
"-rw-r----- wtmp 31K"
]
},
{
"prompt": "$",
"command": "pwd",
"output": [
"/usr/operator"
]
},
{
"prompt": "$",
"command": "du -s /var/adm/*",
"output": [
"94 syslog",
"16 sulog",
"24 messages",
"62 wtmp"
]
},
{
"prompt": "$",
"command": "find /etc -name '*.conf'",
"output": [
"/etc/resolv.conf",
"/etc/ntp.conf",
"/etc/syslog.conf",
"/etc/uucp/Systems"
]
},
{
"prompt": "$",
"command": "file /bin/sh",
"output": [
"MC68020 executable",
"not stripped"
]
},
{
"prompt": "$",
"command": "ls /dev/console",
"output": [
"crw--w--w- 0,0 console"
]
},
{
"prompt": "$",
"command": "ps -ef | head -5",
"output": [
" PID TTY TIME CMD",
" 0 ? 0:12 sched",
" 1 ? 0:03 /etc/init",
" 42 ? 0:01 /etc/cron",
" 58 co 0:00 /bin/sh"
]
},
{
"prompt": "$",
"command": "uptime",
"output": [
"up 47 days, 12:33, 2 users"
]
},
{
"prompt": "$",
"command": "who",
"output": [
"root console Jan 30",
"operator ttyp0 Jan 31"
]
},
{
"prompt": "$",
"command": "uname -a",
"output": [
"UNIX pandora 3.2 2 m68k"
]
},
{
"prompt": "$",
"command": "hostname",
"output": [
"pandora"
]
},
{
"prompt": "$",
"command": "id",
"output": [
"uid=100(operator)",
"gid=100(users)"
]
},
{
"prompt": "$",
"command": "date",
"output": [
"Fri Jan 31 22:47:03 EST"
]
},
{
"prompt": "$",
"command": "cal",
"output": [
"MARCH 1969",
"Su Mo Tu We Th Fr Sa",
" 1",
" 2 3 4 5 6 7 8",
" 9 10 11 12 13 14 15",
"16 17 18 19 20 <b>21</b> 22",
"23 24 25 26 27 28 29",
"30 31"
]
},
{
"prompt": "$",
"command": "netstat -r",
"output": [
"Destination Gateway",
"default 10.0.1.1",
"10.0.1.0 pandora",
"127.0.0.0 localhost"
]
},
{
"prompt": "$",
"command": "ping 10.0.1.1",
"output": [
"10.0.1.1 is alive"
]
},
{
"prompt": "$",
"command": "finger @pandora",
"output": [
"root tty0 Jan 30 08:47",
"operator ttyp0 Jan 31 22:32"
]
},
{
"prompt": "$",
"command": "df -k",
"output": [
"Filesystem kbytes used avail",
"/dev/hd0a 71680 48320 16192",
"/dev/hd0d 51200 12480 33664"
]
},
{
"prompt": "$",
"command": "dmesg | tail -3",
"output": [
"hd0: CDC Wren IV 72MB",
"fd0: 1.2MB floppy",
"tty0: console ready"
]
},
{
"prompt": "$",
"command": "cat /etc/motd",
"output": [
"UNIX System V Release 3.2",
"pandora.local",
"Authorized users only."
]
},
{
"prompt": "$",
"command": "head -3 /etc/passwd",
"output": [
"root:x:0:0::/root:/bin/sh",
"daemon:x:1:1::/:/bin/sh",
"operator:x:100:100::/usr/operator"
]
},
{
"prompt": "$",
"command": "grep root /etc/passwd",
"output": [
"root:x:0:0::/root:/bin/sh"
]
},
{
"prompt": "$",
"command": "wc -l /etc/passwd",
"output": [
"42 /etc/passwd"
]
},
{
"prompt": "$",
"command": "tail -2 /var/adm/syslog",
"output": [
"Jan 31 22:30 cron[42]: CMD",
"Jan 31 22:31 inetd: telnet"
]
},
{
"prompt": "$",
"command": "echo $PATH",
"output": [
"/bin:/usr/bin:/usr/local/bin"
]
},
{
"prompt": "$",
"command": "which sh",
"output": [
"/bin/sh"
]
},
{
"prompt": "$",
"command": "env | head -3",
"output": [
"HOME=/usr/operator",
"TERM=vt100",
"SHELL=/bin/sh"
]
},
{
"prompt": "$",
"command": "tty",
"output": [
"/dev/ttyp0"
]
},
{
"prompt": "$",
"command": "stty",
"output": [
"speed 9600 baud"
]
},
{
"prompt": "$",
"command": "history | tail -3",
"output": [
" 48 ls -la",
" 49 ps -ef",
" 50 uptime"
]
}
]
};
-34
View File
@@ -1,34 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
/**
* Hook for copy-to-clipboard with auto-reset feedback.
*
* Returns `copied` (true for `resetDelayMs` after copy) and an async `copy` function.
* Handles rapid clicks (cancels previous timer), clipboard API errors, and
* cleanup on unmount. Used by CodePreview and PackageBadge.
*/
export function useCopyToClipboard(resetDelayMs = 2000) {
const [copied, setCopied] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
/** Clear any pending reset timer. */
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
const copy = useCallback(async (text: string) => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
// Cancel previous timer on rapid clicks
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setCopied(false), resetDelayMs);
} catch {
// Clipboard API unavailable or permission denied: silently degrade
}
}, [resetDelayMs]);
return { copied, copy };
}
-502
View File
@@ -1,502 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
const OWNER = "phranck";
const REPO = "TUIkit";
const API = `https://api.github.com/repos/${OWNER}/${REPO}`;
/** Single commit entry from the GitHub API. */
export interface CommitEntry {
sha: string;
/** First line of the commit message (title). */
title: string;
/** Lines after the first blank line (body), or null if single-line commit. */
body: string | null;
author: string;
date: string;
url: string;
}
/** Language breakdown (bytes per language). */
export interface LanguageBreakdown {
[language: string]: number;
}
/** A user who starred the repository. */
export interface Stargazer {
login: string;
avatarUrl: string;
profileUrl: string;
mastodon?: {
handle: string;
url: string;
};
twitter?: {
handle: string;
url: string;
};
bluesky?: {
handle: string;
url: string;
};
}
/** Social account cache entry from the pre-generated JSON. */
interface SocialCacheEntry {
login: string;
mastodon?: {
handle: string;
url: string;
source: string;
verified: boolean;
};
twitter?: {
handle: string;
url: string;
source: string;
verified: boolean;
};
bluesky?: {
handle: string;
url: string;
source: string;
verified: boolean;
};
updatedAt: string;
}
/** Structure of the social-cache.json file. */
interface SocialCache {
generatedAt: string | null;
entries: Record<string, SocialCacheEntry>;
}
/** Weekly commit activity (52 weeks). */
export interface WeeklyActivity {
week: number; // Unix timestamp
total: number;
days: number[]; // SunSat
}
/** All GitHub stats fetched client-side. */
export interface GitHubStats {
// Repo overview
stars: number;
forks: number;
watchers: number;
openIssues: number;
size: number; // KB
defaultBranch: string;
license: string | null;
createdAt: string;
updatedAt: string;
pushedAt: string;
// Counts
totalCommits: number;
openPRs: number;
closedPRs: number;
mergedPRs: number;
closedIssues: number;
releases: number;
contributors: number;
branches: number;
tags: number;
// Details
recentCommits: CommitEntry[];
languages: LanguageBreakdown;
weeklyActivity: WeeklyActivity[];
stargazers: Stargazer[];
// Meta
loading: boolean;
error: string | null;
rateLimit: { remaining: number; limit: number } | null;
}
/** Return type of the hook: stats plus manual refresh and data-fetch functions. */
export interface UseGitHubStatsReturn extends GitHubStats {
/** Re-fetch all data from the GitHub API (fire-and-forget, updates internal state). */
refresh: () => void;
/** Fetch all data and return it as a resolved promise (for external caching). */
fetchData: () => Promise<GitHubStats>;
}
const initialStats: GitHubStats = {
stars: 0,
forks: 0,
watchers: 0,
openIssues: 0,
size: 0,
defaultBranch: "",
license: null,
createdAt: "",
updatedAt: "",
pushedAt: "",
totalCommits: 0,
openPRs: 0,
closedPRs: 0,
mergedPRs: 0,
closedIssues: 0,
releases: 0,
contributors: 0,
branches: 0,
tags: 0,
recentCommits: [],
languages: {},
weeklyActivity: [],
stargazers: [],
loading: true,
error: null,
rateLimit: null,
};
/**
* Builds common request headers for all GitHub API calls.
*
* Includes a Bearer token from `PUBLIC_GITHUB_TOKEN` if available,
* raising the rate limit from 60 to 5,000 requests per hour.
*/
function ghHeaders(): Record<string, string> {
const headers: Record<string, string> = { Accept: "application/vnd.github+json" };
const token = import.meta.env.PUBLIC_GITHUB_TOKEN;
if (token && token.length > 0) {
headers.Authorization = `Bearer ${token}`;
}
return headers;
}
/** Fetch JSON from the GitHub API with rate-limit tracking. */
async function ghFetch<T>(
path: string,
signal: AbortSignal,
): Promise<{ data: T; remaining: number; limit: number }> {
const response = await fetch(`${API}${path}`, {
signal,
headers: ghHeaders(),
});
if (!response.ok) {
throw new Error(`GitHub API ${response.status}: ${response.statusText}`);
}
const remaining = Number(response.headers.get("x-ratelimit-remaining") ?? 0);
const limit = Number(response.headers.get("x-ratelimit-limit") ?? 60);
const data = (await response.json()) as T;
return { data, remaining, limit };
}
/** Extract the last page number from a GitHub Link header for pagination count. */
function extractLastPage(response: Response): number {
const link = response.headers.get("link");
if (!link) return 0;
const match = link.match(/page=(\d+)>; rel="last"/);
return match ? Number(match[1]) : 0;
}
/** Count total items via GET request + Link header pagination. */
async function ghCount(path: string, signal: AbortSignal): Promise<number> {
const response = await fetch(`${API}${path}?per_page=1`, {
signal,
method: "GET",
headers: ghHeaders(),
});
if (!response.ok) return 0;
const lastPage = extractLastPage(response);
if (lastPage > 0) return lastPage;
// If no Link header, count the items in the response
const data = await response.json();
return Array.isArray(data) ? data.length : 0;
}
/**
* Splits a full commit message into title and optional body.
*
* Git convention: first line is title, blank line separator, then body.
*/
function splitCommitMessage(fullMessage: string): { title: string; body: string | null } {
const firstNewline = fullMessage.indexOf("\n");
if (firstNewline === -1) return { title: fullMessage, body: null };
const title = fullMessage.slice(0, firstNewline);
const rest = fullMessage.slice(firstNewline + 1).replace(/^\n+/, "");
return { title, body: rest.length > 0 ? rest : null };
}
/** Raw repository data from the GitHub REST API. */
interface GitHubRepoResponse {
stargazers_count: number;
forks_count: number;
subscribers_count: number;
open_issues_count: number;
size: number;
default_branch: string;
license: { spdx_id: string } | null;
created_at: string;
updated_at: string;
pushed_at: string;
}
/** Raw stargazer entry from the GitHub REST API. */
interface GitHubStargazerResponse {
login: string;
avatar_url: string;
html_url: string;
}
/** Options for configuring the `useGitHubStats` hook. */
export interface UseGitHubStatsOptions {
/**
* When `true`, suppresses the automatic fetch on mount.
*
* Useful when a caching layer wants to decide whether to fetch based on
* cached data freshness. The caller can trigger a fetch manually via
* `fetchData()` or `refresh()`.
*
* @default false
*/
skipInitialFetch?: boolean;
}
/**
* Fetches live GitHub stats for the TUIKit repository.
*
* Makes ~13 parallel API requests on mount (unless `skipInitialFetch` is set).
* All data comes from the public GitHub REST API (no token required for
* public repos). Rate limit: 60 requests/hour per IP.
*
* Returns stats plus a `refresh()` function for manual re-fetch and a
* `fetchData()` function that returns a promise with the assembled stats.
*/
export function useGitHubStats(options?: UseGitHubStatsOptions): UseGitHubStatsReturn {
const skipInitialFetch = options?.skipInitialFetch ?? false;
const [stats, setStats] = useState<GitHubStats>(initialStats);
const controllerRef = useRef<AbortController | null>(null);
/**
* Fetches all GitHub stats and returns the result.
*
* Updates internal state AND returns the assembled `GitHubStats` object
* so external consumers (e.g. a caching wrapper) can store the data.
*/
const doFetch = useCallback(async (): Promise<GitHubStats> => {
// Abort any in-flight request
controllerRef.current?.abort();
const controller = new AbortController();
controllerRef.current = controller;
const { signal } = controller;
setStats((prev) => ({ ...prev, loading: true, error: null }));
try {
const [
repoResult,
commitsResult,
languagesResult,
activityResult,
openPRsCount,
closedPRsCount,
closedIssuesCount,
releasesCount,
contributorsCount,
branchesCount,
tagsCount,
stargazersResult,
] = await Promise.all([
ghFetch<GitHubRepoResponse>("", signal),
ghFetch<
Array<{
sha: string;
commit: {
message: string;
author: { name: string; date: string };
};
html_url: string;
}>
>("/commits?per_page=20", signal),
ghFetch<LanguageBreakdown>("/languages", signal),
// Try fetching weekly activity; on failure or empty response, fall back to local cache if available
(async () => {
try {
const res = await ghFetch<WeeklyActivity[]>("/stats/commit_activity", signal);
// If GitHub returns an empty array, the stats endpoint may be pending (202): try cache
if (Array.isArray(res.data) && res.data.length > 0) return res;
// Attempt to read cached weeklyActivity from public JSON
try {
const cacheResp = await fetch('/weekly-activity-cache.json', { signal });
if (cacheResp.ok) {
const cached = await cacheResp.json();
return { data: cached as WeeklyActivity[], remaining: res.remaining, limit: res.limit };
}
} catch {
// ignore cache read errors
}
return { data: [] as WeeklyActivity[], remaining: res.remaining, limit: res.limit };
} catch {
// On network/API failure, attempt cache
try {
const cacheResp = await fetch('/weekly-activity-cache.json', { signal });
if (cacheResp.ok) {
const cached = await cacheResp.json();
return { data: cached as WeeklyActivity[], remaining: 0, limit: 60 };
}
} catch {
// ignore
}
return { data: [] as WeeklyActivity[], remaining: 0, limit: 60 };
}
})(),
ghCount("/pulls?state=open", signal),
ghCount("/pulls?state=closed", signal),
ghCount("/issues?state=closed", signal),
ghCount("/releases", signal),
ghCount("/contributors", signal),
ghCount("/branches", signal),
ghCount("/tags", signal),
ghFetch<GitHubStargazerResponse[]>(
"/stargazers?per_page=100",
signal,
).catch(() => ({ data: [] as GitHubStargazerResponse[], remaining: 0, limit: 60 })),
]);
// Count total commits via Link header
const commitCountResponse = await fetch(
`${API}/commits?per_page=1`,
{ signal, headers: ghHeaders() },
);
const totalCommits = extractLastPage(commitCountResponse);
// Count merged PRs (GitHub search API)
let mergedPRs = 0;
try {
const searchResult = await fetch(
`https://api.github.com/search/issues?q=repo:${OWNER}/${REPO}+is:pr+is:merged&per_page=1`,
{ signal, headers: ghHeaders() },
);
if (searchResult.ok) {
const searchData = await searchResult.json();
mergedPRs = searchData.total_count ?? 0;
}
} catch {
/* search API can be rate-limited separately */
}
// Fetch social cache to merge with stargazers
let socialCache: SocialCache = { generatedAt: null, entries: {} };
try {
const cacheResponse = await fetch("/social-cache.json", { signal });
if (cacheResponse.ok) {
socialCache = await cacheResponse.json();
}
} catch {
/* Cache not available, continue without social info */
}
const repo = repoResult.data;
const recentCommits: CommitEntry[] = commitsResult.data
.filter((commit) => !commit.commit.message.includes("[skip ci]"))
.map((commit) => {
const { title, body } = splitCommitMessage(commit.commit.message);
return {
sha: commit.sha.slice(0, 7),
title,
body,
author: commit.commit.author.name,
date: commit.commit.author.date,
url: commit.html_url,
};
});
const result: GitHubStats = {
stars: repo.stargazers_count,
forks: repo.forks_count,
watchers: repo.subscribers_count,
openIssues: repo.open_issues_count,
size: repo.size,
defaultBranch: repo.default_branch,
license: repo.license?.spdx_id ?? null,
createdAt: repo.created_at,
updatedAt: repo.updated_at,
pushedAt: repo.pushed_at,
totalCommits,
openPRs: openPRsCount,
closedPRs: closedPRsCount,
mergedPRs,
closedIssues: closedIssuesCount,
releases: releasesCount,
contributors: contributorsCount,
branches: branchesCount,
tags: tagsCount,
recentCommits,
languages: languagesResult.data,
weeklyActivity: Array.isArray(activityResult.data)
? activityResult.data
: [],
stargazers: stargazersResult.data.map((user) => {
const cacheEntry = socialCache.entries[user.login];
return {
login: user.login,
avatarUrl: user.avatar_url,
profileUrl: user.html_url,
mastodon: cacheEntry?.mastodon
? { handle: cacheEntry.mastodon.handle, url: cacheEntry.mastodon.url }
: undefined,
twitter: cacheEntry?.twitter
? { handle: cacheEntry.twitter.handle, url: cacheEntry.twitter.url }
: undefined,
bluesky: cacheEntry?.bluesky
? { handle: cacheEntry.bluesky.handle, url: cacheEntry.bluesky.url }
: undefined,
};
}),
loading: false,
error: null,
rateLimit: {
remaining: repoResult.remaining,
limit: repoResult.limit,
},
};
setStats(result);
return result;
} catch (err) {
if (signal.aborted) throw err;
setStats((prev) => ({
...prev,
loading: false,
error: err instanceof Error ? err.message : "Unknown error",
}));
throw err;
}
}, []);
/** Fire-and-forget refresh: triggers fetch but ignores the returned promise. */
const refresh = useCallback(() => {
doFetch().catch(() => {
/* errors are reflected in stats.error */
});
}, [doFetch]);
useEffect(() => {
if (!skipInitialFetch) {
refresh();
}
return () => controllerRef.current?.abort();
}, [refresh, skipInitialFetch]);
return { ...stats, refresh, fetchData: doFetch };
}
-215
View File
@@ -1,215 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useGitHubStats, type GitHubStats } from "./useGitHubStats";
/** How often fresh data is fetched automatically (milliseconds). */
const REFRESH_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
/** Minimum time between force-refresh clicks (milliseconds). */
const FORCE_REFRESH_COOLDOWN_MS = 60 * 1000; // 60 seconds
/** Key used to persist cached stats in localStorage. */
const CACHE_KEY = "tuikit-dashboard-cache";
/** Shape of the localStorage cache entry. */
interface CacheEntry {
data: GitHubStats;
fetchedAt: number;
}
/** Return type of the caching wrapper hook. */
export interface UseGitHubStatsCacheReturn extends GitHubStats {
/** Bypass the cache and fetch fresh data (respects cooldown). */
forceRefresh: () => void;
/** Unix timestamp (ms) of the last successful fetch, or null if none yet. */
lastFetchedAt: number | null;
/** Unix timestamp (ms) when the next auto-refresh will fire, or null during initial load. */
nextRefreshAt: number | null;
/** Whether the force-refresh button is currently allowed (cooldown elapsed). */
canForceRefresh: boolean;
/** Whether the currently displayed data was served from localStorage cache. */
isFromCache: boolean;
/** Whether a background refresh is in progress (for showing a subtle indicator). */
isRefreshing: boolean;
}
// ---------------------------------------------------------------------------
// localStorage helpers: all reads/writes are wrapped in try/catch to handle
// Safari Private Mode, full storage, or disabled storage gracefully.
// ---------------------------------------------------------------------------
/** Read and validate the cached entry from localStorage. */
function readCache(): CacheEntry | null {
try {
const raw = localStorage.getItem(CACHE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as CacheEntry;
if (!parsed || typeof parsed.fetchedAt !== "number" || !parsed.data) {
localStorage.removeItem(CACHE_KEY);
return null;
}
return parsed;
} catch {
// Corrupt data or storage unavailable: clear and move on
try {
localStorage.removeItem(CACHE_KEY);
} catch {
/* ignore */
}
return null;
}
}
/** Persist stats to localStorage with a timestamp. */
function writeCache(data: GitHubStats): number {
const fetchedAt = Date.now();
try {
localStorage.setItem(CACHE_KEY, JSON.stringify({ data, fetchedAt }));
} catch {
/* Storage full or unavailable: silently continue without cache */
}
return fetchedAt;
}
/**
* Caching wrapper around `useGitHubStats` that prevents redundant API calls.
*
* On mount the hook checks localStorage for a recent cache entry (< 5 min old).
* If valid cached data exists it is served immediately: no GitHub API call.
* A background interval automatically refreshes data every 5 minutes.
*
* The `forceRefresh` function bypasses the cache but enforces a 60-second
* cooldown to prevent accidental rate-limit exhaustion.
*/
export function useGitHubStatsCache(): UseGitHubStatsCacheReturn {
// Skip the automatic fetch on mount: we decide whether to fetch based on cache freshness
const { fetchData, ...rawStats } = useGitHubStats({ skipInitialFetch: true });
const [overrideStats, setOverrideStats] = useState<GitHubStats | null>(null);
const [lastFetchedAt, setLastFetchedAt] = useState<number | null>(null);
const [nextRefreshAt, setNextRefreshAt] = useState<number | null>(null);
const [isFromCache, setIsFromCache] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const initializedRef = useRef(false);
// The stats to expose: override (cached) data takes priority while it's set
// Force loading: false when we have data, so components don't show skeletons during background refresh
const activeStats = overrideStats ?? rawStats;
const hasData = lastFetchedAt !== null;
// -------------------------------------------------------------------------
// Core fetch + cache-write logic
// -------------------------------------------------------------------------
const doFetchAndCache = useCallback(async () => {
setIsRefreshing(true);
try {
const freshData = await fetchData();
const timestamp = writeCache(freshData);
setLastFetchedAt(timestamp);
setNextRefreshAt(timestamp + REFRESH_INTERVAL_MS);
setIsFromCache(false);
// Store the fresh data as override with loading: false to prevent skeleton flash
setOverrideStats({ ...freshData, loading: false });
} catch {
// On error, keep showing previous data (overrideStats stays as-is)
// Errors are also reflected in rawStats.error via useGitHubStats if needed
} finally {
setIsRefreshing(false);
}
}, [fetchData]);
// -------------------------------------------------------------------------
// Mount: check cache: serve cached data or trigger a fresh fetch
// -------------------------------------------------------------------------
useEffect(() => {
if (initializedRef.current) return;
initializedRef.current = true;
const cached = readCache();
const now = Date.now();
if (cached && now - cached.fetchedAt < REFRESH_INTERVAL_MS) {
// Cache is fresh: serve it immediately, no API call needed
setOverrideStats({ ...cached.data, loading: false, error: null });
setLastFetchedAt(cached.fetchedAt);
setNextRefreshAt(cached.fetchedAt + REFRESH_INTERVAL_MS);
setIsFromCache(true);
} else {
// No valid cache: fetch fresh data now
doFetchAndCache();
}
}, [doFetchAndCache]);
// -------------------------------------------------------------------------
// Auto-refresh: schedule based on remaining TTL, then repeat every interval
// -------------------------------------------------------------------------
const timerInitializedRef = useRef(false);
useEffect(() => {
// Only set up the timer once on mount
if (timerInitializedRef.current) return;
timerInitializedRef.current = true;
// Calculate delay until first refresh based on cache age
const cached = readCache();
let initialDelay = REFRESH_INTERVAL_MS;
if (cached) {
const elapsed = Date.now() - cached.fetchedAt;
const remaining = REFRESH_INTERVAL_MS - elapsed;
initialDelay = Math.max(0, remaining);
}
// First refresh after remaining TTL
const timeoutId = setTimeout(() => {
doFetchAndCache();
// Then repeat every REFRESH_INTERVAL_MS
intervalRef.current = setInterval(() => {
doFetchAndCache();
}, REFRESH_INTERVAL_MS);
}, initialDelay);
return () => {
clearTimeout(timeoutId);
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [doFetchAndCache]);
// -------------------------------------------------------------------------
// Force refresh with cooldown
// -------------------------------------------------------------------------
const canForceRefresh = lastFetchedAt === null || Date.now() - lastFetchedAt >= FORCE_REFRESH_COOLDOWN_MS;
const forceRefresh = useCallback(() => {
if (lastFetchedAt !== null && Date.now() - lastFetchedAt < FORCE_REFRESH_COOLDOWN_MS) {
return; // Cooldown active: ignore
}
// Reset the interval so the next auto-refresh is a full REFRESH_INTERVAL_MS from now
if (intervalRef.current) clearInterval(intervalRef.current);
doFetchAndCache();
intervalRef.current = setInterval(() => {
doFetchAndCache();
}, REFRESH_INTERVAL_MS);
}, [lastFetchedAt, doFetchAndCache]);
// Override loading to false if we already have data: prevents skeleton flash during background refresh
const statsWithLoadingOverride = hasData
? { ...activeStats, loading: false }
: activeStats;
return {
...statsWithLoadingOverride,
forceRefresh,
lastFetchedAt,
nextRefreshAt,
canForceRefresh,
isFromCache,
isRefreshing,
};
}
-79
View File
@@ -1,79 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
/** Position and content for a hover popover. */
export interface PopoverState<T> {
data: T;
x: number;
y: number;
}
/**
* Manages hover-triggered popover state with stable position on dismiss.
*
* Tracks the current hover target and remembers the last position so the
* popover can fade out in place instead of jumping to (0, 0).
*
* @returns `hover` (current state or null), `popover` (last known state for rendering),
* `show` (set new hover), `hide` (clear hover), `cancelHide` (cancel pending hide).
*/
export function useHoverPopover<T>(showDelay = 300, hideDelay = 150) {
const [hover, setHover] = useState<PopoverState<T> | null>(null);
const lastRef = useRef<PopoverState<T> | null>(null);
const showTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (hover) lastRef.current = hover;
}, [hover]);
const cancelShow = useCallback(() => {
if (showTimeoutRef.current) {
clearTimeout(showTimeoutRef.current);
showTimeoutRef.current = null;
}
}, []);
const cancelHide = useCallback(() => {
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
hideTimeoutRef.current = null;
}
}, []);
const show = useCallback((data: T, x: number, y: number) => {
cancelHide();
cancelShow();
showTimeoutRef.current = setTimeout(() => {
setHover({ data, x, y });
}, showDelay);
}, [cancelHide, cancelShow, showDelay]);
const hide = useCallback(() => {
cancelShow();
cancelHide();
hideTimeoutRef.current = setTimeout(() => {
setHover(null);
}, hideDelay);
}, [cancelShow, cancelHide, hideDelay]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (showTimeoutRef.current) clearTimeout(showTimeoutRef.current);
if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
};
}, []);
return {
/** Current hover state: null when not hovering. */
hover,
/** Last known state for rendering (stable position during fade-out). */
popover: hover ?? lastRef.current,
show,
hide,
/** Cancel a pending hide (call when mouse enters popover). */
cancelHide,
};
}
-90
View File
@@ -1,90 +0,0 @@
import { useState, useEffect } from "react";
interface Plan {
date: string;
slug: string;
title: string;
preface: string;
}
interface PlansData {
generated: string;
open: Plan[];
done: Plan[];
}
const CACHE_KEY = "tuikit_plans_cache";
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
interface CacheEntry {
data: PlansData;
timestamp: number;
}
/**
* Fetches and caches plan data from public/data/plans.json.
* Cache is stored in localStorage with 5-minute TTL.
*/
export function usePlansCache() {
const [data, setData] = useState<PlansData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isFromCache, setIsFromCache] = useState(false);
const [lastFetchedAt, setLastFetchedAt] = useState<number | null>(null);
useEffect(() => {
const fetchPlans = async () => {
try {
// Check cache first
const cached = localStorage.getItem(CACHE_KEY);
if (cached) {
const entry: CacheEntry = JSON.parse(cached);
const age = Date.now() - entry.timestamp;
if (age < CACHE_DURATION) {
setData(entry.data);
setIsFromCache(true);
setLastFetchedAt(entry.timestamp);
setLoading(false);
return;
}
}
// Fetch fresh data
setIsFromCache(false);
const response = await fetch("/data/plans.json");
if (!response.ok) {
throw new Error(`Failed to fetch plans: ${response.statusText}`);
}
const plansData: PlansData = await response.json();
const now = Date.now();
// Cache it
localStorage.setItem(CACHE_KEY, JSON.stringify({ data: plansData, timestamp: now }));
setData(plansData);
setLastFetchedAt(now);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error");
setData(null);
} finally {
setLoading(false);
}
};
fetchPlans();
}, []);
return {
data,
loading,
error,
isFromCache,
lastFetchedAt,
};
}
-97
View File
@@ -1,97 +0,0 @@
---
interface Props {
title?: string;
description?: string;
}
const {
title = "TUIkit: Terminal UI Framework for Swift",
description = "A declarative, SwiftUI-like framework for building Terminal User Interfaces in Swift. Pure Swift with no ncurses or C dependencies.",
} = Astro.props;
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
---
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/favicon-192.png" />
<link rel="icon" type="image/png" sizes="512x512" href="/favicon-512.png" />
<link rel="apple-touch-icon" href="/favicon-512.png" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="canonical" href={canonicalURL} />
<title>{title}</title>
<meta name="description" content={description} />
<meta name="keywords" content="swift, terminal, TUI, framework, SwiftUI, CLI, ncurses alternative, macOS, Linux, terminal UI, declarative" />
<!-- Open Graph -->
<meta property="og:title" content={title} />
<meta property="og:description" content="Build terminal apps with SwiftUI-like syntax. Pure Swift, no ncurses." />
<meta property="og:url" content={canonicalURL} />
<meta property="og:site_name" content="TUIkit" />
<meta property="og:type" content="website" />
<meta property="og:image" content="/og-image.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="TUIkit: Terminal UI Framework for Swift" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content="Build terminal apps with SwiftUI-like syntax. Pure Swift, no ncurses." />
<meta name="twitter:image" content="/og-image.png" />
<!-- Fonts: combined request, preconnect for faster DNS/TLS -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@400;500&family=Nunito:wght@400;500;600;700&display=swap" rel="stylesheet" />
<!-- Preload critical sounds for instant playback on power-on -->
<link rel="preload" href="/sounds/power-on.mp3" as="audio" crossorigin />
<link rel="preload" href="/sounds/hard-drive-boot.m4a" as="audio" crossorigin />
<!-- Analytics -->
<link rel="preconnect" href="https://cloud.umami.is" />
<script defer src="https://cloud.umami.is/script.js" data-website-id="4085eff5-2e56-4e3a-ba91-cf0828914169"></script>
<!-- Theme initialization (blocks to prevent FOUC) -->
<script is:inline>
(function() {
try {
var stored = localStorage.getItem('tuikit-theme');
if (stored && ['green','amber','red','violet','blue','white'].includes(stored)) {
document.documentElement.setAttribute('data-theme', stored);
} else {
document.documentElement.setAttribute('data-theme', 'green');
}
} catch(e) {
document.documentElement.setAttribute('data-theme', 'green');
}
})();
</script>
<!-- Structured Data -->
<script type="application/ld+json" set:html={JSON.stringify({
"@context": "https://schema.org",
"@type": "SoftwareSourceCode",
"name": "TUIkit",
"description": "A declarative, SwiftUI-like framework for building Terminal User Interfaces in Swift.",
"url": "https://tuikit.dev",
"codeRepository": "https://github.com/phranck/TUIkit",
"programmingLanguage": "Swift",
"operatingSystem": ["macOS", "Linux"],
"license": "https://opensource.org/licenses/MIT",
})} />
</head>
<body class="antialiased">
<slot />
</body>
</html>
<style is:global>
@import "../styles/global.css";
</style>
-369
View File
@@ -1,369 +0,0 @@
/**
* Parser for terminal-script.md
*
* Converts markdown terminal script into executable sequences.
* Supports basic Markdown formatting:
* - **bold** → \x1B[1m
* - __underline__ → \x1B[4m
* - ~~strikethrough~~ → \x1B[9m
* - *italic* → \x1B[3m
*/
import fs from "fs";
import path from "path";
/**
* Convert Markdown formatting to HTML-like tags for terminal display.
* These will be rendered by TerminalScreen component.
* Supports: bold, underline, strikethrough, italic
*
* Important: Only matches if there's actual text content between markers,
* not just repeated special chars (e.g., ******** for password masking)
*/
function parseMarkdownFormatting(text: string): string {
let result = text;
// Skip formatting if line contains only special characters (like ********)
if (/^[*_~\s]+$/.test(text)) {
return text;
}
// Process in order, being careful not to match literal asterisks
// **bold** → <b>bold</b>
// Only match if:
// - Content has at least one non-* character
// - NOT preceded or followed by another * (to avoid matching *** as bold)
result = result.replace(/(?<!\*)\*\*([^*]+?)\*\*(?!\*)/g, '<b>$1</b>');
// __underline__ → <u>underline</u>
// Matches content between __ and __, including spaces
// Only match if content has at least one word character
result = result.replace(/__([^_]+?)__/g, '<u>$1</u>');
// ~~strikethrough~~ → <s>strikethrough</s>
// Only match if content has at least one non-~ character
result = result.replace(/~~([^~]+?)~~/g, '<s>$1</s>');
// *italic* - process last, very carefully
// Match single * that:
// - Is preceded by whitespace or start of string (to avoid ** and ***)
// - Is followed by whitespace or end of string (to avoid ** and ***)
// - Content starts with word character (to avoid matching *** or ****)
result = result.replace(/(^|\s)\*(\w[^\*]*?\w)\*(\s|$)/g, '$1<i>$2</i>$3');
return result;
}
export interface BootStep {
type: "instant" | "type" | "counter" | "pause" | "clear" | "dots";
text?: string;
prefix?: string;
target?: number;
suffix?: string;
dotCount?: number;
delayAfter?: number;
}
export interface SchoolStep {
type: "system" | "user" | "inline" | "pause" | "clear";
prompt?: string;
text?: string;
delayAfter?: number;
}
export interface JoshuaStep {
type: "system" | "user" | "pause" | "clear" | "barrage";
text?: string;
delayAfter?: number;
}
export interface TerminalEntry {
prompt: string;
command: string;
output: string[];
}
export interface TerminalScript {
config: {
initialCursorDelay: number;
schoolTrigger: number;
joshuaTrigger: number;
typeMin: number;
typeMax: number;
pauseBeforeOutput: number;
pauseAfterOutput: number;
};
bootSequence: BootStep[];
schoolSequence: SchoolStep[];
joshuaSequence: JoshuaStep[];
unixCommands: TerminalEntry[];
}
/**
* Parse a single line from the script.
* Format: [TYPE] content
* Delay: [DELAY 1200ms]
*/
function parseLine(line: string): { type: string; content: string; delay?: number } | null {
const trimmed = line.trim();
// Parse delay
const delayMatch = trimmed.match(/^\[DELAY (\d+)ms\]$/);
if (delayMatch) {
return { type: "DELAY", content: "", delay: parseInt(delayMatch[1], 10) };
}
// Parse command
const commandMatch = trimmed.match(/^\[(\w+)\]\s*(.*)$/);
if (commandMatch) {
const [, type, content] = commandMatch;
return { type, content };
}
return null;
}
/**
* Parse boot sequence from markdown content.
*/
function parseBootSequence(content: string): BootStep[] {
const steps: BootStep[] = [];
const lines = content.split("\n");
let i = 0;
while (i < lines.length) {
const parsed = parseLine(lines[i]);
if (!parsed) {
i++;
continue;
}
const step: Partial<BootStep> = {};
switch (parsed.type) {
case "INSTANT":
step.type = "instant";
step.text = parseMarkdownFormatting(parsed.content);
break;
case "TYPE":
step.type = "type";
step.text = parseMarkdownFormatting(parsed.content);
break;
case "COUNTER":
step.type = "counter";
// Format: "Memory Test: 0 → 4096K OK" or "Memory Test: 0 → 4096K OK"
// Keep spacing between prefix and "0"
const counterMatch = parsed.content.match(/^(.*?\s+)0\s*→\s*(\d+)(.*)$/);
if (counterMatch) {
step.prefix = parseMarkdownFormatting(counterMatch[1]);
step.target = parseInt(counterMatch[2], 10);
step.suffix = parseMarkdownFormatting(counterMatch[3]);
}
break;
case "DOTS":
step.type = "dots";
// Format: "Detecting drives..."
const dotsMatch = parsed.content.match(/^(.*?)(\.+)$/);
if (dotsMatch) {
step.text = parseMarkdownFormatting(dotsMatch[1]);
step.dotCount = dotsMatch[2].length;
} else {
step.text = parseMarkdownFormatting(parsed.content);
step.dotCount = 3;
}
break;
case "CLEAR":
step.type = "clear";
break;
case "PAUSE":
step.type = "pause";
break;
case "DELAY":
// Apply delay to previous step
if (steps.length > 0) {
steps[steps.length - 1].delayAfter = parsed.delay;
}
i++;
continue;
}
steps.push(step as BootStep);
i++;
}
return steps;
}
/**
* Parse school sequence from markdown content.
*/
function parseSchoolSequence(content: string): SchoolStep[] {
const steps: SchoolStep[] = [];
const lines = content.split("\n");
let i = 0;
while (i < lines.length) {
const parsed = parseLine(lines[i]);
if (!parsed) {
i++;
continue;
}
const step: Partial<SchoolStep> = {};
switch (parsed.type) {
case "SYSTEM":
step.type = "system";
step.text = parseMarkdownFormatting(parsed.content);
break;
case "USER":
step.type = "user";
step.text = parseMarkdownFormatting(parsed.content);
break;
case "INLINE":
step.type = "inline";
// Format: "USER: DABNEY"
const inlineMatch = parsed.content.match(/^(.*?:\s*)(.*)$/);
if (inlineMatch) {
step.prompt = parseMarkdownFormatting(inlineMatch[1]);
step.text = parseMarkdownFormatting(inlineMatch[2]);
}
break;
case "CLEAR":
step.type = "clear";
break;
case "PAUSE":
step.type = "pause";
break;
case "DELAY":
if (steps.length > 0) {
steps[steps.length - 1].delayAfter = parsed.delay;
}
i++;
continue;
}
steps.push(step as SchoolStep);
i++;
}
return steps;
}
/**
* Parse Joshua sequence from markdown content.
*/
function parseJoshuaSequence(content: string): JoshuaStep[] {
const steps: JoshuaStep[] = [];
const lines = content.split("\n");
let i = 0;
while (i < lines.length) {
const parsed = parseLine(lines[i]);
if (!parsed) {
i++;
continue;
}
const step: Partial<JoshuaStep> = {};
switch (parsed.type) {
case "SYSTEM":
step.type = "system";
step.text = parseMarkdownFormatting(parsed.content);
break;
case "USER":
step.type = "user";
// Remove "> " prefix if present, then apply formatting
step.text = parseMarkdownFormatting(parsed.content.replace(/^>\s*/, ""));
break;
case "BARRAGE":
step.type = "barrage";
break;
case "CLEAR":
step.type = "clear";
break;
case "PAUSE":
step.type = "pause";
break;
case "DELAY":
if (steps.length > 0) {
steps[steps.length - 1].delayAfter = parsed.delay;
}
i++;
continue;
}
steps.push(step as JoshuaStep);
i++;
}
return steps;
}
/**
* Parse UNIX commands from markdown content.
*/
function parseUnixCommands(content: string): TerminalEntry[] {
const commands: TerminalEntry[] = [];
const blocks = content.split("```terminal\n").slice(1);
for (const block of blocks) {
const lines = block.split("\n```")[0].split("\n");
if (lines.length === 0) continue;
const firstLine = lines[0];
const promptMatch = firstLine.match(/^(\$|#)\s+(.+)$/);
if (!promptMatch) continue;
const prompt = promptMatch[1];
const command = parseMarkdownFormatting(promptMatch[2]);
const output = lines.slice(1)
.filter(line => line.trim() !== "")
.map(line => parseMarkdownFormatting(line));
commands.push({ prompt, command, output });
}
return commands;
}
/**
* Parse the complete terminal script markdown file.
*/
export function parseTerminalScript(): TerminalScript {
const scriptPath = path.join(process.cwd(), "terminal-script.md");
const content = fs.readFileSync(scriptPath, "utf-8");
// Extract sections
const bootMatch = content.match(/## Boot Sequence\s*```terminal\n([\s\S]*?)\n```/);
const schoolMatch = content.match(/## School Computer Scene[\s\S]*?```terminal\n([\s\S]*?)\n```/);
const joshuaMatch = content.match(/## Joshua\/WOPR Scene[\s\S]*?### First Contact([\s\S]*?)## UNIX Command Pool/);
const unixMatch = content.match(/## UNIX Command Pool([\s\S]*?)## Special Effects/);
// Parse config
const config = {
initialCursorDelay: 1500,
schoolTrigger: 12,
joshuaTrigger: 12,
typeMin: 40,
typeMax: 80,
pauseBeforeOutput: 400,
pauseAfterOutput: 1200,
};
// Parse sequences
const bootSequence = bootMatch ? parseBootSequence(bootMatch[1]) : [];
const schoolSequence = schoolMatch ? parseSchoolSequence(schoolMatch[1]) : [];
const joshuaSequence = joshuaMatch ? parseJoshuaSequence(joshuaMatch[1]) : [];
const unixCommands = unixMatch ? parseUnixCommands(unixMatch[1]) : [];
return {
config,
bootSequence,
schoolSequence,
joshuaSequence,
unixCommands,
};
}
-35
View File
@@ -1,35 +0,0 @@
---
import BaseLayout from "../layouts/BaseLayout.astro";
import CloudBackground from "../components/astro/CloudBackground.astro";
import SiteFooter from "../components/astro/SiteFooter.astro";
import SiteNav from "../components/react/SiteNav";
import RainOverlay from "../components/react/RainOverlay";
import SpinnerLights from "../components/react/SpinnerLights";
import DashboardContent from "../components/react/dashboard/DashboardContent";
---
<BaseLayout title="Dashboard | TUIkit" description="Live GitHub metrics for the TUIkit Swift framework">
<div class="relative min-h-screen">
<CloudBackground />
<RainOverlay client:idle />
<SpinnerLights client:idle />
<!-- Skip navigation -->
<a
href="#main-content"
class="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[9999] focus:rounded-lg focus:bg-background focus:px-4 focus:py-2 focus:text-foreground focus:ring-2 focus:ring-accent"
>
Skip to main content
</a>
<div class="relative z-10 flex min-h-screen flex-col">
<SiteNav activePage="dashboard" client:load />
<main id="main-content" tabindex="-1" class="mx-auto w-full max-w-6xl flex-1 px-6 pt-28 pb-20">
<DashboardContent client:load />
</main>
<SiteFooter />
</div>
</div>
</BaseLayout>
-240
View File
@@ -1,240 +0,0 @@
---
import BaseLayout from "../layouts/BaseLayout.astro";
import CloudBackground from "../components/astro/CloudBackground.astro";
import FeatureCard from "../components/astro/FeatureCard.astro";
import SiteFooter from "../components/astro/SiteFooter.astro";
import SiteNav from "../components/react/SiteNav";
import HeroTerminal from "../components/react/HeroTerminal";
import RainOverlay from "../components/react/RainOverlay";
import SpinnerLights from "../components/react/SpinnerLights";
import CodePreview from "../components/react/CodePreview";
import PackageBadge from "../components/react/PackageBadge";
import TemplateBadge from "../components/react/TemplateBadge";
import Icon from "../components/react/Icon";
/** Test and suite counts injected at build-time by the prebuild script. */
const TEST_COUNT = import.meta.env.PUBLIC_TUIKIT_TEST_COUNT ?? "0";
/** Shared button class strings to avoid duplication across Hero and CTA sections. */
const BTN_PRIMARY =
"inline-flex items-center justify-center gap-2 rounded-full bg-accent px-7 py-2.5 text-xl font-semibold text-background transition-all hover:bg-accent-secondary hover:shadow-lg hover:shadow-accent/20";
const BTN_SECONDARY =
"inline-flex items-center justify-center gap-2 rounded-full border border-border px-7 py-2.5 text-xl font-semibold text-foreground transition-all hover:border-accent/40 hover:bg-white/5";
---
<BaseLayout>
<div class="relative min-h-screen">
<CloudBackground />
<RainOverlay client:idle />
<SpinnerLights client:idle />
<!-- Skip navigation for keyboard/screen reader users -->
<a
href="#main-content"
class="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[9999] focus:rounded-lg focus:bg-background focus:px-4 focus:py-2 focus:text-foreground focus:ring-2 focus:ring-accent"
>
Skip to main content
</a>
<!-- All page content above atmosphere layers -->
<div class="relative z-10">
<SiteNav activePage="home" client:load />
<!-- Main content -->
<main id="main-content" tabindex="-1">
<!-- Hero Section -->
<section class="relative mx-auto flex max-w-6xl flex-col items-center px-6 pt-28 pb-24 text-center sm:pt-40">
<div class="mb-6 sm:mb-10">
<HeroTerminal client:visible />
</div>
<h1
class="mb-6 max-w-4xl text-5xl leading-tight tracking-wide transition-all duration-500 md:text-7xl"
style="font-family: WarText, monospace; color: var(--headline-color); text-shadow: 0 0 7px rgba(var(--headline-glow),0.6), 0 0 20px rgba(var(--headline-glow),0.35), 0 0 42px rgba(var(--headline-glow),0.15);"
>
&gt; Terminal UIs, the Swift way<span class="animate-cursor-blink">_</span>
</h1>
<p class="mb-10 max-w-2xl text-2xl leading-relaxed text-muted">
A declarative, SwiftUI-like framework for building Terminal User
Interfaces. Pure Swift on macOS and Linux, with no ncurses or C dependencies.
</p>
<div class="flex flex-col gap-4 sm:flex-row">
<a href="/documentation/tuikit" class={BTN_PRIMARY}>
<Icon name="document" size={20} />
Read the Docs
</a>
<a
href="https://github.com/phranck/TUIkit"
target="_blank"
rel="noopener noreferrer"
class={BTN_SECONDARY}
>
View on GitHub
</a>
</div>
</section>
<!-- Getting Started Card (hidden on mobile) -->
<section class="mx-auto hidden max-w-5xl px-6 pb-12 sm:block">
<div class="rounded-2xl border border-border bg-frosted-glass p-8 backdrop-blur-xl">
<h2 class="mb-4 flex items-center justify-center gap-4 text-center text-3xl font-bold text-foreground">
<Icon name="swift" size={32} className="text-accent -mt-0.5" />
Get started in seconds
</h2>
<p class="mx-auto mb-6 max-w-2xl text-center text-lg leading-relaxed text-muted">
Add TUIkit as a dependency to your Swift package. No ncurses, no C libraries,
no complex setup. Just pure Swift that runs on macOS and Linux.
</p>
<PackageBadge client:visible />
</div>
</section>
<!-- CLI Installer Card (hidden on mobile) -->
<section class="mx-auto hidden max-w-5xl px-6 pb-16 sm:block">
<div class="rounded-2xl border border-border bg-frosted-glass p-8 backdrop-blur-xl">
<h2 class="mb-4 flex items-center justify-center gap-4 text-center text-3xl font-bold text-foreground">
<Icon name="terminal" size={32} className="text-accent -mt-0.5" />
Project Creator CLI
</h2>
<p class="mx-auto mb-6 max-w-2xl text-center text-lg leading-relaxed text-muted">
The installer adds the <code class="text-foreground">tuikit</code> command to your system.
It detects your platform, installs to <code class="text-foreground">/usr/local/bin</code> or <code class="text-foreground">~/.local/bin</code>,
and on Linux offers to install Swift if needed.
</p>
<TemplateBadge client:visible />
<div class="mt-6 mx-auto max-w-xl text-left text-sm text-muted">
<p class="mb-3">Then create projects with optional features:</p>
<ul class="space-y-1 font-mono text-foreground">
<li><code>tuikit init MyApp</code> <span class="text-muted ml-2">Basic app</span></li>
<li><code>tuikit init git MyApp</code> <span class="text-muted ml-2">With Git repository</span></li>
<li><code>tuikit init sqlite MyApp</code> <span class="text-muted ml-2">With SQLiteData</span></li>
<li><code>tuikit init testing MyApp</code> <span class="text-muted ml-2">With Swift Testing</span></li>
</ul>
<p class="mt-3 text-muted">Options can be combined: <code class="text-foreground">tuikit init git sqlite testing MyApp</code></p>
</div>
</div>
</section>
<!-- Code Preview Section (hidden on mobile) -->
<section class="mx-auto hidden max-w-5xl px-6 pb-28 sm:block">
<CodePreview client:visible />
</section>
<!-- Features Grid -->
<section class="mx-auto max-w-6xl px-6 pb-28">
<div class="mb-12 text-center">
<h2 class="mb-4 text-4xl font-bold text-foreground">
Everything you need
</h2>
<p class="mx-auto max-w-2xl text-2xl text-muted">
Built from the ground up for the terminal, with APIs you already
know from SwiftUI.
</p>
</div>
<div class="grid gap-5 md:grid-cols-2 lg:grid-cols-3">
<FeatureCard
icon="terminal"
title="Declarative Syntax"
description="Build UIs with VStack, HStack, Text, Button, and more. The same patterns you know from SwiftUI."
/>
<FeatureCard
icon="paintbrush"
title="Theming System"
description="Multiple built-in phosphor themes with full RGB color support. Cycle at runtime or create custom palettes."
/>
<FeatureCard
icon="keyboard"
title="Keyboard-Driven"
description="Focus management, key event handlers, customizable status bar with shortcut display."
/>
<FeatureCard
icon="stack"
title="Rich Components"
description="Panel, Card, Dialog, Alert, Menu, Button, and ForEach. Container and interactive views out of the box."
/>
<FeatureCard
icon="bolt"
title="Zero Dependencies"
description="Pure Swift. No ncurses, no C libraries. Just add the Swift package and go."
/>
<FeatureCard
icon="arrows"
title="Cross-Platform"
description="Runs on macOS and Linux. Same code, same API, same results."
/>
</div>
</section>
<!-- Architecture highlights -->
<section class="mx-auto max-w-6xl px-6 pb-28">
<div class="rounded-2xl border border-border bg-frosted-glass p-8 backdrop-blur-xl md:p-12">
<h2 class="mb-8 text-center text-4xl font-bold text-foreground">
Built right
</h2>
<div class="grid gap-8 md:grid-cols-2">
<!-- Arch Highlight: Swift 6.0 -->
<div class="flex gap-3">
<Icon name="swift" size={24} className="text-accent mt-1" />
<div>
<h3 class="mb-1 text-xl font-semibold text-foreground">Swift 6.0 + Strict Concurrency</h3>
<p class="text-lg leading-relaxed text-muted">Full Sendable compliance. No data races, no unsafe globals. Modern Swift from top to bottom.</p>
</div>
</div>
<!-- Arch Highlight: Border Appearances -->
<div class="flex gap-3">
<Icon name="eye" size={24} className="text-accent mt-1" />
<div>
<h3 class="mb-1 text-xl font-semibold text-foreground">5 Border Appearances</h3>
<p class="text-lg leading-relaxed text-muted">Line, rounded, double-line, heavy, and block style. Cycle with a single keystroke.</p>
</div>
</div>
<!-- Arch Highlight: Tests -->
<div class="flex gap-3">
<Icon name="checkmark" size={24} className="text-accent mt-1" />
<div>
<h3 class="mb-1 text-xl font-semibold text-foreground">{TEST_COUNT} Tests</h3>
<p class="text-lg leading-relaxed text-muted">Comprehensive test suite covering views, modifiers, rendering, state management, and more.</p>
</div>
</div>
<!-- Arch Highlight: Docs -->
<div class="flex gap-3">
<Icon name="document" size={24} className="text-accent mt-1" />
<div>
<h3 class="mb-1 text-xl font-semibold text-foreground">13 DocC Articles</h3>
<p class="text-lg leading-relaxed text-muted">Architecture guides, API references, theming, focus system, keyboard shortcuts, and palette documentation.</p>
</div>
</div>
</div>
</div>
</section>
<!-- CTA Section -->
<section class="mx-auto max-w-6xl px-6 pb-20">
<div class="text-center">
<h2 class="mb-4 text-4xl font-bold text-foreground">
Ready to build?
</h2>
<p class="mb-8 text-2xl text-muted">
Get started with TUIkit in minutes. Add the package, write your
first view, run it.
</p>
<div class="flex flex-col items-center justify-center gap-4 sm:flex-row">
<a href="/documentation/tuikit/gettingstarted" class={BTN_PRIMARY}>
Getting Started Guide
</a>
<a href="/documentation/tuikit" class={BTN_SECONDARY}>
Browse Documentation
</a>
</div>
</div>
</section>
</main>
<SiteFooter />
</div>
</div>
</BaseLayout>
-491
View File
@@ -1,491 +0,0 @@
@import "tailwindcss";
@font-face {
font-family: "WarText";
src: url("/fonts/WarText.ttf") format("truetype");
font-weight: normal;
font-style: normal;
font-display: swap;
}
/* Themes: Green -> Amber -> Red -> Violet -> Blue -> White */
/* Theme: Green (default) */
:root,
[data-theme="green"] {
--avatar-hue: 70deg;
--background: #060a07;
--foreground: #33ff33;
--accent: #66ff66;
--accent-secondary: #00cc00;
--accent-glow: 102, 255, 102;
--accent-glow-secondary: 0, 204, 0;
--accent-glow-tertiary: 31, 143, 31;
--headline-color: #33ff33;
--headline-glow: 51, 255, 51;
--muted: #1f8f1f;
--border: #2d5a2d;
--container-body: #0e271c;
--container-cap: #0a1b13;
}
/* Theme: Amber */
[data-theme="amber"] {
--avatar-hue: 10deg;
--background: #0a0706;
--foreground: #ffaa00;
--accent: #ffcc33;
--accent-secondary: #cc9900;
--accent-glow: 255, 204, 51;
--accent-glow-secondary: 204, 153, 0;
--accent-glow-tertiary: 143, 102, 0;
--headline-color: #ffaa00;
--headline-glow: 255, 170, 0;
--muted: #8f6600;
--border: #5a4a2d;
--container-body: #251710;
--container-cap: #1e110e;
}
/* Theme: Red */
[data-theme="red"] {
--avatar-hue: -30deg;
--background: #0a0606;
--foreground: #ff4444;
--accent: #ff6666;
--accent-secondary: #cc4444;
--accent-glow: 255, 68, 68;
--accent-glow-secondary: 204, 51, 51;
--accent-glow-tertiary: 143, 34, 34;
--headline-color: #ff4444;
--headline-glow: 255, 68, 68;
--muted: #8f2222;
--border: #5a2d2d;
--container-body: #281112;
--container-cap: #1e0f10;
}
/* Theme: Violet */
[data-theme="violet"] {
--avatar-hue: 210deg;
--background: #08060a;
--foreground: #bb77ff;
--accent: #cc99ff;
--accent-secondary: #9944dd;
--accent-glow: 204, 153, 255;
--accent-glow-secondary: 153, 68, 221;
--accent-glow-tertiary: 119, 68, 170;
--headline-color: #bb77ff;
--headline-glow: 187, 119, 255;
--muted: #7744aa;
--border: #4a2d5a;
--container-body: #1a1028;
--container-cap: #120c1e;
}
/* Theme: Blue (VFD) */
[data-theme="blue"] {
--avatar-hue: 160deg;
--background: #060708;
--foreground: #00aaff;
--accent: #33bbff;
--accent-secondary: #0099dd;
--accent-glow: 51, 187, 255;
--accent-glow-secondary: 0, 153, 221;
--accent-glow-tertiary: 0, 102, 153;
--headline-color: #00aaff;
--headline-glow: 0, 170, 255;
--muted: #006699;
--border: #2d4a5a;
--container-body: #0e1825;
--container-cap: #0a121c;
}
/* Theme: White */
[data-theme="white"] {
--background: #06070a;
--foreground: #e8e8e8;
--accent: #ffffff;
--accent-secondary: #c0c0c0;
--accent-glow: 255, 255, 255;
--accent-glow-secondary: 192, 192, 192;
--accent-glow-tertiary: 120, 120, 120;
--headline-color: #e8e8e8;
--headline-glow: 232, 232, 232;
--muted: #787878;
--border: #484848;
--container-body: #111a2a;
--container-cap: #0d131d;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-accent: var(--accent);
--color-accent-secondary: var(--accent-secondary);
--color-muted: var(--muted);
--color-border: var(--border);
--color-container-body: var(--container-body);
--color-container-cap: var(--container-cap);
--font-sans: "Nunito", system-ui, sans-serif;
--font-mono: "Geist Mono", ui-monospace, monospace;
}
body {
background: var(--background);
color: var(--foreground);
font-family: "Nunito", system-ui, sans-serif;
font-size: 1.375rem;
line-height: 1.75;
}
/* Smooth scroll */
html {
scroll-behavior: smooth;
}
/* Code block styling */
code {
font-family: "Geist Mono", ui-monospace, monospace;
}
/* Cloud animation keyframes */
@keyframes cloud-drift {
0% {
transform: translate(0, 0) scale(1);
opacity: 0.5;
}
10% {
transform: translate(900px, -500px) scale(1.15);
opacity: 0.6;
}
22% {
transform: translate(-600px, 800px) scale(0.85);
opacity: 0.4;
}
38% {
transform: translate(1100px, 300px) scale(1.1);
opacity: 0.55;
}
52% {
transform: translate(-900px, -700px) scale(0.9);
opacity: 0.45;
}
68% {
transform: translate(500px, -1000px) scale(1.08);
opacity: 0.5;
}
82% {
transform: translate(-400px, 600px) scale(0.92);
opacity: 0.48;
}
100% {
transform: translate(0, 0) scale(1);
opacity: 0.5;
}
}
@keyframes cloud-drift-reverse {
0% {
transform: translate(0, 0) scale(1);
opacity: 0.4;
}
12% {
transform: translate(-1000px, 600px) scale(1.12);
opacity: 0.55;
}
25% {
transform: translate(700px, -900px) scale(0.88);
opacity: 0.35;
}
40% {
transform: translate(-500px, -800px) scale(1.08);
opacity: 0.5;
}
55% {
transform: translate(1100px, 400px) scale(0.92);
opacity: 0.42;
}
70% {
transform: translate(-800px, 700px) scale(1.06);
opacity: 0.48;
}
85% {
transform: translate(400px, -500px) scale(0.95);
opacity: 0.44;
}
100% {
transform: translate(0, 0) scale(1);
opacity: 0.4;
}
}
/* Spinner light pulse */
@keyframes spinner-pulse {
0% {
opacity: 0;
transform: scale(0.5);
}
12% {
opacity: var(--peak-opacity, 0.5);
transform: scale(1);
}
30% {
opacity: calc(var(--peak-opacity, 0.5) * 0.6);
}
45% {
opacity: var(--peak-opacity, 0.5);
}
60% {
opacity: calc(var(--peak-opacity, 0.5) * 0.7);
}
75% {
opacity: var(--peak-opacity, 0.5);
}
88% {
opacity: calc(var(--peak-opacity, 0.5) * 0.4);
transform: scale(0.8);
}
100% {
opacity: 0;
transform: scale(0.3);
}
}
/* Spinner light inward drift */
@keyframes spinner-pulse-inward {
0% {
opacity: 0;
translate: 0px 0px;
scale: 1;
}
10% {
opacity: var(--peak-opacity, 0.5);
scale: 0.95;
}
30% {
opacity: calc(var(--peak-opacity, 0.5) * 0.7);
scale: 0.8;
}
50% {
opacity: var(--peak-opacity, 0.5);
scale: 0.6;
}
70% {
opacity: calc(var(--peak-opacity, 0.5) * 0.6);
scale: 0.4;
}
90% {
opacity: calc(var(--peak-opacity, 0.5) * 0.3);
scale: 0.2;
}
100% {
opacity: 0;
translate: var(--drift-x) var(--drift-y);
scale: 0.1;
}
}
/* Headline cursor blink */
@keyframes cursor-blink {
0%,
50% {
opacity: 1;
}
51%,
100% {
opacity: 0;
}
}
@utility animate-cursor-blink {
animation: cursor-blink 1s step-end infinite;
}
@utility bg-frosted-glass {
background-color: color-mix(in srgb, var(--container-body) 50%, transparent);
}
@utility text-glow {
text-shadow:
0 0 4px rgba(var(--accent-glow), 0.55),
0 0 11px rgba(var(--accent-glow), 0.22);
}
@keyframes theme-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.75); }
}
/* CRT scanline */
@keyframes crt-scanline-move {
0% {
transform: translateY(-150px);
}
100% {
transform: translateY(calc(100% + 150px));
}
}
@keyframes cloud-drift-slow {
0% {
transform: translate(0, 0) scale(1);
opacity: 0.35;
}
14% {
transform: translate(800px, 900px) scale(1.1);
opacity: 0.5;
}
28% {
transform: translate(-1000px, -400px) scale(0.88);
opacity: 0.3;
}
42% {
transform: translate(600px, -1100px) scale(1.06);
opacity: 0.45;
}
57% {
transform: translate(-700px, 500px) scale(0.92);
opacity: 0.38;
}
72% {
transform: translate(1000px, -300px) scale(1.04);
opacity: 0.42;
}
86% {
transform: translate(-500px, 800px) scale(0.96);
opacity: 0.4;
}
100% {
transform: translate(0, 0) scale(1);
opacity: 0.35;
}
}
/* CRT afterglow */
@keyframes crt-afterglow {
0% {
opacity: 1;
transform: scale(1);
}
30% {
opacity: 0.7;
transform: scale(0.8);
}
100% {
opacity: 0;
transform: scale(0.2);
}
}
/* Avatar tint */
img.avatar-tinted {
filter: grayscale(1) brightness(0.7) sepia(1) saturate(3) hue-rotate(var(--avatar-hue, 70deg));
transition: filter 0.3s ease;
}
[data-theme="white"] img.avatar-tinted {
filter: grayscale(1) brightness(0.8);
}
.group:hover img.avatar-tinted {
filter: none;
}
/* Dashboard Utilities */
@keyframes skeleton-pulse {
0%, 100% { opacity: 0.15; }
50% { opacity: 0.3; }
}
@utility animate-skeleton {
animation: skeleton-pulse 1.8s ease-in-out infinite;
}
@keyframes spin-slow {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@utility animate-spin-slow {
animation: spin-slow 1.5s linear infinite;
}
/* Commit list animations (replaces Framer Motion) */
@keyframes fade-slide-in {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@utility animate-fade-slide-in {
animation: fade-slide-in 0.2s ease-out both;
}
/* Refresh icon fade in/out */
@keyframes fade-scale-in {
from {
opacity: 0;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
@utility animate-fade-scale-in {
animation: fade-scale-in 0.2s ease-out both;
}
/* Mobile Performance: Disable expensive effects */
@media (max-width: 768px) {
/* Disable backdrop-blur on mobile (very expensive on iOS Safari) */
.backdrop-blur-xl,
.backdrop-blur-lg,
.backdrop-blur-md,
.backdrop-blur-sm,
.backdrop-blur {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
/* Replace with solid background for frosted-glass elements */
.bg-frosted-glass {
background-color: var(--container-body) !important;
}
/* Simplify cloud animations on mobile: no blur, reduced opacity, no animation */
.cloud-element {
filter: none !important;
opacity: 0.15 !important;
animation: none !important;
}
/* Simplify avatar filters on mobile */
img.avatar-tinted {
filter: grayscale(1) brightness(0.7) !important;
}
/* Disable SpinnerLights complex shadows on mobile */
.spinner-light {
box-shadow: none !important;
}
}
/* Reduced Motion */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
File diff suppressed because it is too large Load Diff
-11
View File
@@ -1,11 +0,0 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "react",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}