├── src
├── setupTests.ts
├── vite-env.d.ts
├── main.tsx
├── App.css
├── App.test.tsx
├── contexts
│ ├── ThemeContext.tsx
│ ├── AppContext.tsx
│ └── __tests__
│ │ └── AppContext.test.tsx
├── index.css
├── components
│ ├── TerminalWindow
│ │ ├── TerminalWindow.css
│ │ ├── __tests__
│ │ │ ├── TerminalWindow.test.tsx
│ │ │ └── __snapshots__
│ │ │ │ └── TerminalWindow.test.tsx.snap
│ │ └── TerminalWindow.tsx
│ ├── YouTubeAudioPlayer
│ │ ├── SimpleYouTubePlayer.tsx
│ │ └── YouTubeAudioPlayer.tsx
│ ├── BouncyCat
│ │ └── BouncyCat.tsx
│ └── ControlsPanel
│ │ ├── __tests__
│ │ └── ControlsPanel.test.tsx
│ │ └── ControlsPanel.tsx
├── hooks
│ └── useTypewriter.ts
├── data
│ ├── colorThemes.ts
│ └── terminalOutputs.ts
├── assets
│ └── react.svg
├── utils
│ └── asciiArt.ts
├── App.tsx
└── pages
│ └── AsciiTyper.tsx
├── public
├── og.png
├── favicon.ico
├── favicon.png
├── nyancat.gif
├── sonomaBackground.jpg
└── vite.svg
├── postcss.config.js
├── vercel.json
├── tsconfig.json
├── .prettierrc
├── tailwind.config.js
├── .gitignore
├── README.md
├── vite.config.ts
├── .vercelignore
├── tsconfig.node.json
├── eslint.config.js
├── .github
└── workflows
│ └── ci.yml
├── tsconfig.app.json
├── index.html
├── package.json
├── .eslintrc.cjs
└── planning
├── todo.md
├── spec.md
└── plan.md
/src/setupTests.ts:
--------------------------------------------------------------------------------
1 | import '@testing-library/jest-dom';
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/public/og.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gkamradt/MultiTerminalCodeViz/HEAD/public/og.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gkamradt/MultiTerminalCodeViz/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/public/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gkamradt/MultiTerminalCodeViz/HEAD/public/favicon.png
--------------------------------------------------------------------------------
/public/nyancat.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gkamradt/MultiTerminalCodeViz/HEAD/public/nyancat.gif
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/public/sonomaBackground.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gkamradt/MultiTerminalCodeViz/HEAD/public/sonomaBackground.jpg
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "rewrites": [
3 | {
4 | "source": "/(.*)",
5 | "destination": "/index.html"
6 | }
7 | ]
8 | }
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | { "path": "./tsconfig.app.json" },
5 | { "path": "./tsconfig.node.json" }
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": true,
3 | "singleQuote": true,
4 | "trailingComma": "all",
5 | "printWidth": 80,
6 | "tabWidth": 2,
7 | "arrowParens": "always",
8 | "endOfLine": "lf"
9 | }
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | export default {
3 | content: [
4 | "./index.html",
5 | "./src/**/*.{js,ts,jsx,tsx}",
6 | ],
7 | theme: {
8 | extend: {},
9 | },
10 | plugins: [],
11 | }
12 |
13 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { StrictMode } from 'react'
2 | import { createRoot } from 'react-dom/client'
3 | import './index.css'
4 | import App from './App.tsx'
5 |
6 | createRoot(document.getElementById('root')!).render(
7 |
8 |
9 | ,
10 | )
11 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # I Vibe More Than You
2 |
3 | https://www.IVibeMoreThanYou.com/
4 |
5 | This app is for fun - PRs welcome
6 |
7 | A React app that displays multiple draggable and resizable terminal windows with animated typing effects. Each terminal shows different development scenarios with customizable themes and variable typing speeds.
8 |
9 | ## Quick Start
10 |
11 | ```bash
12 | git clone https://github.com/gkamradt/MultiTerminalCodeViz
13 | npm install
14 | npm run dev
15 | ```
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | ///
2 | import { defineConfig } from 'vite'
3 | import react from '@vitejs/plugin-react'
4 |
5 | // https://vite.dev/config/
6 | export default defineConfig({
7 | plugins: [react()],
8 | test: {
9 | globals: true,
10 | environment: 'jsdom',
11 | setupFiles: './src/setupTests.ts', // Optional: if you have a setup file
12 | css: true, // if you want to test CSS
13 | include: ['src/**/*.test.{ts,tsx}'],
14 | },
15 | })
16 |
--------------------------------------------------------------------------------
/.vercelignore:
--------------------------------------------------------------------------------
1 | # Dependencies
2 | node_modules
3 | npm-debug.log*
4 | yarn-debug.log*
5 | yarn-error.log*
6 |
7 | # Build outputs (handled by Vercel)
8 | dist
9 | build
10 |
11 | # Development
12 | .env.local
13 | .env.development.local
14 | .env.test.local
15 | .env.production.local
16 |
17 | # Testing
18 | coverage
19 | .nyc_output
20 |
21 | # IDE
22 | .vscode
23 | .idea
24 | *.swp
25 | *.swo
26 |
27 | # OS
28 | .DS_Store
29 | Thumbs.db
30 |
31 | # Logs
32 | logs
33 | *.log
34 |
35 | # Runtime data
36 | pids
37 | *.pid
38 | *.seed
39 | *.pid.lock
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4 | "target": "ES2022",
5 | "lib": ["ES2023"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "verbatimModuleSyntax": true,
13 | "moduleDetection": "force",
14 | "noEmit": true,
15 |
16 | /* Linting */
17 | "strict": true,
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true,
20 | "noFallthroughCasesInSwitch": true,
21 | "noUncheckedSideEffectImports": true
22 | },
23 | "include": ["vite.config.ts"]
24 | }
25 |
--------------------------------------------------------------------------------
/src/App.css:
--------------------------------------------------------------------------------
1 | #root {
2 | max-width: 1280px;
3 | margin: 0 auto;
4 | padding: 2rem;
5 | text-align: center;
6 | }
7 |
8 | .logo {
9 | height: 6em;
10 | padding: 1.5em;
11 | will-change: filter;
12 | transition: filter 300ms;
13 | }
14 | .logo:hover {
15 | filter: drop-shadow(0 0 2em #646cffaa);
16 | }
17 | .logo.react:hover {
18 | filter: drop-shadow(0 0 2em #61dafbaa);
19 | }
20 |
21 | @keyframes logo-spin {
22 | from {
23 | transform: rotate(0deg);
24 | }
25 | to {
26 | transform: rotate(360deg);
27 | }
28 | }
29 |
30 | @media (prefers-reduced-motion: no-preference) {
31 | a:nth-of-type(2) .logo {
32 | animation: logo-spin infinite 20s linear;
33 | }
34 | }
35 |
36 | .card {
37 | padding: 2em;
38 | }
39 |
40 | .read-the-docs {
41 | color: #888;
42 | }
43 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import js from '@eslint/js'
2 | import globals from 'globals'
3 | import reactHooks from 'eslint-plugin-react-hooks'
4 | import reactRefresh from 'eslint-plugin-react-refresh'
5 | import tseslint from 'typescript-eslint'
6 |
7 | export default tseslint.config(
8 | { ignores: ['dist'] },
9 | {
10 | extends: [js.configs.recommended, ...tseslint.configs.recommended],
11 | files: ['**/*.{ts,tsx}'],
12 | languageOptions: {
13 | ecmaVersion: 2020,
14 | globals: globals.browser,
15 | },
16 | plugins: {
17 | 'react-hooks': reactHooks,
18 | 'react-refresh': reactRefresh,
19 | },
20 | rules: {
21 | ...reactHooks.configs.recommended.rules,
22 | 'react-refresh/only-export-components': [
23 | 'warn',
24 | { allowConstantExport: true },
25 | ],
26 | },
27 | },
28 | )
29 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | pull_request:
7 | branches: [ main ]
8 |
9 | jobs:
10 | build:
11 | runs-on: ubuntu-latest
12 |
13 | strategy:
14 | matrix:
15 | node-version: [20.x]
16 |
17 | steps:
18 | - name: Checkout repository
19 | uses: actions/checkout@v4
20 |
21 | - name: Set up Node.js ${{ matrix.node-version }}
22 | uses: actions/setup-node@v4
23 | with:
24 | node-version: ${{ matrix.node-version }}
25 | cache: 'npm'
26 |
27 | - name: Install dependencies
28 | run: npm ci --legacy-peer-deps
29 |
30 | - name: Lint code
31 | run: npm run lint --if-present
32 |
33 | - name: Run tests
34 | run: npm test -- --run # Add --run for Vitest to exit after tests
35 |
36 | - name: Build project
37 | run: npm run build
--------------------------------------------------------------------------------
/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4 | "target": "ES2020",
5 | "useDefineForClassFields": true,
6 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
7 | "module": "ESNext",
8 | "skipLibCheck": true,
9 | "types": ["vitest/globals"],
10 |
11 | /* Bundler mode */
12 | "moduleResolution": "bundler",
13 | "allowImportingTsExtensions": true,
14 | "verbatimModuleSyntax": true,
15 | "moduleDetection": "force",
16 | "noEmit": true,
17 | "jsx": "react-jsx",
18 |
19 | /* Linting */
20 | "strict": true,
21 | "noUnusedLocals": true,
22 | "noUnusedParameters": true,
23 | // "erasableSyntaxOnly": true, // Temporarily commented out
24 | "noFallthroughCasesInSwitch": true,
25 | "noUncheckedSideEffectImports": true
26 | },
27 | "include": ["src"]
28 | }
29 |
--------------------------------------------------------------------------------
/src/App.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import App from './App';
3 |
4 | // Mock window.innerWidth and innerHeight for the terminal layout
5 | Object.defineProperty(window, 'innerWidth', {
6 | writable: true,
7 | configurable: true,
8 | value: 1024,
9 | });
10 | Object.defineProperty(window, 'innerHeight', {
11 | writable: true,
12 | configurable: true,
13 | value: 768,
14 | });
15 |
16 | describe('', () => {
17 | it('renders terminal controls panel', () => {
18 | // App already includes Router, so we don't need to wrap it
19 | render();
20 |
21 | // Check for terminal controls
22 | expect(screen.getByText('Terminal Controls')).toBeInTheDocument();
23 | expect(screen.getByText('1 terminal')).toBeInTheDocument();
24 | expect(screen.getByText('Arrange')).toBeInTheDocument();
25 | expect(screen.getByText(/Theme:/)).toBeInTheDocument();
26 | });
27 | });
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | I Vibe More Than You
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/contexts/ThemeContext.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react-refresh/only-export-components */
2 | import { createContext, useContext, useState } from 'react';
3 | import type { ReactNode } from 'react';
4 | import { terminalThemes, defaultTheme } from '../data/colorThemes';
5 | import type { TerminalTheme, TerminalColorRole } from '../data/colorThemes';
6 |
7 | interface ThemeContextType {
8 | currentTheme: TerminalTheme;
9 | themeName: string;
10 | setTheme: (themeName: string) => void;
11 | getThemeNames: () => string[];
12 | getColorForRole: (role: TerminalColorRole) => string;
13 | }
14 |
15 | const ThemeContext = createContext(undefined);
16 |
17 | export function ThemeProvider({ children }: { children: ReactNode }) {
18 | const [themeName, setThemeName] = useState(defaultTheme);
19 | const currentTheme = terminalThemes[themeName];
20 |
21 | const setTheme = (newThemeName: string) => {
22 | if (terminalThemes[newThemeName]) {
23 | setThemeName(newThemeName);
24 | }
25 | };
26 |
27 | const getThemeNames = () => Object.keys(terminalThemes);
28 |
29 | const getColorForRole = (role: TerminalColorRole) => {
30 | return currentTheme.colors[role];
31 | };
32 |
33 | return (
34 |
43 | {children}
44 |
45 | );
46 | }
47 |
48 | export function useTheme() {
49 | const context = useContext(ThemeContext);
50 | if (context === undefined) {
51 | throw new Error('useTheme must be used within a ThemeProvider');
52 | }
53 | return context;
54 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "multiplecodeterminals",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc -b && vite build",
9 | "lint": "eslint . --report-unused-disable-directives --max-warnings 0",
10 | "format": "prettier --write .",
11 | "test": "vitest",
12 | "test:ui": "vitest --ui",
13 | "preview": "vite preview",
14 | "vercel-build": "npm run build"
15 | },
16 | "dependencies": {
17 | "@vercel/analytics": "^1.5.0",
18 | "html2canvas": "^1.4.1",
19 | "react": "^18.2.0",
20 | "react-dom": "^18.2.0",
21 | "react-draggable": "^4.4.6",
22 | "react-resizable": "^3.0.5",
23 | "react-router-dom": "^7.6.2"
24 | },
25 | "devDependencies": {
26 | "@eslint/js": "^9.25.0",
27 | "@tailwindcss/postcss": "^4.1.8",
28 | "@testing-library/dom": "^10.4.0",
29 | "@testing-library/jest-dom": "^6.6.3",
30 | "@testing-library/react": "^16.3.0",
31 | "@testing-library/user-event": "^14.6.1",
32 | "@types/react": "^18.2.66",
33 | "@types/react-dom": "^18.2.22",
34 | "@types/react-resizable": "^3.0.8",
35 | "@types/react-router-dom": "^5.3.3",
36 | "@typescript-eslint/eslint-plugin": "^7.2.0",
37 | "@typescript-eslint/parser": "^7.2.0",
38 | "@vitejs/plugin-react": "^4.2.1",
39 | "@vitest/ui": "^3.2.0",
40 | "autoprefixer": "^10.4.19",
41 | "eslint": "^8.57.0",
42 | "eslint-config-airbnb": "^19.0.4",
43 | "eslint-config-prettier": "^10.1.5",
44 | "eslint-import-resolver-typescript": "^4.4.2",
45 | "eslint-plugin-import": "^2.29.1",
46 | "eslint-plugin-jsx-a11y": "^6.8.0",
47 | "eslint-plugin-react": "^7.34.1",
48 | "eslint-plugin-react-hooks": "^4.6.0",
49 | "eslint-plugin-react-refresh": "^0.4.6",
50 | "globals": "^16.0.0",
51 | "jsdom": "^26.1.0",
52 | "postcss": "^8.4.38",
53 | "prettier": "^3.2.5",
54 | "tailwindcss": "^3.4.3",
55 | "typescript": "^5.2.2",
56 | "typescript-eslint": "^8.30.1",
57 | "vite": "^5.2.0",
58 | "vitest": "^3.2.0"
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | html,
6 | body,
7 | #root {
8 | height: 100vh;
9 | width: 100vw;
10 | margin: 0 !important;
11 | padding: 0 !important;
12 | overflow: hidden;
13 | box-sizing: border-box;
14 | }
15 |
16 | * {
17 | box-sizing: border-box;
18 | }
19 |
20 | :root {
21 | font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
22 | line-height: 1.5;
23 | font-weight: 400;
24 |
25 | color-scheme: light dark;
26 | /* Default text color and bg color will be managed by App.tsx for dark mode */
27 | /* color: rgba(255, 255, 255, 0.87); */
28 | /* background-color: #242424; */
29 |
30 | font-synthesis: none;
31 | text-rendering: optimizeLegibility;
32 | -webkit-font-smoothing: antialiased;
33 | -moz-osx-font-smoothing: grayscale;
34 | }
35 |
36 | a {
37 | font-weight: 500;
38 | color: #646cff;
39 | text-decoration: inherit;
40 | }
41 | a:hover {
42 | color: #535bf2;
43 | }
44 |
45 | body {
46 | /* margin: 0; */ /* Moved to html,body,#root rule */
47 | /* display: flex; */ /* REMOVED to allow full page content */
48 | /* place-items: center; */ /* REMOVED */
49 | /* min-width: 320px; */ /* REMOVED */
50 | /* min-height: 100vh; */ /* Replaced by height: 100% */
51 | }
52 |
53 | h1 {
54 | font-size: 3.2em;
55 | line-height: 1.1;
56 | }
57 |
58 | button {
59 | border-radius: 8px;
60 | border: 1px solid transparent;
61 | padding: 0.6em 1.2em;
62 | font-size: 1em;
63 | font-weight: 500;
64 | font-family: inherit;
65 | background-color: #1a1a1a; /* Default button color, can be overridden by Tailwind */
66 | cursor: pointer;
67 | transition: border-color 0.25s;
68 | }
69 | button:hover {
70 | border-color: #646cff;
71 | }
72 | button:focus,
73 | button:focus-visible {
74 | outline: 4px auto -webkit-focus-ring-color;
75 | }
76 |
77 | @media (prefers-color-scheme: light) {
78 | /* :root {
79 | color: #213547;
80 | background-color: #ffffff;
81 | } */ /* App.tsx will handle theming */
82 | a:hover {
83 | color: #747bff;
84 | }
85 | /* button {
86 | background-color: #f9f9f9;
87 | } */ /* Tailwind will handle button colors */
88 | }
89 |
--------------------------------------------------------------------------------
/src/components/TerminalWindow/TerminalWindow.css:
--------------------------------------------------------------------------------
1 | .react-resizable {
2 | position: relative;
3 | /* background-clip: padding-box; */ /* Optional: Might conflict with terminal styling */
4 | }
5 |
6 | .react-resizable-handle {
7 | position: absolute;
8 | width: 20px;
9 | height: 20px;
10 | background-repeat: no-repeat;
11 | background-origin: content-box;
12 | box-sizing: border-box;
13 | background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2IDYiIHN0eWxlPSJiYWNrZ3JvdW5kLWNvbG9yOndoaXRlMDAiIHg9IjAiIHk9IjAiIHdpZHRoPSI2cHgiIGhlaWdodD0iNnB4Ij48ZyBvcGFjaXR5PSIwLjMwMiI+PHBhdGggZD0iTSA2IDYgTCAwIDYgTCAwIDQuOCBMIDQuOCA0LjggTCA0LjggMCBMIDYgMCBMIDYgNiBaIiBmaWxsPSIjMDAwMDAwIi8+PC9nPjwvc3ZnPg==');
14 | background-position: bottom right;
15 | padding: 0 3px 3px 0;
16 | }
17 |
18 | .react-resizable-handle-sw {
19 | bottom: 0;
20 | left: 0;
21 | cursor: sw-resize;
22 | transform: rotate(90deg);
23 | }
24 |
25 | .react-resizable-handle-se {
26 | bottom: 0;
27 | right: 0;
28 | cursor: se-resize;
29 | }
30 |
31 | .react-resizable-handle-nw {
32 | top: 0;
33 | left: 0;
34 | cursor: nw-resize;
35 | transform: rotate(180deg);
36 | }
37 |
38 | .react-resizable-handle-ne {
39 | top: 0;
40 | right: 0;
41 | cursor: ne-resize;
42 | transform: rotate(270deg);
43 | }
44 |
45 | .react-resizable-handle-w,
46 | .react-resizable-handle-e {
47 | top: 50%;
48 | margin-top: -10px;
49 | cursor: ew-resize;
50 | }
51 |
52 | .react-resizable-handle-w {
53 | left: 0;
54 | transform: rotate(135deg);
55 | }
56 |
57 | .react-resizable-handle-e {
58 | right: 0;
59 | transform: rotate(315deg);
60 | }
61 |
62 | .react-resizable-handle-n,
63 | .react-resizable-handle-s {
64 | left: 50%;
65 | margin-left: -10px;
66 | cursor: ns-resize;
67 | }
68 |
69 | .react-resizable-handle-n {
70 | top: 0;
71 | transform: rotate(225deg);
72 | }
73 |
74 | .react-resizable-handle-s {
75 | bottom: 0;
76 | transform: rotate(45deg);
77 | }
78 |
79 | /* Ensure the resizable box itself uses absolute positioning if it's inside a Draggable that will be providing x/y */
80 | .react-resizable-box.absolute {
81 | position: absolute;
82 | }
83 |
84 | /* Hide scrollbars */
85 | .terminal-content {
86 | /* Hide scrollbar for Chrome, Safari and Opera */
87 | -webkit-scrollbar: none;
88 | -ms-overflow-style: none; /* IE and Edge */
89 | scrollbar-width: none; /* Firefox */
90 | }
91 |
92 | .terminal-content::-webkit-scrollbar {
93 | display: none;
94 | }
--------------------------------------------------------------------------------
/src/hooks/useTypewriter.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useRef } from 'react';
2 | import type { TerminalLine } from '../data/terminalOutputs';
3 |
4 | interface UseTypewriterProps {
5 | lines: TerminalLine[];
6 | speed?: number; // ms per token (3-6 chars)
7 | enabled?: boolean;
8 | loop?: boolean; // Add loop option
9 | loopDelay?: number; // Delay before restarting
10 | }
11 |
12 | export function useTypewriter({
13 | lines,
14 | speed = 20, // Slower since we're doing tokens now
15 | enabled = true,
16 | loop = false,
17 | loopDelay = 2000
18 | }: UseTypewriterProps) {
19 | const [displayedLines, setDisplayedLines] = useState([]);
20 | const [currentLineIndex, setCurrentLineIndex] = useState(0);
21 | const [currentCharIndex, setCurrentCharIndex] = useState(0);
22 | const timeoutRef = useRef | null>(null);
23 |
24 | // Generate random token size (3-6 characters)
25 | const getTokenSize = () => Math.floor(Math.random() * 2) + 10; // 3-6 chars
26 |
27 | useEffect(() => {
28 | if (!enabled || lines.length === 0) {
29 | return;
30 | }
31 |
32 | // Handle loop restart
33 | if (currentLineIndex >= lines.length) {
34 | if (loop) {
35 | timeoutRef.current = setTimeout(() => {
36 | setDisplayedLines([]);
37 | setCurrentLineIndex(0);
38 | setCurrentCharIndex(0);
39 | }, loopDelay);
40 | return;
41 | } else {
42 | return;
43 | }
44 | }
45 |
46 | const currentLine = lines[currentLineIndex];
47 | const delay = currentLine.delay || 0;
48 |
49 | // Add delay before starting the line if specified
50 | if (currentCharIndex === 0 && delay > 0) {
51 | timeoutRef.current = setTimeout(() => {
52 | setCurrentCharIndex(1);
53 | }, delay);
54 | return;
55 | }
56 |
57 | // Type out the current line using tokens
58 | if (currentCharIndex < currentLine.text.length) {
59 | timeoutRef.current = setTimeout(() => {
60 | const tokenSize = getTokenSize();
61 | const nextCharIndex = Math.min(currentCharIndex + tokenSize, currentLine.text.length);
62 |
63 | const partialLine = {
64 | ...currentLine,
65 | text: currentLine.text.slice(0, nextCharIndex)
66 | };
67 |
68 | setDisplayedLines(prev => {
69 | const newLines = [...prev];
70 | newLines[currentLineIndex] = partialLine;
71 | return newLines;
72 | });
73 |
74 | setCurrentCharIndex(nextCharIndex);
75 | }, speed);
76 | } else {
77 | // Move to next line
78 | setCurrentLineIndex(prev => prev + 1);
79 | setCurrentCharIndex(0);
80 | }
81 |
82 | return () => {
83 | if (timeoutRef.current) {
84 | clearTimeout(timeoutRef.current);
85 | }
86 | };
87 | }, [currentLineIndex, currentCharIndex, lines, speed, enabled, loop, loopDelay]);
88 |
89 | const reset = () => {
90 | setDisplayedLines([]);
91 | setCurrentLineIndex(0);
92 | setCurrentCharIndex(0);
93 | };
94 |
95 | const isTyping = currentLineIndex < lines.length || (loop && currentLineIndex >= lines.length);
96 |
97 | return {
98 | displayedLines,
99 | isTyping,
100 | reset
101 | };
102 | }
--------------------------------------------------------------------------------
/src/data/colorThemes.ts:
--------------------------------------------------------------------------------
1 | // Semantic color roles for terminal output
2 | export type TerminalColorRole =
3 | | 'muted' // gray, secondary text
4 | | 'success' // green, success messages
5 | | 'warning' // yellow/orange, warnings
6 | | 'error' // red, errors
7 | | 'info' // blue, informational
8 | | 'accent' // purple/cyan, highlights
9 | | 'primary' // main text color
10 | | 'secondary' // slightly dimmed text
11 | | 'command'; // command/input text
12 |
13 | export interface TerminalTheme {
14 | name: string;
15 | background: string;
16 | colors: Record;
17 | }
18 |
19 | export const terminalThemes: Record = {
20 | dark: {
21 | name: 'Dark',
22 | background: 'bg-black',
23 | colors: {
24 | muted: 'text-gray-400',
25 | success: 'text-green-400',
26 | warning: 'text-yellow-400',
27 | error: 'text-red-400',
28 | info: 'text-blue-400',
29 | accent: 'text-purple-400',
30 | primary: 'text-green-200',
31 | secondary: 'text-gray-300',
32 | command: 'text-gray-500'
33 | }
34 | },
35 | light: {
36 | name: 'Light',
37 | background: 'bg-gray-50',
38 | colors: {
39 | muted: 'text-gray-500',
40 | success: 'text-green-600',
41 | warning: 'text-orange-600',
42 | error: 'text-red-600',
43 | info: 'text-blue-600',
44 | accent: 'text-purple-600',
45 | primary: 'text-gray-800',
46 | secondary: 'text-gray-600',
47 | command: 'text-gray-700'
48 | }
49 | },
50 | minimal: {
51 | name: 'Minimal',
52 | background: 'bg-white',
53 | colors: {
54 | muted: 'text-gray-400',
55 | success: 'text-gray-600',
56 | warning: 'text-gray-600',
57 | error: 'text-gray-700',
58 | info: 'text-gray-600',
59 | accent: 'text-gray-700',
60 | primary: 'text-gray-800',
61 | secondary: 'text-gray-600',
62 | command: 'text-gray-500'
63 | }
64 | },
65 | retro: {
66 | name: 'Retro Green',
67 | background: 'bg-black',
68 | colors: {
69 | muted: 'text-green-600',
70 | success: 'text-green-400',
71 | warning: 'text-green-300',
72 | error: 'text-green-500',
73 | info: 'text-green-400',
74 | accent: 'text-green-300',
75 | primary: 'text-green-400',
76 | secondary: 'text-green-500',
77 | command: 'text-green-600'
78 | }
79 | },
80 | dracula: {
81 | name: 'Dracula',
82 | background: 'bg-gray-900',
83 | colors: {
84 | muted: 'text-gray-400',
85 | success: 'text-green-400',
86 | warning: 'text-yellow-500',
87 | error: 'text-red-500',
88 | info: 'text-cyan-400',
89 | accent: 'text-purple-400',
90 | primary: 'text-pink-300',
91 | secondary: 'text-gray-300',
92 | command: 'text-purple-500'
93 | }
94 | },
95 | solarized: {
96 | name: 'Solarized Dark',
97 | background: 'bg-[#002b36]',
98 | colors: {
99 | muted: 'text-[#839496]',
100 | success: 'text-[#859900]',
101 | warning: 'text-[#b58900]',
102 | error: 'text-[#dc322f]',
103 | info: 'text-[#268bd2]',
104 | accent: 'text-[#2aa198]',
105 | primary: 'text-[#eee8d5]',
106 | secondary: 'text-[#839496]',
107 | command: 'text-[#586e75]'
108 | }
109 | }
110 | };
111 |
112 | export const defaultTheme = 'dark';
113 |
--------------------------------------------------------------------------------
/src/components/TerminalWindow/__tests__/TerminalWindow.test.tsx:
--------------------------------------------------------------------------------
1 | import { describe, it, expect } from 'vitest';
2 | import { render } from '@testing-library/react';
3 | import { TerminalWindow } from '../TerminalWindow';
4 | import { ThemeProvider } from '../../../contexts/ThemeContext';
5 |
6 | describe('', () => {
7 | it('should match snapshot for static UI', () => {
8 | const { container } = render(
9 |
10 |
16 |
17 | );
18 | // For snapshot testing with react-draggable and react-resizable,
19 | // it's often best to assert against the direct child of the ResizableBox
20 | // or a stable part of your component structure, as the wrapper elements
21 | // from these libraries can add dynamic attributes (like transform styles)
22 | // that make snapshots brittle.
23 | // Here, we target the main 'terminal-window' div directly.
24 | const terminalWindowElement = container.querySelector('.terminal-window');
25 | expect(terminalWindowElement).toMatchSnapshot();
26 | });
27 |
28 | // 3.4 Clamping tests for resize (verifying props passed to ResizableBox)
29 | it('should pass correct min/max constraints to ResizableBox', () => {
30 | const { container } = render(
31 |
32 |
36 |
37 | );
38 |
39 | // Find the ResizableBox element which has the react-resizable class
40 | const resizableBoxElement = container.querySelector('.react-resizable');
41 | expect(resizableBoxElement).toBeInTheDocument();
42 |
43 | // We can also check that our initial size is applied.
44 | // The ResizableBox wrapper div gets inline styles for width and height.
45 | expect(resizableBoxElement).toHaveStyle('width: 300px;');
46 | expect(resizableBoxElement).toHaveStyle('height: 200px;');
47 | });
48 |
49 | it('should apply default dimensions if initialSize is not provided', () => {
50 | const { container } = render(
51 |
52 |
53 |
54 | );
55 | const resizableBoxElement = container.querySelector('.react-resizable');
56 | expect(resizableBoxElement).toHaveStyle('width: 650px;');
57 | expect(resizableBoxElement).toHaveStyle('height: 450px;');
58 | });
59 |
60 | it('should apply partial default dimensions if initialSize is partially provided', () => {
61 | const { container: containerW } = render(
62 |
63 |
64 |
65 | );
66 | const resizableBoxW = containerW.querySelector('.react-resizable');
67 | expect(resizableBoxW).toHaveStyle('width: 350px;');
68 | expect(resizableBoxW).toHaveStyle('height: 450px;'); // Default height
69 |
70 | const { container: containerH } = render(
71 |
72 |
73 |
74 | );
75 | const resizableBoxH = containerH.querySelector('.react-resizable');
76 | expect(resizableBoxH).toHaveStyle('width: 650px;'); // Default width
77 | expect(resizableBoxH).toHaveStyle('height: 250px;');
78 | });
79 | });
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | browser: true,
4 | es2021: true,
5 | node: true,
6 | },
7 | extends: [
8 | 'eslint:recommended',
9 | 'plugin:@typescript-eslint/recommended',
10 | 'plugin:react/recommended',
11 | 'plugin:react-hooks/recommended',
12 | 'plugin:jsx-a11y/recommended',
13 | 'airbnb',
14 | 'airbnb/hooks',
15 | 'plugin:import/typescript',
16 | 'prettier', // Make sure this is last to override other configs
17 | ],
18 | parser: '@typescript-eslint/parser',
19 | parserOptions: {
20 | ecmaFeatures: {
21 | jsx: true,
22 | },
23 | ecmaVersion: 'latest',
24 | sourceType: 'module',
25 | project: './tsconfig.json', // Specify your tsconfig.json path
26 | },
27 | plugins: [
28 | '@typescript-eslint',
29 | 'react',
30 | 'react-hooks',
31 | 'jsx-a11y',
32 | 'import',
33 | 'react-refresh', // For Vite HMR
34 | ],
35 | rules: {
36 | // TypeScript specific rules
37 | '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
38 | '@typescript-eslint/explicit-function-return-type': 'off',
39 | '@typescript-eslint/explicit-module-boundary-types': 'off',
40 | '@typescript-eslint/no-explicit-any': 'warn',
41 |
42 | // React specific rules
43 | 'react/react-in-jsx-scope': 'off', // Not needed with React 17+ and Vite
44 | 'react/jsx-filename-extension': [1, { extensions: ['.tsx', '.jsx'] }],
45 | 'react/prop-types': 'off', // We use TypeScript for prop types
46 | 'react/jsx-props-no-spreading': 'off',
47 | 'react-hooks/rules-of-hooks': 'error',
48 | 'react-hooks/exhaustive-deps': 'warn',
49 | 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
50 |
51 |
52 | // Import rules
53 | 'import/extensions': [
54 | 'error',
55 | 'ignorePackages',
56 | {
57 | js: 'never',
58 | jsx: 'never',
59 | ts: 'never',
60 | tsx: 'never',
61 | },
62 | ],
63 | 'import/prefer-default-export': 'off',
64 | 'import/no-extraneous-dependencies': ['error', { devDependencies: ['**/*.test.ts', '**/*.test.tsx', '**/*.spec.ts', '**/*.spec.tsx', 'vite.config.ts', '.eslintrc.cjs', 'eslint.config.js', 'postcss.config.js', 'tailwind.config.js'] }],
65 |
66 |
67 | // General rules
68 | 'no-console': 'warn',
69 | 'no-unused-vars': 'off', // Handled by @typescript-eslint/no-unused-vars
70 | 'comma-dangle': ['error', 'always-multiline'],
71 | semi: ['error', 'always'],
72 | quotes: ['error', 'single'],
73 | 'object-curly-spacing': ['error', 'always'],
74 | 'arrow-parens': ['error', 'always'],
75 | 'no-shadow': 'off', // Replaced by @typescript-eslint/no-shadow
76 | '@typescript-eslint/no-shadow': ['error'],
77 | 'no-restricted-exports': 'off',
78 |
79 | // jsx-a11y improvements (can be project-specific)
80 | 'jsx-a11y/anchor-is-valid': 'off', // Often handled by routers
81 | 'jsx-a11y/label-has-associated-control': ['error', {
82 | assert: 'either', // Allows nesting or id referencing
83 | }],
84 | },
85 | settings: {
86 | react: {
87 | version: 'detect', // Automatically detect the React version
88 | },
89 | 'import/resolver': {
90 | typescript: {
91 | project: './tsconfig.json',
92 | },
93 | node: {
94 | extensions: ['.js', '.jsx', '.ts', '.tsx'],
95 | },
96 | },
97 | },
98 | ignorePatterns: ['node_modules/', 'dist/', '.eslintrc.cjs', 'vite.config.ts', 'tailwind.config.js', 'postcss.config.js'], // Ignore build outputs and config files
99 | };
--------------------------------------------------------------------------------
/src/components/YouTubeAudioPlayer/SimpleYouTubePlayer.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useRef, useEffect, useCallback } from 'react';
2 |
3 | interface YouTubeVideo {
4 | id: string;
5 | url: string;
6 | title?: string;
7 | }
8 |
9 | interface SimpleYouTubePlayerProps {
10 | videos: YouTubeVideo[];
11 | }
12 |
13 | export const SimpleYouTubePlayer: React.FC = ({ videos }) => {
14 | const [currentVideoIndex, setCurrentVideoIndex] = useState(0);
15 | const [isPlaying, setIsPlaying] = useState(false);
16 | const iframeRef = useRef(null);
17 |
18 | const currentVideo = videos[currentVideoIndex];
19 |
20 | const getEmbedUrl = useCallback((videoUrl: string) => {
21 | const videoId = videoUrl.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([^&\n?#]+)/)?.[1];
22 | return `https://www.youtube.com/embed/${videoId}?enablejsapi=1&autoplay=0&controls=0&showinfo=0&rel=0&modestbranding=1`;
23 | }, []);
24 |
25 | const togglePlayPause = () => {
26 | if (iframeRef.current && iframeRef.current.contentWindow) {
27 | const command = isPlaying ? 'pauseVideo' : 'playVideo';
28 | iframeRef.current.contentWindow.postMessage(
29 | `{"event":"command","func":"${command}","args":""}`,
30 | '*'
31 | );
32 | setIsPlaying(!isPlaying);
33 | }
34 | };
35 |
36 | useEffect(() => {
37 | if (iframeRef.current) {
38 | iframeRef.current.src = getEmbedUrl(currentVideo.url);
39 | }
40 |
41 | // Auto-advance to next video when current one ends
42 | const handleMessage = (event: MessageEvent) => {
43 | if (event.origin !== 'https://www.youtube.com') return;
44 |
45 | try {
46 | const data = JSON.parse(event.data);
47 | if (data.event === 'video-progress' && data.info === 0) { // Video ended
48 | const nextIndex = (currentVideoIndex + 1) % videos.length;
49 | setCurrentVideoIndex(nextIndex);
50 | setIsPlaying(false);
51 | }
52 | } catch {
53 | // Ignore invalid JSON
54 | }
55 | };
56 |
57 | window.addEventListener('message', handleMessage);
58 | return () => window.removeEventListener('message', handleMessage);
59 | }, [currentVideoIndex, currentVideo.url, getEmbedUrl, videos.length]);
60 |
61 | if (!videos.length) {
62 | return null;
63 | }
64 |
65 | return (
66 | <>
67 | {/* Hidden iframe for audio playback */}
68 |
75 |
76 | {/* Simple play/stop button */}
77 |
100 | >
101 | );
102 | };
103 |
--------------------------------------------------------------------------------
/src/components/TerminalWindow/__tests__/__snapshots__/TerminalWindow.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2 |
3 | exports[` > should match snapshot for static UI 1`] = `
4 |
8 |
11 |
25 |
28 |
31 | Test Terminal
32 |
33 |
34 |
35 |
38 |
39 |
42 |
43 | ██╗ ██╗ ██╗██╗██████╗ ███████╗ ███╗ ███╗ ██████╗ ██████╗ ███████╗
44 |
45 |
46 | ██║ ██║ ██║██║██╔══██╗██╔════╝ ████╗ ████║██╔═══██╗██╔══██╗██╔════╝
47 |
48 |
49 | ██║ ██║ ██║██║██████╔╝█████╗ ██╔████╔██║██║ ██║██████╔╝█████╗
50 |
51 |
52 | ██║ ╚██╗ ██╔╝██║██╔══██╗██╔══╝ ██║╚██╔╝██║██║ ██║██╔══██╗██╔══╝
53 |
54 |
55 | ██║ ╚████╔╝ ██║██████╔╝███████╗ ██║ ╚═╝ ██║╚██████╔╝██║ ██║███████╗
56 |
57 |
58 | ╚═╝ ╚═══╝ ╚═╝╚═════╝ ╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝
59 |
60 |
61 |
62 | ████████╗██╗ ██╗ █████╗ ███╗ ██╗ ██╗ ██╗ ██████╗ ██╗ ██╗
63 |
64 |
65 | ╚══██╔══╝██║ ██║██╔══██╗████╗ ██║ ╚██╗ ██╔╝██╔═══██╗██║ ██║
66 |
67 |
68 | ██║ ███████║███████║██╔██╗ ██║ ╚████╔╝ ██║ ██║██║ ██║
69 |
70 |
71 | ██║ ██╔══██║██╔══██║██║╚██╗██║ ╚██╔╝ ██║ ██║██║ ██║
72 |
73 |
74 | ██║ ██║ ██║██║ ██║██║ ╚████║ ██║ ╚██████╔╝╚██████╔╝
75 |
76 |
77 | ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═╝ ╚═════╝ ╚═════╝
78 |
79 |
80 |
81 |
84 | Last login:
85 | Wed, Jun 12, 20:41:30
86 | on ttys002
87 |
88 |
91 |
94 | user@localhost
95 |
96 |
99 | :
100 |
101 |
104 | ~
105 |
106 |
109 | $
110 |
111 |
114 | npm run dev
115 |
116 |
117 |
118 |
119 | `;
120 |
--------------------------------------------------------------------------------
/src/assets/react.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/BouncyCat/BouncyCat.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useRef, useCallback } from 'react';
2 |
3 | interface BouncyCatProps {
4 | id: string;
5 | onRemove?: (id: string) => void;
6 | totalCatCount?: number;
7 | }
8 |
9 | interface Position {
10 | x: number;
11 | y: number;
12 | }
13 |
14 | interface Velocity {
15 | x: number;
16 | y: number;
17 | }
18 |
19 | export function BouncyCat({ totalCatCount = 1 }: BouncyCatProps) {
20 | const [position, setPosition] = useState(() => ({
21 | x: Math.random() * (window.innerWidth - 100),
22 | y: Math.random() * (window.innerHeight - 100),
23 | }));
24 |
25 | const [velocity, setVelocity] = useState(() => ({
26 | x: (Math.random() - 0.5) * 4 + 2, // Random speed between -2 and 4, biased toward positive
27 | y: (Math.random() - 0.5) * 4,
28 | }));
29 |
30 | const animationRef = useRef();
31 | const lastUpdateRef = useRef(0);
32 | const catSize = 64; // Assumed cat GIF size
33 |
34 | // Throttle animation updates to reduce CPU usage - more throttling with more cats
35 | const animate = useCallback((timestamp: number) => {
36 | // Dynamic throttling based on cat count: more cats = lower frame rate
37 | const throttleTime = totalCatCount > 50 ? 50 : totalCatCount > 20 ? 33 : 16;
38 | if (timestamp - lastUpdateRef.current < throttleTime) {
39 | animationRef.current = requestAnimationFrame(animate);
40 | return;
41 | }
42 | lastUpdateRef.current = timestamp;
43 |
44 | setPosition(prevPosition => {
45 | const newPosition = { ...prevPosition };
46 |
47 | // Update position
48 | newPosition.x += velocity.x;
49 | newPosition.y += velocity.y;
50 |
51 | // Bounce off walls
52 | const newVelocity = { ...velocity };
53 |
54 | // Left and right walls
55 | if (newPosition.x <= 0 || newPosition.x >= window.innerWidth - catSize) {
56 | newVelocity.x = -newVelocity.x;
57 | newPosition.x = Math.max(0, Math.min(newPosition.x, window.innerWidth - catSize));
58 | }
59 |
60 | // Top and bottom walls
61 | if (newPosition.y <= 0 || newPosition.y >= window.innerHeight - catSize) {
62 | newVelocity.y = -newVelocity.y;
63 | newPosition.y = Math.max(0, Math.min(newPosition.y, window.innerHeight - catSize));
64 | }
65 |
66 | // Update velocity if it changed
67 | if (newVelocity.x !== velocity.x || newVelocity.y !== velocity.y) {
68 | setVelocity(newVelocity);
69 | }
70 |
71 | return newPosition;
72 | });
73 |
74 | animationRef.current = requestAnimationFrame(animate);
75 | }, [velocity, totalCatCount]);
76 |
77 | useEffect(() => {
78 | animationRef.current = requestAnimationFrame(animate);
79 |
80 | return () => {
81 | if (animationRef.current) {
82 | cancelAnimationFrame(animationRef.current);
83 | }
84 | };
85 | }, [animate]);
86 |
87 | // Handle window resize
88 | useEffect(() => {
89 | const handleResize = () => {
90 | setPosition(prevPosition => ({
91 | x: Math.min(prevPosition.x, window.innerWidth - catSize),
92 | y: Math.min(prevPosition.y, window.innerHeight - catSize),
93 | }));
94 | };
95 |
96 | window.addEventListener('resize', handleResize);
97 | return () => window.removeEventListener('resize', handleResize);
98 | }, []);
99 |
100 | return (
101 |
111 |

120 |
121 | );
122 | }
--------------------------------------------------------------------------------
/src/contexts/AppContext.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react-refresh/only-export-components */
2 | import React,
3 | { createContext,
4 | useState,
5 | useMemo,
6 | useCallback } from 'react';
7 | import type { ReactNode, Dispatch, SetStateAction } from 'react';
8 |
9 | // 2.1 Create AppContext.tsx
10 | // - State fields : numWindows (1-100), layout ('uniform'/'scattered'), speed, theme, controlsVisible
11 | // - Default values (numWindows: 1, uniform, 10 chunks/s, dark, true)
12 | // - Bound-checked setter helpers (e.g., for numWindows)
13 |
14 | export type LayoutMode = 'uniform' | 'scattered';
15 | export type ThemeMode = 'dark' | 'light';
16 |
17 | interface AppState {
18 | numWindows: number;
19 | layout: LayoutMode;
20 | speed: number; // 1-20 chunks/s
21 | theme: ThemeMode;
22 | controlsVisible: boolean;
23 | // Future state for text content might go here
24 | // textContent: string | null;
25 | }
26 |
27 | interface AppContextProps extends AppState {
28 | setNumWindows: (num: number | ((prev: number) => number)) => void;
29 | setLayout: Dispatch>;
30 | setSpeed: (speed: number | ((prev: number) => number)) => void;
31 | setTheme: Dispatch>;
32 | setControlsVisible: Dispatch>;
33 | toggleLayout: () => void;
34 | toggleTheme: () => void;
35 | toggleControlsVisible: () => void;
36 | // setTextContent: Dispatch>;
37 | }
38 |
39 | const MIN_WINDOWS = 1;
40 | const MAX_WINDOWS = 100;
41 | const MIN_SPEED = 1;
42 | const MAX_SPEED = 20;
43 |
44 | const defaultState: AppState = {
45 | numWindows: 1,
46 | layout: 'uniform',
47 | speed: 10, // Default 10 chunks/s
48 | theme: 'dark',
49 | controlsVisible: true,
50 | // textContent: null,
51 | };
52 |
53 | export const AppContext = createContext(undefined);
54 |
55 | interface AppProviderProps {
56 | children: ReactNode;
57 | }
58 |
59 | export function AppProvider({ children }: AppProviderProps) {
60 | const [numWindows, setNumWindowsState] = useState(defaultState.numWindows);
61 | const [layout, setLayout] = useState(defaultState.layout);
62 | const [speed, setSpeedState] = useState(defaultState.speed);
63 | const [theme, setTheme] = useState(defaultState.theme);
64 | const [controlsVisible, setControlsVisible] = useState(defaultState.controlsVisible);
65 | // const [textContent, setTextContent] = useState(defaultState.textContent);
66 |
67 | const setNumWindows = useCallback((numOrFn: number | ((prev: number) => number)) => {
68 | setNumWindowsState(prev => {
69 | const newValue = typeof numOrFn === 'function' ? numOrFn(prev) : numOrFn;
70 | return Math.max(MIN_WINDOWS, Math.min(MAX_WINDOWS, newValue));
71 | });
72 | }, []);
73 |
74 | const setSpeed = useCallback((speedOrFn: number | ((prev: number) => number)) => {
75 | setSpeedState(prev => {
76 | const newValue = typeof speedOrFn === 'function' ? speedOrFn(prev) : speedOrFn;
77 | return Math.max(MIN_SPEED, Math.min(MAX_SPEED, newValue));
78 | });
79 | }, []);
80 |
81 | const toggleLayout = useCallback(() => {
82 | setLayout(prevLayout => prevLayout === 'uniform' ? 'scattered' : 'uniform');
83 | }, []);
84 |
85 | const toggleTheme = useCallback(() => {
86 | setTheme(prevTheme => prevTheme === 'dark' ? 'light' : 'dark');
87 | }, []);
88 |
89 | const toggleControlsVisible = useCallback(() => {
90 | setControlsVisible(prev => !prev);
91 | }, []);
92 |
93 |
94 | const contextValue = useMemo(() => ({
95 | numWindows,
96 | layout,
97 | speed,
98 | theme,
99 | controlsVisible,
100 | setNumWindows,
101 | setLayout,
102 | setSpeed,
103 | setTheme,
104 | setControlsVisible,
105 | toggleLayout,
106 | toggleTheme,
107 | toggleControlsVisible,
108 | // textContent,
109 | // setTextContent,
110 | }), [
111 | numWindows, layout, speed, theme, controlsVisible,
112 | setNumWindows, setLayout, setSpeed, setTheme, setControlsVisible,
113 | toggleLayout, toggleTheme, toggleControlsVisible,
114 | // textContent, setTextContent
115 | ]);
116 |
117 | return (
118 |
119 | {children}
120 |
121 | );
122 | }
123 |
124 | // Custom hook for consuming the context
125 | export const useAppContext = (): AppContextProps => {
126 | const context = React.useContext(AppContext);
127 | if (context === undefined) {
128 | throw new Error('useAppContext must be used within an AppProvider');
129 | }
130 | return context;
131 | };
--------------------------------------------------------------------------------
/src/components/ControlsPanel/__tests__/ControlsPanel.test.tsx:
--------------------------------------------------------------------------------
1 | import { describe, it, expect, vi } from 'vitest';
2 | import { render, screen, fireEvent } from '@testing-library/react';
3 | import { ControlsPanel } from '../ControlsPanel';
4 | import { AppProvider } from '../../../contexts/AppContext';
5 | import { ThemeProvider } from '../../../contexts/ThemeContext';
6 |
7 | function renderControlsPanel() {
8 | const mockOnTerminalCountChange = () => {};
9 | const mockOnArrangeTerminals = () => {};
10 |
11 | return render(
12 |
13 |
14 |
19 |
20 |
21 | );
22 | }
23 |
24 | describe('ControlsPanel', () => {
25 | it('should render with default values', () => {
26 | renderControlsPanel();
27 |
28 | expect(screen.getByText('Terminal Controls')).toBeInTheDocument();
29 | expect(screen.getByText('1 terminal')).toBeInTheDocument();
30 | expect(screen.getByText('+')).toBeInTheDocument();
31 | expect(screen.getByText('-')).toBeInTheDocument();
32 | expect(screen.getByText('+10')).toBeInTheDocument();
33 | expect(screen.getByText('-10')).toBeInTheDocument();
34 | expect(screen.getByText('Arrange')).toBeInTheDocument();
35 | expect(screen.getByText(/Theme:/)).toBeInTheDocument();
36 | expect(screen.getByText(/Made with ❤️ by/)).toBeInTheDocument();
37 | expect(screen.getByRole('link', { name: /vibe typer/i })).toBeInTheDocument();
38 | });
39 |
40 | it('should call onTerminalCountChange when + button is clicked', () => {
41 | const mockOnTerminalCountChange = vi.fn();
42 | const mockOnArrangeTerminals = vi.fn();
43 |
44 | render(
45 |
46 |
47 |
52 |
53 |
54 | );
55 |
56 | const incrementButton = screen.getByText('+');
57 | fireEvent.click(incrementButton);
58 |
59 | expect(mockOnTerminalCountChange).toHaveBeenCalledWith(2);
60 | });
61 |
62 | it('should call onTerminalCountChange when +10 button is clicked', () => {
63 | const mockOnTerminalCountChange = vi.fn();
64 | const mockOnArrangeTerminals = vi.fn();
65 |
66 | render(
67 |
68 |
69 |
74 |
75 |
76 | );
77 |
78 | const incrementTenButton = screen.getByText('+10');
79 | fireEvent.click(incrementTenButton);
80 |
81 | expect(mockOnTerminalCountChange).toHaveBeenCalledWith(11);
82 | });
83 |
84 | it('should call onArrangeTerminals when Arrange button is clicked', () => {
85 | const mockOnTerminalCountChange = vi.fn();
86 | const mockOnArrangeTerminals = vi.fn();
87 |
88 | render(
89 |
90 |
91 |
96 |
97 |
98 | );
99 |
100 | const arrangeButton = screen.getByText('Arrange');
101 | fireEvent.click(arrangeButton);
102 |
103 | expect(mockOnArrangeTerminals).toHaveBeenCalled();
104 | });
105 |
106 | it('should disable decrement button when at minimum (1)', () => {
107 | renderControlsPanel();
108 |
109 | const decrementButton = screen.getByText('-');
110 | expect(decrementButton).toBeDisabled();
111 | });
112 |
113 | it('should call onTerminalCountChange when -10 button is clicked', () => {
114 | const mockOnTerminalCountChange = vi.fn();
115 | const mockOnArrangeTerminals = vi.fn();
116 |
117 | render(
118 |
119 |
120 |
125 |
126 |
127 | );
128 |
129 | const decrementTenButton = screen.getByText('-10');
130 | fireEvent.click(decrementTenButton);
131 |
132 | expect(mockOnTerminalCountChange).toHaveBeenCalledWith(10);
133 | });
134 | });
135 |
--------------------------------------------------------------------------------
/src/components/YouTubeAudioPlayer/YouTubeAudioPlayer.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useRef, useEffect, useCallback } from 'react';
2 |
3 | interface YouTubeVideo {
4 | id: string;
5 | url: string;
6 | title?: string;
7 | }
8 |
9 | interface YouTubeAudioPlayerProps {
10 | videos: YouTubeVideo[];
11 | className?: string;
12 | }
13 |
14 | export const YouTubeAudioPlayer: React.FC = ({
15 | videos,
16 | className = ''
17 | }) => {
18 | const [currentVideoIndex, setCurrentVideoIndex] = useState(0);
19 | const [isPlaying, setIsPlaying] = useState(false);
20 | const [isMuted, setIsMuted] = useState(false);
21 | const iframeRef = useRef(null);
22 |
23 | const currentVideo = videos[currentVideoIndex];
24 |
25 | const getEmbedUrl = useCallback((videoUrl: string) => {
26 | const videoId = videoUrl.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([^&\n?#]+)/)?.[1];
27 | return `https://www.youtube.com/embed/${videoId}?enablejsapi=1&autoplay=${isPlaying ? 1 : 0}&controls=0&showinfo=0&rel=0&modestbranding=1`;
28 | }, [isPlaying]);
29 |
30 | const togglePlayPause = () => {
31 | if (iframeRef.current && iframeRef.current.contentWindow) {
32 | const command = isPlaying ? 'pauseVideo' : 'playVideo';
33 | iframeRef.current.contentWindow.postMessage(
34 | `{"event":"command","func":"${command}","args":""}`,
35 | '*'
36 | );
37 | setIsPlaying(!isPlaying);
38 | }
39 | };
40 |
41 | const toggleMute = () => {
42 | if (iframeRef.current && iframeRef.current.contentWindow) {
43 | const command = isMuted ? 'unMute' : 'mute';
44 | iframeRef.current.contentWindow.postMessage(
45 | `{"event":"command","func":"${command}","args":""}`,
46 | '*'
47 | );
48 | setIsMuted(!isMuted);
49 | }
50 | };
51 |
52 | const nextVideo = () => {
53 | const nextIndex = (currentVideoIndex + 1) % videos.length;
54 | setCurrentVideoIndex(nextIndex);
55 | setIsPlaying(false);
56 | };
57 |
58 | useEffect(() => {
59 | if (iframeRef.current) {
60 | iframeRef.current.src = getEmbedUrl(currentVideo.url);
61 | }
62 | }, [currentVideoIndex, currentVideo.url, getEmbedUrl]);
63 |
64 | if (!videos.length) {
65 | return No videos available
;
66 | }
67 |
68 | return (
69 |
70 | {/* Hidden iframe for audio playback */}
71 |
78 |
79 | {/* Control buttons */}
80 |
81 | {/* Play/Stop button */}
82 |
98 |
99 | {/* Speaker/Mute button */}
100 |
117 |
118 | {/* Next button */}
119 |
128 |
129 | {/* Current video info */}
130 |
131 |
132 | {currentVideo.title || `Video ${currentVideoIndex + 1} of ${videos.length}`}
133 |
134 |
135 |
136 |
137 | );
138 | };
--------------------------------------------------------------------------------
/planning/todo.md:
--------------------------------------------------------------------------------
1 | Each task is a Markdown checkbox so you can tick them off as you go.
2 | Indentation denotes nesting—finish all subtasks before marking the parent as complete.
3 |
4 | ```markdown
5 | # Cloud Code Meme Terminal Visualizer – TODO Checklist
6 |
7 | ## 0 Project Setup & CI
8 | - [x] 0.1 Initialize Git repository
9 | - [x] 0.2 **Scaffold** Vite + React TS project
10 | - [x] `npm create vite@latest`
11 | - [x] Choose "React + TypeScript" template
12 | - [x] 0.3 Add ESLint (Airbnb) + Prettier
13 | - [x] Create `.eslintrc.cjs`
14 | - [x] Create `.prettierrc`
15 | - [x] Add lint scripts to `package.json`
16 | - [x] 0.4 Add Vitest + React-Testing-Library
17 | - [x] Install dev-deps
18 | - [x] Configure `vitest.config.ts`
19 | - [x] Write first dummy test (` renders`)
20 | - [X] 0.5 Add GitHub Actions CI
21 | - [x] `.github/workflows/ci.yml` (install → lint → test → build)
22 | - [X] Verify CI passes on push
23 | - [X] 0.6 Commit + push scaffold
24 |
25 | ---
26 |
27 | ## 1 Styling Baseline
28 | - [x] 1.1 Install Tailwind CSS (`tailwindcss postcss autoprefixer`)
29 | - [x] 1.2 `tailwind.config.js` – set content paths
30 | - [x] 1.3 Add Tailwind directives to `src/index.css`
31 | - [x] 1.4 Verify Tailwind class renders (`text-2xl font-bold`)
32 | - [x] 1.5 Update unit test to assert Tailwind class present
33 |
34 | ---
35 |
36 | ## 2 Global State & Controls (Context)
37 | - [x] 2.1 Create `AppContext.tsx`
38 | - [x] State fields : `numWindows` (1-100), layout ('uniform'/'scattered'), speed, theme, `controlsVisible`
39 | - [x] Default values (`numWindows`: 1, uniform, 10 chunks/s, dark, true)
40 | - [x] Bound-checked setter helpers (e.g., for `numWindows`)
41 | - [x] 2.2 Wrap `` in `AppProvider`
42 | - [x] 2.3 Unit tests: default values, bounds enforcement for `numWindows`
43 |
44 | ### ControlsPanel Skeleton
45 | - [x] 2.4 Build ``
46 | - [x] `numWindows` +/– buttons (displays current count, min 1, max 100)
47 | - [x] Slider 1–20 (speed)
48 | - [x] Toggle: layout (uniform/scattered)
49 | - [x] Toggle: theme (dark/light)
50 | - [x] Hide button (toggles `controlsVisible`)
51 | - [x] 2.5 Bind controls to context
52 | - [x] 2.6 Unit tests: `numWindows` increment/decrement, slider updates speed
53 |
54 | ---
55 |
56 | ## 3 Terminal Window UI
57 | - [x] 3.1 Install `react-draggable` & `react-resizable`
58 | - [x] 3.2 Create ``
59 | - [x] Mac terminal chrome (red/yellow/green)
60 | - [x] Monospace content area
61 | - [x] Blinking cursor placeholder
62 | - [x] Wrap with Draggable + Resizable
63 | - [x] Min 200×100px
64 | - [x] Max 1200×800px
65 | - [x] 3.3 Snapshot test of static UI
66 | - [x] 3.4 Clamping tests for resize
67 |
68 | ---
69 |
70 | ## 4 Layout Engine
71 | - [ ] 4.1 Implement `LayoutManager` utility
72 | - [ ] Takes `numWindows` and `layoutMode` as input.
73 | - [ ] For 'uniform' mode:
74 | - [ ] Calculates optimal grid (rows/columns) based on `numWindows`.
75 | - [ ] Returns array of `{ id, left, top, width, height }` in % for each terminal.
76 | - [ ] For 'scattered' mode:
77 | - [ ] Generates random `{ left, top }` for each terminal.
78 | - [ ] Unit test uniform layout for various `numWindows` (e.g., 1, 4, 5 windows).
79 | - [ ] 4.2 Render `numWindows` TerminalWindows positioned by the LayoutManager.
80 | - [ ] 4.3 Uniform layout snap logic: on drag stop, snap to nearest calculated grid cell.
81 | - [ ] 4.4 Scattered layout:
82 | - [ ] Randomize all window positions on toggle to 'scattered' or when `numWindows` changes in 'scattered' mode.
83 | - [ ] No snapping in 'scattered' mode during drag.
84 | - [ ] Unit test: positions differ after toggle to 'scattered'.
85 |
86 | ---
87 |
88 | ## 5 File Loading & Error Handling
89 | - [ ] 5.1 Create ``
90 | - [ ] Hidden file input accept `.txt`
91 | - [ ] Drag-and-drop zone
92 | - [ ] Use FileReader to read text
93 | - [ ] 5.2 Validate MIME starts with `text/`
94 | - [ ] 5.3 Dispatch context action `SET_TEXT`
95 | - [ ] 5.4 Error toasts:
96 | - [ ] Invalid file type
97 | - [ ] Empty file
98 | - [ ] 5.5 Unit tests: happy path & error branches
99 | - [ ] 5.6 Mount FileLoader above ControlsPanel
100 |
101 | ---
102 |
103 | ## 6 Token Animation
104 | - [ ] 6.1 Implement `useTokenStream(text, baseSpeed)`
105 | - [ ] Random start offset
106 | - [ ] Speed = baseSpeed ±15 % jitter
107 | - [ ] Emit 3–4 char chunks (single-char words whole)
108 | - [ ] `done` flag at EOF
109 | - [ ] Hook tests with fake timers
110 | - [ ] 6.2 Integrate hook into TerminalWindow
111 | - [ ] Maintain `lines` state
112 | - [ ] Auto-scroll to bottom on update
113 | - [ ] Draw blinking cursor
114 | - [ ] Halt on `done`
115 | - [ ] 6.3 Test: lines grow, cursor persists, stops at EOF
116 |
117 | ---
118 |
119 | ## 7 Live Controls Wiring
120 | - [ ] 7.1 Speed slider updates interval without recreating hook
121 | - [ ] 7.2 Theme toggle – add `className="dark"` on ``
122 | - [ ] 7.3 Hide/Show ControlsPanel – CSS transition optional
123 |
124 | ---
125 |
126 | ## 8 Edge-Case Tests
127 | - [ ] 8.1 Drag window partially off-screen – allowed
128 | - [ ] 8.2 Attempt resize beyond bounds – clamped
129 | - [ ] 8.3 Spam speed slider – no leaked timers (`jest.spyOn(global, 'setInterval')`)
130 | - [ ] 8.4 Load 0 byte file – error UI
131 |
132 | ---
133 |
134 | ## 9 Smoke Testing & Build
135 | - [ ] 9.1 (Optional) Add Cypress/Playwright smoke test
136 | - [ ] Load sample text
137 | - [ ] Verify a set number of windows (e.g., 5) animate in 'uniform' grid layout.
138 | - [ ] Toggle dark/light, scattered, hide menu, change number of windows.
139 | - [ ] 9.2 `npm run build` – check bundle size
140 | - [ ] 9.3 Update CI to upload build artifacts
141 |
142 | ---
143 |
144 | ## 10 Deployment & Docs
145 | - [ ] 10.1 Add `vercel.json` (or Netlify config)
146 | - [ ] 10.2 Push to hosting provider; confirm live site
147 | - [ ] 10.3 Update `README.md`
148 | - [ ] Usage instructions
149 | - [ ] Local dev commands
150 | - [ ] Licence & credits
151 | - [ ] 10.4 Tag `v1.0.0` release
152 |
153 | ---
154 |
155 | ### Optional Enhancements (Parked)
156 | - [ ] Typing sound effects
157 | - [ ] Multiple text files, one per terminal
158 | - [ ] "Record mode" – hides cursor & mouse
159 | ```
160 |
161 | *Happy building!*
162 |
--------------------------------------------------------------------------------
/planning/spec.md:
--------------------------------------------------------------------------------
1 | Absolutely. Here's a **comprehensive, developer-ready specification** for your "Cloud Code Meme Terminal Visualizer" app, ready to hand off to an engineer. This covers **requirements, architecture, data handling, error handling, and a test plan** in clear detail.
2 |
3 | ---
4 |
5 | ## Cloud Code Meme Terminal Visualizer: Developer Specification
6 |
7 | ### 1. Overview
8 |
9 | A static, client-side web app that simulates a grid (or scattered layout) of Mac-style terminal windows, each animating code from a shared, user-supplied text file. The purpose is to create the illusion that multiple "coding terminals" are running, as a meme/visual gag. Users can control the grid size, layout, animation speed, theme, and window arrangement, and can hide the controls for clean screenshots or recording.
10 |
11 | ---
12 |
13 | ### 2. Architecture
14 |
15 | * **Frontend Only:**
16 |
17 | * No backend; pure static site.
18 | * Framework: **React + TypeScript** (for maintainability, animation, and third-party drag/drop/resize support).
19 | * Styling: **Tailwind CSS** for fast, clean visuals (can substitute with CSS-in-JS if needed).
20 | * State management: Local React state; no global store necessary.
21 | * Asset hosting: Deployed via Vercel/Netlify/S3/static web host.
22 |
23 | ---
24 |
25 | ### 3. Features & Requirements
26 |
27 | #### 3.1. Terminal Windows
28 |
29 | * **Look & Feel:**
30 |
31 | * Each window mimics a Mac terminal:
32 |
33 | * Title bar with red/yellow/green "traffic light" buttons.
34 | * Window border, shadow, and authentic color scheme.
35 | * Dark mode (default) and light mode (toggle).
36 | * Terminal content area with monospace font.
37 | * **Blinking cursor** at end of current line in each terminal.
38 |
39 | * **Behavior:**
40 |
41 | * **Resizable:** User can drag bottom/right corners to resize.
42 |
43 | * **Min size:** 200x100px; **Max size:** 1200x800px (tweak as appropriate).
44 | * **Draggable:** User can drag and move windows anywhere within the container.
45 |
46 | * **Uniform mode:** Windows are arranged in an automatically calculated grid to maximize visibility. Dragging moves/switches windows between these calculated grid slots.
47 | * **Scattered mode:** Random positions at toggle; after that, user can drag freely; no auto-snap-back even if overlapping or off-screen.
48 | * **New lines animate at bottom and scroll up** (terminal-style).
49 | * **No "fancy" extras** (focus glow, jitter, etc.); just realistic terminal visuals and controls.
50 |
51 | #### 3.2. Controls Panel
52 |
53 | * **Number of Windows:**
54 | * Plus (+) and minus (-) buttons to control the number of terminal windows.
55 | * Displays the current number of windows.
56 | * Min: 1, Max: 100.
57 | * Dynamically adjusts the number of terminals displayed.
58 |
59 | * **Layout Mode:**
60 |
61 | * Toggle (checkbox or switch):
62 |
63 | * "Uniform" = terminals are arranged in an automatically calculated grid for optimal viewing.
64 | * "Scattered" = random positions (re-randomized each toggle).
65 |
66 | * **Speed:**
67 |
68 | * Slider: 1–20 "chunks" per second.
69 | * **Each terminal receives a randomized speed** based on this global threshold (add ±15% random jitter per terminal on initialization and on slider update).
70 |
71 | * **Theme:**
72 |
73 | * Toggle: Dark/Light mode.
74 | * Affects terminals, background, and control panel.
75 |
76 | * **Hide Menu:**
77 |
78 | * Button to hide/show the controls panel for screenshots or screencasts.
79 | * Hidden menu does not remove interactivity—just hides the controls visually.
80 |
81 | #### 3.3. Code Animation
82 |
83 | * **Text Source:**
84 |
85 | * A single user-supplied text file (loaded client-side via file input, or can be hardcoded for demo purposes).
86 | * File is read once on page load; **no backend or uploads**.
87 | * Each terminal starts at a random offset within the file and animates forward linearly.
88 |
89 | * **Animation Logic:**
90 |
91 | * "Token" = chunk of 3–4 characters; single-letter "words" are output in one chunk.
92 | * Animate by revealing code in these chunks, not per character or per whole word.
93 | * When the end of the text file is reached, the terminal window **pauses/halts**; it does not loop or reset.
94 | * **Each terminal animates independently:**
95 |
96 | * Randomized starting offset in text.
97 | * Randomized speed (within ±15% of slider value).
98 | * Animation ticks are not synced between terminals.
99 |
100 | ---
101 |
102 | ### 4. Data Handling
103 |
104 | * **Text File Loading:**
105 |
106 | * Accept a `.txt` file via file input (or drag & drop).
107 | * On load, parse into an array of characters for efficient chunking.
108 | * If file fails to load, show a friendly error/toast and disable terminals.
109 | * Easter eggs are supported; user can include any strings they like in the text.
110 |
111 | ---
112 |
113 | ### 5. Error Handling
114 |
115 | * **File Load Error:**
116 |
117 | * User is notified via toast/banner; terminals show "No data loaded" message.
118 | * **Invalid File Type:**
119 |
120 | * Reject with user-facing message.
121 | * **Empty File:**
122 |
123 | * Show message, do not animate terminals.
124 | * **Terminal Drag/Resize Edge Cases:**
125 |
126 | * Prevent dragging completely out of the container (but allow partial overlap).
127 | * Prevent resizing below min or above max.
128 |
129 | ---
130 |
131 | ### 6. Responsive Design
132 |
133 | * **Mobile:**
134 |
135 | * Minimal support; shrink terminal size for small viewports.
136 | * No special stacking or mobile optimizations.
137 | * Controls panel remains usable but not optimized for touch.
138 |
139 | ---
140 |
141 | ### 7. Testing Plan
142 |
143 | **Manual QA:**
144 |
145 | * **File Loading:**
146 |
147 | * Load with a large (100KB+) `.txt` file and verify smooth operation.
148 | * Load with empty or non-text file; verify error message.
149 |
150 | * **Animation:**
151 |
152 | * Check token animation logic (chunk size 3–4, except single-letter words).
153 | * Verify all terminals animate at slightly different speeds.
154 | * Adjust speed slider and observe real-time speed change.
155 |
156 | * **Controls:**
157 |
158 | * Increase/decrease the number of windows using +/- buttons and confirm terminals are added/removed.
159 | * In "Uniform" mode, verify the grid re-arranges dynamically to best fit the current number of windows.
160 | * Toggle between uniform/scattered layouts; ensure terminals arrange into an optimal grid or randomize positions accordingly.
161 | * Toggle dark/light mode and verify UI updates everywhere.
162 | * Hide/show menu and confirm clean screenshot/video capability.
163 |
164 | * **Window Interactivity:**
165 |
166 | * Drag and resize terminals; ensure min/max respected.
167 | * In uniform mode, terminals snap to the auto-calculated grid positions on drag; in scattered, free-form movement.
168 | * Try dragging out of bounds and resizing to limits.
169 |
170 | * **Edge Cases:**
171 |
172 | * End-of-file: terminal stops animating, cursor blinks at end.
173 | * Rapidly change controls (speed, theme, hide menu) to ensure no crashes.
174 |
175 | ---
176 |
177 | ### 8. Tech Recommendations
178 |
179 | * **React** for composable UI and state management.
180 | * **Tailwind CSS** for styling and dark/light theming.
181 | * **React-Draggable** and **React-Resizable** for window management.
182 | * **FileReader API** for text file handling.
183 | * All logic runs in the browser; zero backend.
184 |
185 | ---
186 |
187 | ### 9. Optional (Not Required Now, But Documented)
188 |
189 | * Add sound effects (typing sounds) for realism.
190 | * Support for multiple text files, random file per terminal.
191 | * "Record" mode: auto-hides mouse/cursor during animation.
192 |
193 | ---
194 |
195 | **That's the full spec.**
196 | If you want this as a Markdown file or formatted for project management (like Notion/Jira), just say the word. Anything else you want to clarify or add?
197 |
--------------------------------------------------------------------------------
/src/components/TerminalWindow/TerminalWindow.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useRef } from 'react';
2 | import Draggable from 'react-draggable';
3 | import { ResizableBox } from 'react-resizable';
4 | import './TerminalWindow.css';
5 | import { useTypewriter } from '../../hooks/useTypewriter';
6 | import { terminalOutputs } from '../../data/terminalOutputs';
7 | import { useTheme } from '../../contexts/ThemeContext';
8 | import { generateMultiLineAsciiArt } from '../../utils/asciiArt';
9 |
10 | interface TerminalWindowProps {
11 | id: string;
12 | initialPosition?: { x: number; y: number };
13 | initialSize?: { width?: number; height?: number };
14 | title?: string;
15 | onClose?: () => void;
16 | onPositionChange?: (id: string, position: { x: number; y: number }) => void;
17 | zIndex?: number;
18 | onFocus?: (id: string) => void;
19 | totalTerminalCount?: number;
20 | }
21 |
22 | const DEFAULT_WIDTH = 650;
23 | const DEFAULT_HEIGHT = 450;
24 |
25 | export function TerminalWindow({
26 | id,
27 | initialPosition = { x: 0, y: 0 },
28 | initialSize = {},
29 | title = 'Terminal',
30 | onClose,
31 | onPositionChange,
32 | zIndex = 10,
33 | onFocus,
34 | totalTerminalCount = 1
35 | }: TerminalWindowProps) {
36 | const { currentTheme, getColorForRole } = useTheme();
37 | const nodeRef = useRef(null);
38 |
39 | const [size, setSize] = useState({
40 | width: initialSize.width ?? DEFAULT_WIDTH,
41 | height: initialSize.height ?? DEFAULT_HEIGHT,
42 | });
43 |
44 | // Static login time - calculated once when terminal is created
45 | // Use fixed date during testing for snapshot consistency
46 | const [loginTime] = useState(() => {
47 | // Check if we're in a test environment by looking for testing-specific props or environment
48 | const isTestEnv = id.includes('test');
49 | return isTestEnv
50 | ? new Date('2024-06-12T20:41:30.000Z') // Fixed test date
51 | : new Date(Date.now() - 12 * 60 * 60 * 1000);
52 | });
53 |
54 | // Variable speed per terminal (±25% of base speed) - slightly slower when many terminals
55 | const [terminalSpeed] = useState(() => {
56 | const baseSpeed = totalTerminalCount > 100 ? 80 : 50; // Slightly slower for very many terminals
57 | const variation = 0.55; // ±25%
58 | const randomFactor = 1 + (Math.random() - 0.5) * 2 * variation; // Random between 0.75 and 1.25
59 | return Math.round(baseSpeed * randomFactor);
60 | });
61 |
62 | // Keep animations enabled but clean them up properly when component unmounts
63 | const shouldAnimate = true;
64 |
65 | // Terminal output animation
66 | // const outputTypes = ['development', 'build', 'error', 'conversation', 'troubleshooting', 'epic'] as const;
67 | const outputTypes = ['conversation', 'troubleshooting', 'epic'] as const;
68 | const terminalIndex = parseInt(id.split('-')[1]) || 0;
69 | const outputType = outputTypes[terminalIndex % outputTypes.length];
70 | const { displayedLines, isTyping } = useTypewriter({
71 | lines: terminalOutputs[outputType] || [],
72 | speed: terminalSpeed, // Use variable speed per terminal
73 | enabled: shouldAnimate,
74 | loop: shouldAnimate,
75 | loopDelay: 3000 // 3 seconds before restarting
76 | });
77 |
78 | // Auto-scroll to bottom when new content is added
79 | const contentRef = useRef(null);
80 | useEffect(() => {
81 | if (contentRef.current) {
82 | contentRef.current.scrollTop = contentRef.current.scrollHeight;
83 | }
84 | }, [displayedLines]);
85 |
86 | // console.log(`Terminal ${id} using position:`, initialPosition);
87 |
88 | return (
89 | }
91 | handle=".terminal-title-bar"
92 | position={initialPosition}
93 | cancel=".no-drag, .react-resizable-handle"
94 | bounds="parent"
95 | onDrag={(_e, data) => {
96 | if (onPositionChange) {
97 | onPositionChange(id, { x: data.x, y: data.y });
98 | }
99 | }}
100 | >
101 | onFocus && onFocus(id)}
105 | >
106 |
{
113 | setSize({ width: data.size.width, height: data.size.height });
114 | }}
115 | >
116 |
120 | {/* Title Bar */}
121 |
onFocus && onFocus(id)}>
123 | {/* Traffic Lights */}
124 |
133 | {/* Title */}
134 |
137 |
138 |
139 | {/* Content Area */}
140 |
onFocus && onFocus(id)}
144 | >
145 | {/* ASCII Art Header */}
146 |
147 |
148 | {generateMultiLineAsciiArt(['I VIBE MORE', 'THAN YOU']).map((line, index) => (
149 |
{line}
150 | ))}
151 |
152 |
153 |
154 |
Last login: {loginTime.toLocaleString('en-US', { weekday: 'short', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZone: 'UTC' })} on ttys002
155 |
156 |
157 | user@localhost
158 | :
159 | ~
160 | $
161 | npm run dev
162 |
163 |
164 | {/* Animated output lines */}
165 | {displayedLines.map((line, index) => {
166 | if (!line) return null;
167 | // Use colorRole if available, fallback to legacy color, then to primary
168 | const colorClass = line.colorRole
169 | ? getColorForRole(line.colorRole)
170 | : line.color || getColorForRole('primary');
171 | return (
172 |
176 | {line.text || '\u00A0'} {/* Non-breaking space for empty lines */}
177 |
178 | );
179 | })}
180 |
181 | {/* Cursor at the end when done typing */}
182 | {!isTyping && (
183 |
184 | user@localhost
185 | :
186 | ~
187 | $
188 |
189 |
190 | )}
191 |
192 |
193 |
194 |
195 |
196 | );
197 | }
--------------------------------------------------------------------------------
/src/utils/asciiArt.ts:
--------------------------------------------------------------------------------
1 | // ASCII Art Generator - Block Style
2 | // Each letter is 6 lines tall and variable width
3 |
4 | const ASCII_LETTERS: Record = {
5 | 'A': [
6 | ' █████╗ ',
7 | '██╔══██╗',
8 | '███████║',
9 | '██╔══██║',
10 | '██║ ██║',
11 | '╚═╝ ╚═╝'
12 | ],
13 | 'B': [
14 | '██████╗ ',
15 | '██╔══██╗',
16 | '██████╔╝',
17 | '██╔══██╗',
18 | '██████╔╝',
19 | '╚═════╝ '
20 | ],
21 | 'C': [
22 | ' ██████╗',
23 | '██╔════╝',
24 | '██║ ',
25 | '██║ ',
26 | '╚██████╗',
27 | ' ╚═════╝'
28 | ],
29 | 'D': [
30 | '██████╗ ',
31 | '██╔══██╗',
32 | '██║ ██║',
33 | '██║ ██║',
34 | '██████╔╝',
35 | '╚═════╝ '
36 | ],
37 | 'E': [
38 | '███████╗',
39 | '██╔════╝',
40 | '█████╗ ',
41 | '██╔══╝ ',
42 | '███████╗',
43 | '╚══════╝'
44 | ],
45 | 'F': [
46 | '███████╗',
47 | '██╔════╝',
48 | '█████╗ ',
49 | '██╔══╝ ',
50 | '██║ ',
51 | '╚═╝ '
52 | ],
53 | 'G': [
54 | ' ██████╗ ',
55 | '██╔════╝ ',
56 | '██║ ███╗',
57 | '██║ ██║',
58 | '╚██████╔╝',
59 | ' ╚═════╝ '
60 | ],
61 | 'H': [
62 | '██╗ ██╗',
63 | '██║ ██║',
64 | '███████║',
65 | '██╔══██║',
66 | '██║ ██║',
67 | '╚═╝ ╚═╝'
68 | ],
69 | 'I': [
70 | '██╗',
71 | '██║',
72 | '██║',
73 | '██║',
74 | '██║',
75 | '╚═╝'
76 | ],
77 | 'J': [
78 | ' ██╗',
79 | ' ██║',
80 | ' ██║',
81 | '██ ██║',
82 | '╚█████╔╝',
83 | ' ╚════╝ '
84 | ],
85 | 'K': [
86 | '██╗ ██╗',
87 | '██║ ██╔╝',
88 | '█████╔╝ ',
89 | '██╔═██╗ ',
90 | '██║ ██╗',
91 | '╚═╝ ╚═╝'
92 | ],
93 | 'L': [
94 | '██╗ ',
95 | '██║ ',
96 | '██║ ',
97 | '██║ ',
98 | '███████╗',
99 | '╚══════╝'
100 | ],
101 | 'M': [
102 | '███╗ ███╗',
103 | '████╗ ████║',
104 | '██╔████╔██║',
105 | '██║╚██╔╝██║',
106 | '██║ ╚═╝ ██║',
107 | '╚═╝ ╚═╝'
108 | ],
109 | 'N': [
110 | '███╗ ██╗',
111 | '████╗ ██║',
112 | '██╔██╗ ██║',
113 | '██║╚██╗██║',
114 | '██║ ╚████║',
115 | '╚═╝ ╚═══╝'
116 | ],
117 | 'O': [
118 | ' ██████╗ ',
119 | '██╔═══██╗',
120 | '██║ ██║',
121 | '██║ ██║',
122 | '╚██████╔╝',
123 | ' ╚═════╝ '
124 | ],
125 | 'P': [
126 | '██████╗ ',
127 | '██╔══██╗',
128 | '██████╔╝',
129 | '██╔═══╝ ',
130 | '██║ ',
131 | '╚═╝ '
132 | ],
133 | 'Q': [
134 | ' ██████╗ ',
135 | '██╔═══██╗',
136 | '██║ ██║',
137 | '██║▄▄ ██║',
138 | '╚██████╔╝',
139 | ' ╚══▀▀═╝ '
140 | ],
141 | 'R': [
142 | '██████╗ ',
143 | '██╔══██╗',
144 | '██████╔╝',
145 | '██╔══██╗',
146 | '██║ ██║',
147 | '╚═╝ ╚═╝'
148 | ],
149 | 'S': [
150 | '███████╗',
151 | '██╔════╝',
152 | '███████╗',
153 | '╚════██║',
154 | '███████║',
155 | '╚══════╝'
156 | ],
157 | 'T': [
158 | '████████╗',
159 | '╚══██╔══╝',
160 | ' ██║ ',
161 | ' ██║ ',
162 | ' ██║ ',
163 | ' ╚═╝ '
164 | ],
165 | 'U': [
166 | '██╗ ██╗',
167 | '██║ ██║',
168 | '██║ ██║',
169 | '██║ ██║',
170 | '╚██████╔╝',
171 | ' ╚═════╝ '
172 | ],
173 | 'V': [
174 | '██╗ ██╗',
175 | '██║ ██║',
176 | '██║ ██║',
177 | '╚██╗ ██╔╝',
178 | ' ╚████╔╝ ',
179 | ' ╚═══╝ '
180 | ],
181 | 'W': [
182 | '██╗ ██╗',
183 | '██║ ██║',
184 | '██║ █╗ ██║',
185 | '██║███╗██║',
186 | '╚███╔███╔╝',
187 | ' ╚══╝╚══╝ '
188 | ],
189 | 'X': [
190 | '██╗ ██╗',
191 | '╚██╗██╔╝',
192 | ' ╚███╔╝ ',
193 | ' ██╔██╗ ',
194 | '██╔╝ ██╗',
195 | '╚═╝ ╚═╝'
196 | ],
197 | 'Y': [
198 | '██╗ ██╗',
199 | '╚██╗ ██╔╝',
200 | ' ╚████╔╝ ',
201 | ' ╚██╔╝ ',
202 | ' ██║ ',
203 | ' ╚═╝ '
204 | ],
205 | 'Z': [
206 | '███████╗',
207 | '╚══███╔╝',
208 | ' ███╔╝ ',
209 | ' ███╔╝ ',
210 | '███████╗',
211 | '╚══════╝'
212 | ],
213 | ' ': [
214 | ' ',
215 | ' ',
216 | ' ',
217 | ' ',
218 | ' ',
219 | ' '
220 | ],
221 | // Numbers
222 | '0': [
223 | ' ██████╗ ',
224 | '██╔═══██╗',
225 | '██║ ██║',
226 | '██║ ██║',
227 | '╚██████╔╝',
228 | ' ╚═════╝ '
229 | ],
230 | '1': [
231 | ' ██╗',
232 | '███║',
233 | '╚██║',
234 | ' ██║',
235 | ' ██║',
236 | ' ╚═╝'
237 | ],
238 | '2': [
239 | '██████╗ ',
240 | '╚════██╗',
241 | ' █████╔╝',
242 | '██╔═══╝ ',
243 | '███████╗',
244 | '╚══════╝'
245 | ],
246 | '3': [
247 | '██████╗ ',
248 | '╚════██╗',
249 | ' █████╔╝',
250 | ' ╚═══██╗',
251 | '██████╔╝',
252 | '╚═════╝ '
253 | ],
254 | '4': [
255 | '██╗ ██╗',
256 | '██║ ██║',
257 | '███████║',
258 | '╚════██║',
259 | ' ██║',
260 | ' ╚═╝'
261 | ],
262 | '5': [
263 | '███████╗',
264 | '██╔════╝',
265 | '███████╗',
266 | '╚════██║',
267 | '███████║',
268 | '╚══════╝'
269 | ],
270 | '6': [
271 | ' ██████╗ ',
272 | '██╔════╝ ',
273 | '██████╗ ',
274 | '██╔══██╗ ',
275 | '╚██████╔╝',
276 | ' ╚═════╝ '
277 | ],
278 | '7': [
279 | '███████╗',
280 | '╚══════╗',
281 | ' ██║',
282 | ' ██╔╝',
283 | ' ██╔╝ ',
284 | ' ╚═╝ '
285 | ],
286 | '8': [
287 | ' █████╗ ',
288 | '██╔══██╗',
289 | '╚█████╔╝',
290 | '██╔══██╗',
291 | '╚█████╔╝',
292 | ' ╚════╝ '
293 | ],
294 | '9': [
295 | ' █████╗ ',
296 | '██╔══██╗',
297 | '╚██████║',
298 | ' ╚═══██║',
299 | ' █████╔╝',
300 | ' ╚════╝ '
301 | ],
302 | // Punctuation
303 | '*': [
304 | ' ',
305 | ' ██ ██',
306 | ' ███ ',
307 | ' ██ ██',
308 | ' ',
309 | ' '
310 | ],
311 | '(': [
312 | ' ██╗',
313 | ' ██╔╝',
314 | '██╔╝ ',
315 | '██╚╗ ',
316 | ' ██╚╗',
317 | ' ╚═╝'
318 | ],
319 | ')': [
320 | '██╗ ',
321 | '╚██╗ ',
322 | ' ╚██╗',
323 | ' ██╔╝',
324 | '██╔╝ ',
325 | '╚═╝ '
326 | ],
327 | '~': [
328 | ' ',
329 | ' ',
330 | ' ███╗ ',
331 | '██╔██╗',
332 | '╚═╝██║',
333 | ' ╚═╝'
334 | ],
335 | '!': [
336 | '██╗',
337 | '██║',
338 | '██║',
339 | '╚═╝',
340 | '██╗',
341 | '╚═╝'
342 | ],
343 | '?': [
344 | '██████╗ ',
345 | '╚════██╗',
346 | ' ███╔═╝',
347 | ' ╚══╝ ',
348 | ' ██╗ ',
349 | ' ╚═╝ '
350 | ],
351 | '.': [
352 | ' ',
353 | ' ',
354 | ' ',
355 | ' ',
356 | '██╗',
357 | '╚═╝'
358 | ],
359 | ',': [
360 | ' ',
361 | ' ',
362 | ' ',
363 | '██╗',
364 | '╚██╗',
365 | ' ╚═╝'
366 | ],
367 | ';': [
368 | ' ',
369 | '██╗',
370 | '╚═╝',
371 | '██╗',
372 | '╚██╗',
373 | ' ╚═╝'
374 | ],
375 | ':': [
376 | ' ',
377 | '██╗',
378 | '╚═╝',
379 | '██╗',
380 | '╚═╝',
381 | ' '
382 | ],
383 | '-': [
384 | ' ',
385 | ' ',
386 | '█████╗',
387 | '╚════╝',
388 | ' ',
389 | ' '
390 | ],
391 | '+': [
392 | ' ',
393 | ' ██╗ ',
394 | '█████╗',
395 | ' ██╔╝',
396 | ' ╚═╝ ',
397 | ' '
398 | ],
399 | '=': [
400 | ' ',
401 | '█████╗',
402 | '╚════╝',
403 | '█████╗',
404 | '╚════╝',
405 | ' '
406 | ],
407 | '_': [
408 | ' ',
409 | ' ',
410 | ' ',
411 | ' ',
412 | '██████',
413 | '╚═════'
414 | ],
415 | '@': [
416 | ' ██████╗ ',
417 | '██╔═══██╗',
418 | '██║██╗██║',
419 | '██║██║██║',
420 | '╚██████╔╝',
421 | ' ╚═════╝ '
422 | ],
423 | '#': [
424 | ' ██╗ ██╗ ',
425 | '███████╗ ',
426 | ' ██╔██╔╝ ',
427 | '███████╗ ',
428 | ' ██╔██╔╝ ',
429 | ' ╚═╝╚═╝ '
430 | ],
431 | '$': [
432 | ' ██████╗',
433 | '██╔══██╗',
434 | '███████╗',
435 | '╚════██║',
436 | '██████╔╝',
437 | ' ╚═██═╝ '
438 | ],
439 | '%': [
440 | '██╗ ██╗',
441 | '╚═╝██╔╝',
442 | ' ██╔╝ ',
443 | ' ██╔╝██╗',
444 | '██╔╝ ██║',
445 | '╚═╝ ╚═╝'
446 | ],
447 | '&': [
448 | ' ██████╗ ',
449 | '██╔═══██╗',
450 | '███████╔╝',
451 | '██╔═══╗ ',
452 | '██║ ██╗',
453 | '╚██████╔╝'
454 | ]
455 | };
456 |
457 | /**
458 | * Generates ASCII art for a given text string
459 | * @param text - The text to convert to ASCII art
460 | * @returns Array of strings, each representing a line of ASCII art
461 | */
462 | export function generateAsciiArt(text: string): string[] {
463 | const upperText = text.toUpperCase();
464 | const lines: string[] = ['', '', '', '', '', ''];
465 |
466 | for (const char of upperText) {
467 | const letterArt = ASCII_LETTERS[char];
468 | if (letterArt) {
469 | for (let i = 0; i < 6; i++) {
470 | lines[i] += letterArt[i];
471 | }
472 | } else {
473 | // Fallback for unsupported characters
474 | for (let i = 0; i < 6; i++) {
475 | lines[i] += ' ';
476 | }
477 | }
478 | }
479 |
480 | return lines;
481 | }
482 |
483 | /**
484 | * Generates multi-line ASCII art
485 | * @param lines - Array of text lines to convert
486 | * @returns Array of strings representing the complete ASCII art
487 | */
488 | export function generateMultiLineAsciiArt(lines: string[]): string[] {
489 | const result: string[] = [];
490 |
491 | for (const line of lines) {
492 | const asciiLines = generateAsciiArt(line);
493 | result.push(...asciiLines);
494 | // Add empty line between text lines (except for the last one)
495 | if (line !== lines[lines.length - 1]) {
496 | result.push('');
497 | }
498 | }
499 |
500 | return result;
501 | }
--------------------------------------------------------------------------------
/src/components/ControlsPanel/ControlsPanel.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { useTheme } from '../../contexts/ThemeContext';
3 | import { SimpleYouTubePlayer } from '../YouTubeAudioPlayer/SimpleYouTubePlayer';
4 |
5 | interface ControlsPanelProps {
6 | terminalCount: number;
7 | onTerminalCountChange: (count: number) => void;
8 | onArrangeTerminals?: () => void;
9 | minTerminals?: number;
10 | maxTerminals?: number;
11 | catCount?: number;
12 | onRemoveAllCats?: () => void;
13 | }
14 |
15 | export function ControlsPanel({
16 | terminalCount,
17 | onTerminalCountChange,
18 | onArrangeTerminals,
19 | minTerminals = 1,
20 | maxTerminals = 10000,
21 | catCount = 0,
22 | onRemoveAllCats
23 | }: ControlsPanelProps) {
24 | const [isVisible, setIsVisible] = useState(true);
25 | const { themeName, setTheme, getThemeNames } = useTheme();
26 |
27 | // YouTube videos for the audio player
28 | const youtubeVideos = [
29 | {
30 | id: 'jfKfPfyJRdk',
31 | url: 'https://www.youtube.com/watch?v=jfKfPfyJRdk',
32 | title: 'lofi hip hop radio 📚 - beats to relax/study to'
33 | },
34 | {
35 | id: 'example2',
36 | url: 'https://www.youtube.com/watch?v=5qap5aO4i9A',
37 | title: 'lofi hip hop radio 🎵'
38 | }
39 | ];
40 |
41 | const handleIncrement = () => {
42 | if (terminalCount < maxTerminals) {
43 | onTerminalCountChange(terminalCount + 1);
44 | }
45 | };
46 |
47 | const handleIncrementByTen = () => {
48 | if (terminalCount < maxTerminals) {
49 | const newCount = Math.min(terminalCount + 10, maxTerminals);
50 | onTerminalCountChange(newCount);
51 | }
52 | };
53 |
54 | const handleDecrement = () => {
55 | if (terminalCount > minTerminals) {
56 | onTerminalCountChange(terminalCount - 1);
57 | }
58 | };
59 |
60 | const handleDecrementByTen = () => {
61 | if (terminalCount > minTerminals) {
62 | const newCount = Math.max(terminalCount - 10, minTerminals);
63 | onTerminalCountChange(newCount);
64 | }
65 | };
66 |
67 | const toggleVisibility = () => {
68 | setIsVisible(!isVisible);
69 | };
70 |
71 | const handleThemeToggle = () => {
72 | const themes = getThemeNames();
73 | const currentIndex = themes.indexOf(themeName);
74 | const nextIndex = (currentIndex + 1) % themes.length;
75 | setTheme(themes[nextIndex]);
76 | };
77 |
78 | return (
79 | <>
80 | {/* Main Controls Panel */}
81 |
87 |
88 | {/* Header */}
89 |
90 |
Terminal Controls
91 |
101 |
102 |
103 | {/* Terminal Count Control */}
104 |
105 |
114 |
115 |
124 |
125 |
126 | {terminalCount} terminal{terminalCount !== 1 ? 's' : ''}
127 |
128 |
129 |
138 |
139 |
148 |
149 |
150 | {/* Arrange Button */}
151 | {onArrangeTerminals && (
152 |
160 | )}
161 |
162 | {/* Theme Toggle Button */}
163 |
171 |
172 | {/* Cat Controls (only show if cats exist) */}
173 | {catCount > 0 && onRemoveAllCats && (
174 |
175 |
176 |
177 | 🐱 {catCount} vibe cat{catCount !== 1 ? 's' : ''}
178 |
179 |
180 |
188 |
189 | )}
190 |
191 | {/* YouTube Audio Player */}
192 |
193 |
194 |
195 |
196 | {/* Footer */}
197 |
226 |
227 |
228 |
229 | {/* Show Controls Button (appears when panel is hidden) */}
230 | {!isVisible && (
231 |
241 | )}
242 | >
243 | );
244 | }
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import './App.css';
2 | import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
3 | import { AppProvider } from './contexts/AppContext';
4 | import { ThemeProvider } from './contexts/ThemeContext';
5 | import { TerminalWindow } from './components/TerminalWindow/TerminalWindow';
6 | import { ControlsPanel } from './components/ControlsPanel/ControlsPanel';
7 | import { BouncyCat } from './components/BouncyCat/BouncyCat';
8 | import { AsciiTyper } from './pages/AsciiTyper';
9 | import { useState } from 'react';
10 | import { Analytics } from "@vercel/analytics/react"
11 |
12 | interface Terminal {
13 | id: string;
14 | position: { x: number; y: number };
15 | zIndex: number;
16 | }
17 |
18 | function AppContent() {
19 | // Generate random position for a new terminal within safe bounds
20 | const generateRandomPosition = () => {
21 | const padding = 100; // Extra padding to avoid edge cases
22 | const maxX = Math.max(50, window.innerWidth - 200 - padding);
23 | const maxY = Math.max(50, window.innerHeight - 150 - padding);
24 |
25 | const position = {
26 | x: Math.floor(Math.random() * maxX),
27 | y: Math.floor(Math.random() * maxY),
28 | };
29 |
30 | return position;
31 | };
32 |
33 | // Hold all terminals directly in state so changes trigger re-renders
34 | const [highestZIndex, setHighestZIndex] = useState(1);
35 |
36 | // Separate actual count from rendered terminals for performance
37 | const [actualTerminalCount, setActualTerminalCount] = useState(1);
38 | const [nextTerminalId, setNextTerminalId] = useState(1);
39 |
40 | // Keep only the most recent N terminals rendered (circular buffer approach)
41 | // Reduce rendered terminals when performance is struggling
42 | const maxRenderedTerminals = actualTerminalCount > 1000 ? 40 : 80;
43 |
44 | const [terminals, setTerminals] = useState(() => [
45 | {
46 | id: 'terminal-0',
47 | position: generateRandomPosition(),
48 | zIndex: 1,
49 | },
50 | ]);
51 |
52 | // Bouncy cats state
53 | const [cats, setCats] = useState([]);
54 |
55 |
56 | // Use actual count for cats, not rendered count - allow up to 1000 cats
57 | // const expectedCatCount = Math.min(Math.floor(actualTerminalCount / 5), 1000);
58 |
59 | const handleTerminalCountChange = (count: number) => {
60 | // Update the actual terminal count (can go to 10,000+)
61 | setActualTerminalCount(count);
62 |
63 | setTerminals((prev) => {
64 | let updated = [...prev];
65 | let newZ = highestZIndex;
66 | let newNextId = nextTerminalId;
67 |
68 | // Add new terminals if needed
69 | if (count > actualTerminalCount) {
70 | const terminalsToAdd = count - actualTerminalCount;
71 | for (let i = 0; i < terminalsToAdd; i++) {
72 | newZ += 1;
73 | const newTerminal = {
74 | id: `terminal-${newNextId}`,
75 | position: generateRandomPosition(),
76 | zIndex: newZ,
77 | };
78 | updated.push(newTerminal);
79 | newNextId += 1;
80 |
81 | // If we exceed max rendered terminals, remove the oldest ones
82 | if (updated.length > maxRenderedTerminals) {
83 | updated = updated.slice(-maxRenderedTerminals);
84 | }
85 | }
86 | } else if (count < actualTerminalCount) {
87 | // Remove terminals from the end
88 | const terminalsToRemove = actualTerminalCount - count;
89 | updated = updated.slice(0, Math.max(0, updated.length - terminalsToRemove));
90 | }
91 |
92 | setHighestZIndex(newZ);
93 | setNextTerminalId(newNextId);
94 |
95 | // Update cats based on new actual terminal count (max 1000 cats)
96 | const newExpectedCatCount = Math.min(Math.floor(count / 5), 1000);
97 | setCats(prevCats => {
98 | if (newExpectedCatCount > prevCats.length) {
99 | // Add new cats
100 | const newCats = [...prevCats];
101 | for (let i = prevCats.length; i < newExpectedCatCount; i++) {
102 | newCats.push(`cat-${Date.now()}-${i}`);
103 | }
104 | return newCats;
105 | } else if (newExpectedCatCount < prevCats.length) {
106 | // Remove excess cats if terminals are removed
107 | return prevCats.slice(0, newExpectedCatCount);
108 | }
109 | return prevCats;
110 | });
111 |
112 | return updated;
113 | });
114 | };
115 |
116 | const handleTerminalClose = (terminalId: string) => {
117 | // Decrease actual terminal count
118 | const newCount = Math.max(1, actualTerminalCount - 1);
119 | setActualTerminalCount(newCount);
120 |
121 | setTerminals((prev) => {
122 | const updated = prev.filter(terminal => terminal.id !== terminalId);
123 |
124 | // Update cats when terminal count changes (max 1000 cats)
125 | const newExpectedCatCount = Math.min(Math.floor(newCount / 5), 1000);
126 | setCats(prevCats => prevCats.slice(0, newExpectedCatCount));
127 |
128 | return updated;
129 | });
130 | };
131 |
132 | const handleRemoveAllCats = () => {
133 | setCats([]);
134 | };
135 |
136 | const handlePositionChange = (terminalId: string, position: { x: number; y: number }) => {
137 | setTerminals((prev) =>
138 | prev.map((terminal) =>
139 | terminal.id === terminalId
140 | ? { ...terminal, position }
141 | : terminal
142 | )
143 | );
144 | };
145 |
146 | const handleFocus = (terminalId: string) => {
147 | // Cap z-index to prevent conflicts with controls (controls are at 10001)
148 | const newZ = Math.min(highestZIndex + 1, 9999);
149 | setHighestZIndex(newZ);
150 | setTerminals((prev) =>
151 | prev.map((terminal) =>
152 | terminal.id === terminalId ? { ...terminal, zIndex: newZ } : terminal
153 | )
154 | );
155 | };
156 |
157 | const handleArrangeTerminals = () => {
158 | setTerminals((prev) => {
159 | const terminalWidth = 320; // Default terminal width + some padding
160 | const terminalHeight = 280; // Default terminal height + some padding
161 | const padding = 20;
162 | const layerOffset = 15; // Offset between layers
163 |
164 | // Calculate viewport constraints
165 | const availableWidth = window.innerWidth - 200; // Account for controls panel
166 | const availableHeight = window.innerHeight - 100; // Account for some top/bottom padding
167 |
168 | const terminalsPerRow = Math.floor(availableWidth / terminalWidth);
169 | const maxRows = Math.floor(availableHeight / terminalHeight);
170 | const terminalsPerLayer = terminalsPerRow * maxRows;
171 |
172 | return prev.map((terminal, index) => {
173 | // Determine which layer this terminal belongs to
174 | const layer = Math.floor(index / terminalsPerLayer);
175 | const indexInLayer = index % terminalsPerLayer;
176 |
177 | const row = Math.floor(indexInLayer / terminalsPerRow);
178 | const col = indexInLayer % terminalsPerRow;
179 |
180 | // Checkerboard offset - alternate starting positions
181 | const offsetX = (row % 2) * (terminalWidth / 2);
182 |
183 | // Layer offset - each layer is slightly behind and offset
184 | const layerOffsetX = layer * layerOffset;
185 | const layerOffsetY = layer * layerOffset;
186 |
187 | const x = col * terminalWidth + padding + offsetX + layerOffsetX + 150; // Extra offset for controls
188 | const y = row * terminalHeight + padding + layerOffsetY + 50;
189 |
190 | return {
191 | ...terminal,
192 | position: { x, y }
193 | };
194 | });
195 | });
196 | };
197 |
198 | return (
199 |
215 | {/* Controls Panel - positioned absolutely in top-left */}
216 |
223 |
224 | {/* Terminal Container */}
225 |
234 | {terminals.map((terminal, index) => (
235 | handleTerminalClose(terminal.id)}
243 | onPositionChange={handlePositionChange}
244 | totalTerminalCount={actualTerminalCount}
245 | />
246 | ))}
247 |
248 |
249 | {/* Bouncy Cats - render above everything */}
250 | {cats.map((catId) => (
251 |
256 | ))}
257 |
258 | );
259 | }
260 |
261 | function App() {
262 | return (
263 |
264 |
265 |
266 |
267 | } />
268 | } />
269 |
270 |
271 |
272 |
273 |
274 | );
275 | }
276 |
277 | export default App;
278 |
--------------------------------------------------------------------------------
/src/contexts/__tests__/AppContext.test.tsx:
--------------------------------------------------------------------------------
1 | import { describe, it, expect, vi } from 'vitest';
2 | import { render, screen, act } from '@testing-library/react';
3 | import { AppProvider, useAppContext } from '../AppContext';
4 |
5 | // Test component that uses the context
6 | function TestComponent() {
7 | const {
8 | numWindows,
9 | setNumWindows,
10 | speed,
11 | setSpeed,
12 | layout,
13 | toggleLayout,
14 | theme,
15 | toggleTheme,
16 | controlsVisible,
17 | toggleControlsVisible,
18 | } = useAppContext();
19 |
20 | return (
21 |
22 | {numWindows}
23 | {speed}
24 | {layout}
25 | {theme}
26 | {controlsVisible.toString()}
27 |
28 |
57 | );
58 | }
59 |
60 | function renderWithProvider() {
61 | return render(
62 |
63 |
64 |
65 | );
66 | }
67 |
68 | describe('AppContext', () => {
69 | // 2.3 Unit tests: default values, bounds enforcement for numWindows
70 | describe('Default values', () => {
71 | it('should have correct default values', () => {
72 | renderWithProvider();
73 |
74 | expect(screen.getByTestId('numWindows')).toHaveTextContent('1');
75 | expect(screen.getByTestId('speed')).toHaveTextContent('10');
76 | expect(screen.getByTestId('layout')).toHaveTextContent('uniform');
77 | expect(screen.getByTestId('theme')).toHaveTextContent('dark');
78 | expect(screen.getByTestId('controlsVisible')).toHaveTextContent('true');
79 | });
80 | });
81 |
82 | describe('numWindows bounds enforcement', () => {
83 | it('should enforce minimum bound (1)', async () => {
84 | renderWithProvider();
85 |
86 | const decrementButton = screen.getByTestId('decrement-windows');
87 |
88 | // Try to decrement below 1
89 | await act(async () => {
90 | decrementButton.click();
91 | });
92 |
93 | expect(screen.getByTestId('numWindows')).toHaveTextContent('1');
94 | });
95 |
96 | it('should enforce maximum bound (100)', async () => {
97 | renderWithProvider();
98 |
99 | screen.getByTestId('increment-windows');
100 |
101 | // Set to 100 first
102 | await act(async () => {
103 | screen.getByTestId('set-windows-direct').click();
104 | });
105 |
106 | // Manually set to 100 by calling setNumWindows(100)
107 | await act(async () => {
108 | // We need a way to set it to 100. Let's modify our test component
109 | });
110 | });
111 |
112 | it('should clamp values above 100 to 100', async () => {
113 | const TestClampComponent = () => {
114 | const { numWindows, setNumWindows } = useAppContext();
115 | return (
116 |
117 | {numWindows}
118 | setNumWindows(150)}
121 | />
122 |
123 | );
124 | };
125 |
126 | render(
127 |
128 |
129 |
130 | );
131 |
132 | await act(async () => {
133 | screen.getByTestId('set-150').click();
134 | });
135 |
136 | expect(screen.getByTestId('numWindows')).toHaveTextContent('100');
137 | });
138 |
139 | it('should clamp values below 1 to 1', async () => {
140 | const TestClampComponent = () => {
141 | const { numWindows, setNumWindows } = useAppContext();
142 | return (
143 |
144 | {numWindows}
145 | setNumWindows(-5)}
148 | />
149 |
150 | );
151 | };
152 |
153 | render(
154 |
155 |
156 |
157 | );
158 |
159 | await act(async () => {
160 | screen.getByTestId('set-negative').click();
161 | });
162 |
163 | expect(screen.getByTestId('numWindows')).toHaveTextContent('1');
164 | });
165 | });
166 |
167 | // 2.6 Unit tests: numWindows increment/decrement, slider updates speed
168 | describe('numWindows increment/decrement', () => {
169 | it('should increment numWindows', async () => {
170 | renderWithProvider();
171 |
172 | const incrementButton = screen.getByTestId('increment-windows');
173 |
174 | await act(async () => {
175 | incrementButton.click();
176 | });
177 |
178 | expect(screen.getByTestId('numWindows')).toHaveTextContent('2');
179 |
180 | await act(async () => {
181 | incrementButton.click();
182 | });
183 |
184 | expect(screen.getByTestId('numWindows')).toHaveTextContent('3');
185 | });
186 |
187 | it('should decrement numWindows', async () => {
188 | renderWithProvider();
189 |
190 | // First set to 5
191 | await act(async () => {
192 | screen.getByTestId('set-windows-direct').click();
193 | });
194 |
195 | expect(screen.getByTestId('numWindows')).toHaveTextContent('5');
196 |
197 | const decrementButton = screen.getByTestId('decrement-windows');
198 |
199 | await act(async () => {
200 | decrementButton.click();
201 | });
202 |
203 | expect(screen.getByTestId('numWindows')).toHaveTextContent('4');
204 |
205 | await act(async () => {
206 | decrementButton.click();
207 | });
208 |
209 | expect(screen.getByTestId('numWindows')).toHaveTextContent('3');
210 | });
211 | });
212 |
213 | describe('speed updates', () => {
214 | it('should update speed value', async () => {
215 | renderWithProvider();
216 |
217 | expect(screen.getByTestId('speed')).toHaveTextContent('10');
218 |
219 | await act(async () => {
220 | screen.getByTestId('set-speed').click();
221 | });
222 |
223 | expect(screen.getByTestId('speed')).toHaveTextContent('15');
224 | });
225 |
226 | it('should enforce speed bounds (1-20)', async () => {
227 | const TestSpeedBounds = () => {
228 | const { speed, setSpeed } = useAppContext();
229 | return (
230 |
231 | {speed}
232 | setSpeed(25)} />
233 | setSpeed(-5)} />
234 |
235 | );
236 | };
237 |
238 | render(
239 |
240 |
241 |
242 | );
243 |
244 | // Test upper bound
245 | await act(async () => {
246 | screen.getByTestId('set-speed-high').click();
247 | });
248 | expect(screen.getByTestId('speed')).toHaveTextContent('20');
249 |
250 | // Test lower bound
251 | await act(async () => {
252 | screen.getByTestId('set-speed-low').click();
253 | });
254 | expect(screen.getByTestId('speed')).toHaveTextContent('1');
255 | });
256 | });
257 |
258 | describe('toggles', () => {
259 | it('should toggle layout between uniform and scattered', async () => {
260 | renderWithProvider();
261 |
262 | expect(screen.getByTestId('layout')).toHaveTextContent('uniform');
263 |
264 | await act(async () => {
265 | screen.getByTestId('toggle-layout').click();
266 | });
267 |
268 | expect(screen.getByTestId('layout')).toHaveTextContent('scattered');
269 |
270 | await act(async () => {
271 | screen.getByTestId('toggle-layout').click();
272 | });
273 |
274 | expect(screen.getByTestId('layout')).toHaveTextContent('uniform');
275 | });
276 |
277 | it('should toggle theme between dark and light', async () => {
278 | renderWithProvider();
279 |
280 | expect(screen.getByTestId('theme')).toHaveTextContent('dark');
281 |
282 | await act(async () => {
283 | screen.getByTestId('toggle-theme').click();
284 | });
285 |
286 | expect(screen.getByTestId('theme')).toHaveTextContent('light');
287 |
288 | await act(async () => {
289 | screen.getByTestId('toggle-theme').click();
290 | });
291 |
292 | expect(screen.getByTestId('theme')).toHaveTextContent('dark');
293 | });
294 |
295 | it('should toggle controlsVisible', async () => {
296 | renderWithProvider();
297 |
298 | expect(screen.getByTestId('controlsVisible')).toHaveTextContent('true');
299 |
300 | await act(async () => {
301 | screen.getByTestId('toggle-controls').click();
302 | });
303 |
304 | expect(screen.getByTestId('controlsVisible')).toHaveTextContent('false');
305 |
306 | await act(async () => {
307 | screen.getByTestId('toggle-controls').click();
308 | });
309 |
310 | expect(screen.getByTestId('controlsVisible')).toHaveTextContent('true');
311 | });
312 | });
313 |
314 | it('should throw error when useAppContext is used outside provider', () => {
315 | // Suppress console.error for this test since we expect an error
316 | const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
317 |
318 | expect(() => {
319 | render();
320 | }).toThrow('useAppContext must be used within an AppProvider');
321 |
322 | consoleSpy.mockRestore();
323 | });
324 | });
--------------------------------------------------------------------------------
/planning/plan.md:
--------------------------------------------------------------------------------
1 | Below is a **three-stage deliverable**:
2 |
3 | 1. **Blueprint** – a top-down, step-by-step technical roadmap.
4 | 2. **Chunking & Re-chunking** – two passes that progressively slice the work into safe, testable increments.
5 | 3. **LLM Prompts** – a numbered series of *ready-to-paste* prompts for a code-generation model, each fenced in `text` so nothing gets mis-interpreted as code.
6 |
7 | ---
8 |
9 | ## 1. Blueprint – Full Technical Roadmap
10 |
11 | | # | Phase | Key Outputs | Notes |
12 | | - | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
13 | | 0 | **Project Scaffold** | Vite + React + TypeScript repo; Tailwind; ESLint + Prettier; Vitest + React-Testing-Library; GitHub Actions CI | Pure static bundle deployable to Vercel/Netlify |
14 | | 1 | **Core State & Controls** | Global context for `numWindows` (1-100), layout ('uniform'/'scattered'), speed, theme; `` with +/- for `numWindows`, slider (1-20), toggles for theme/layout, hide/show | State lives in React Context; no external store. Default `numWindows` is 1. |
15 | | 2 | **Terminal Shell** | `` component with: Mac chrome, resizable/ draggable wrapper, blinking cursor placeholder | Use `react-draggable` + `react-resizable`; dimensions min 200×100, max 1200×800 |
16 | | 3 | **Layout Manager** | Uniform (auto-calculated grid based on `numWindows` to maximize visibility) & scattered random placement; snap-to-grid logic when dragging in uniform mode | Logic to calculate optimal grid (rows/cols) based on `numWindows` for uniform layout. |
17 | | 4 | **File Loader** | `` drag-and-drop / click input; reads `.txt` via FileReader; pushes char array into global store | Graceful errors (“Invalid file”, “Empty file”) |
18 | | 5 | **Token Animator** | Hook (`useTokenStream`) that: picks random start offset, reveals 3-4 char “chunks” at an interval derived from global speed ±15 % jitter | Exposes next-token callback to TerminalWindow |
19 | | 6 | **Integration** | Connect animator to windows; implement scroll-up behaviour and cursor; stop at EOF | Use virtualized DOM list if perf issues |
20 | | 7 | **Theme & Styling** | Dark & light Tailwind themes; theme toggle; global CSS vars for colours | Tailwind's `dark:` variants |
21 | | 8 | **Hide Menu & Final Polish** | Toggle to collapse controls; ensure no layout shift; smoke tests | Optional: screenshot mode CSS class |
22 | | 9 | **Deployment** | Build & deploy script; CI verifies `npm run build` and runs tests | README with usage instructions |
23 |
24 | ---
25 |
26 | ## 2. Chunking Passes
27 |
28 | ### 2.1 – First-Level Chunks (9)
29 |
30 | 1. **Repo init & CI**
31 | 2. **Tailwind + basic App shell**
32 | 3. **Global Context (`numWindows`) & Controls skeleton**
33 | 4. **TerminalWindow static UI**
34 | 5. **Layout engine (uniform auto-grid & scattered)**
35 | 6. **Drag/resize support**
36 | 7. **File loading & error states**
37 | 8. **Token animation hook**
38 | 9. **Theme toggle, hide menu, cleanup & deploy**
39 |
40 | ### 2.2 – Second-Level Re-chunking (18)
41 |
42 | | Chunk | Micro-Step |
43 | | ----- | ----------------------------------------------------------------- |
44 | | 1A | Init Vite + React TS template |
45 | | 1B | Add ESLint/Prettier configs |
46 | | 1C | Add Vitest + first passing dummy test |
47 | | 1D | GitHub Actions CI workflow |
48 | | 2A | Install Tailwind; configure `tailwind.config.js` |
49 | | 2B | Minimal `` renders "Hello world", Tailwind classes tested |
50 | | 3A | Create `AppContext` (`numWindows`, theme, layout, speed) |
51 | | 3B | Build bare `` with +/- `numWindows` (1-100) |
52 | | 3C | Tests: context defaults (`numWindows`=1), +/- logic (1-100 bounds) |
53 | | 4A | Static TerminalWindow with Mac chrome HTML/CSS |
54 | | 4B | Jest DOM snapshot for window UI |
55 | | 5A | LayoutManager util returning coordinates for uniform (auto-grid from `numWindows`) and scattered layouts |
56 | | 5B | Render `numWindows` instances; unit test coordinate math for uniform layout (e.g., for 1, 2, 3, 4, 5 windows) |
57 | | 6A | Integrate `react-draggable` & `react-resizable` on TerminalWindow |
58 | | 6B | Min/max dim constraints; tests for size clamping |
59 | | 7A | ``: accept text file, FileReader pipeline |
60 | | 7B | Error branch tests (empty, wrong MIME) |
61 | | 8A | `useTokenStream` hook with param: text, speed, jitter |
62 | | 8B | Unit test: given "abcdef", speed =1 chunk/s → emits 3-char tokens |
63 | | 8C | Connect hook to TerminalWindow; implement scroll container |
64 | | 9A | Theme toggle: dark vs light class on `` |
65 | | 9B | Hide/show ControlsPanel; integration tests |
66 | | 9C | Deploy script + README |
67 |
68 | Each micro-step is independently testable and merges cleanly.
69 |
70 | ---
71 |
72 | ## 3. Code-Generation LLM Prompts (TDD-ready)
73 |
74 | Paste one prompt to the model at a time. **Do not skip ahead**—each step relies on the previous commit being green.
75 |
76 | ### Prompt 01 – Repository Scaffold
77 |
78 | ```text
79 | You are ChatGPT-DevOps.
80 | Create a new Git repository initialized with Vite + React + TypeScript.
81 |
82 | **Requirements**
83 | - Vite React TS template
84 | - Package scripts: `dev`, `build`, `test`
85 | - ESLint (airbnb + React) and Prettier
86 | - Vitest + React-Testing-Library
87 | - First dummy test (`App renders hello world`)
88 | - `.editorconfig`
89 |
90 | **Deliverables**
91 | - All project files
92 | - `README.md` with setup commands
93 | - Proof: show `npm test` output (all green)
94 |
95 | Return the full file tree **and** key file contents. Then stop.
96 | ```
97 |
98 | ### Prompt 02 – GitHub Actions CI
99 |
100 | ```text
101 | Add GitHub Actions workflow `.github/workflows/ci.yml`.
102 |
103 | **Workflow**
104 | - Runs on `push` and `pull_request`
105 | - Node 20
106 | - Steps: install deps, lint, test, build
107 |
108 | **Tests**
109 | - Push a commit in the PR containing the workflow and verify all steps succeed (use `--if_present` for lint).
110 |
111 | Return updated files and screenshot of workflow run (or textual log excerpt).
112 | ```
113 |
114 | ### Prompt 03 – Tailwind Setup
115 |
116 | ```text
117 | Install Tailwind CSS and configure it.
118 |
119 | **Tasks**
120 | 1. `npm install -D tailwindcss postcss autoprefixer`
121 | 2. `npx tailwindcss init -p`
122 | 3. Add Tailwind directives to `src/index.css`.
123 | 4. Configure content paths in `tailwind.config.js`.
124 | 5. Replace App markup with `Cloud Code Meme
`.
125 |
126 | **Tests**
127 | - Update test to assert rendered text has class `font-bold`.
128 |
129 | Provide updated files + passing test output.
130 | ```
131 |
132 | ### Prompt 04 – Global App Context
133 |
134 | ```text
135 | Create `src/context/AppContext.tsx`.
136 |
137 | **State**
138 | ```ts
139 | {
140 | numWindows: number; // 1–100
141 | layout: 'uniform' | 'scattered';
142 | speed: number; // 1–20
143 | theme: 'dark' | 'light';
144 | }
145 | ```
146 |
147 | **Features**
148 |
149 | * Default values: `numWindows`: 1, uniform, speed 10, dark.
150 | * Provider + custom hook `useAppContext`.
151 | * Unit tests: default state, bounds checking for setters (`numWindows` 1-100).
152 |
153 | Expose the Provider at `` root. Show updated tests passing.
154 | ```
155 |
156 | ### Prompt 05 – Controls Panel Skeleton
157 | ```text
158 | Build ``.
159 |
160 | **UI (no styling detail yet)**
161 | - +/- buttons for number of windows (label shows current `numWindows`, disable at 1 and 100)
162 | - Slider (1–20) for speed
163 | - Toggle switches for layout, theme
164 | - Hide button (temporarily just `console.log('hide')`)
165 |
166 | State is bound to `AppContext`.
167 |
168 | **Tests**
169 | - Clicking + increases `numWindows` in context until 100 then disables button
170 | - Clicking - decreases `numWindows` in context until 1 then disables button
171 | - Slider value updates speed in context
172 |
173 | Return component, context updates, and passing test logs.
174 | ```
175 |
176 | ### Prompt 06 – TerminalWindow Static UI
177 |
178 | ```text
179 | Create `` with Mac chrome look.
180 |
181 | **Specs**
182 | - Props: `id`, optional style override
183 | - Static text area (no animation yet)
184 | - Mac traffic light buttons via CSS
185 | - Jest DOM snapshot test for UI
186 |
187 | Render a single TerminalWindow in `` for now.
188 |
189 | Return new files, CSS, and snapshot test results.
190 | ```
191 |
192 | ### Prompt 07 – Grid Layout Engine
193 |
194 | ```text
195 | Implement layout logic within a `LayoutManager` or similar utility.
196 |
197 | **Functionality**
198 | - Accept `numWindows` and `layoutMode` ('uniform' | 'scattered').
199 | - For 'uniform' mode:
200 | - Calculate an optimal grid (rows and columns) based on `numWindows` to display all terminals clearly (e.g., for 4 windows, a 2x2 grid; for 5 windows, perhaps a 2x3 or 3x2 arrangement leaving one cell empty or adjusting cell sizes).
201 | - Return an array of coordinate objects `{id, x, y, w, h}` for each terminal (0–100 % for positioning within a container).
202 | - For 'scattered' mode:
203 | - Generate random `{x, y}` coordinates for each terminal within viewport bounds. Width and height can be default or individually stored if allowing resize in scattered mode.
204 |
205 | Render `numWindows` TerminalWindow instances. In 'uniform' mode, position them using the calculated grid coordinates via inline `style={{left, top, width, height}}`.
206 |
207 | **Tests**
208 | - Given `numWindows = 1` (uniform), returns 1 coordinate for a full-container cell.
209 | - Given `numWindows = 4` (uniform), returns 4 coordinates for a 2x2 grid.
210 | - Given `numWindows = 5` (uniform), returns 5 coordinates for a well-distributed layout (e.g., 2 rows, 3 columns on top row, 2 on bottom, or similar).
211 | - snapshot shows correct number of windows positioned.
212 |
213 | Focus on 'uniform' layout calculations first. Scattered can be simpler random positions initially.
214 | ```
215 |
216 | ### Prompt 08 – Drag & Resize Integration
217 |
218 | ```text
219 | Add `react-draggable` and `react-resizable`.
220 |
221 | **Changes**
222 | - Wrap TerminalWindow in Draggable + Resizable.
223 | - Constraints: min 200×100, max 1200×800.
224 | - In 'uniform' layout, dragging a window should attempt to snap it to the nearest available calculated grid cell on `onStop`. This might involve swapping with an existing window or moving to an empty calculated slot.
225 |
226 | **Tests**
227 | - Simulate drag event in 'uniform' mode; assert new position snaps to a calculated grid cell.
228 | - Simulate resize; assert clamped dims.
229 |
230 | Provide updated components and tests.
231 | ```
232 |
233 | ### Prompt 09 – FileLoader Component
234 |
235 | ```text
236 | Create `` with hidden ``.
237 |
238 | **Behaviour**
239 | - On file select/drop: read text via FileReader
240 | - Validate MIME starts with `text/`
241 | - Raise context event `SET_TEXT` with char array
242 | - Error toast on invalid/empty file
243 |
244 | **Tests**
245 | - Mock FileReader; assert context gets text
246 | - Invalid file triggers error message
247 |
248 | Mount FileLoader above ControlsPanel.
249 |
250 | Return code + passing tests.
251 | ```
252 |
253 | ### Prompt 10 – useTokenStream Hook
254 |
255 | ```text
256 | Implement `useTokenStream(text: string, baseSpeed: number)`.
257 |
258 | **Logic**
259 | - Random starting offset
260 | - Speed = baseSpeed ±15 % jitter
261 | - Emits next chunk (3–4 chars; single-char words whole) every interval (1/speed seconds)
262 | - Returns `[currentLines, cursorCoords]`
263 |
264 | **Tests**
265 | - Given small text, hook emits expected chunks with fake timers.
266 | ```
267 |
268 | ### Prompt 11 – Animate Terminal Content
269 |
270 | ```text
271 | Wire useTokenStream into TerminalWindow.
272 |
273 | **Additions**
274 | - Internal state `lines` from hook; render with auto-scroll (scrollTop = scrollHeight on update)
275 | - Blinking cursor (`|`)
276 | - Stop animation at EOF (hook returns `done=true`)
277 |
278 | **Tests**
279 | - Mount window with sample text; advance timers; assert lines grow.
280 | ```
281 |
282 | ### Prompt 12 – Scattered Layout Toggle
283 |
284 | ```text
285 | Add scattered layout functionality to the LayoutManager and connect to the UI toggle.
286 |
287 | **Algorithm for Scattered Layout**
288 | - On toggle to 'scattered', or when `numWindows` changes while in 'scattered' mode: iterate through all `numWindows` and assign each a random `{left, top}` position within the main viewport container.
289 | - Windows can overlap in scattered mode.
290 | - Dragging in 'scattered' mode allows free movement without snapping.
291 |
292 | **Algorithm for Uniform Layout (Refinement)**
293 | - On toggle to 'uniform', or when `numWindows` changes while in 'uniform' mode: re-calculate the optimal grid and assign/move windows to these grid positions.
294 |
295 | **Tests**
296 | - Toggle layout to 'scattered'; assert terminal positions are randomized and differ from any grid-based coordinates.
297 | - Toggle layout to 'uniform'; assert terminal positions conform to the dynamically calculated optimal grid.
298 | ```
299 |
300 | ### Prompt 13 – Theme Toggle & Hide Menu
301 |
302 | ```text
303 | Theme
304 | - Tailwind dark mode: `className={theme === 'dark' ? 'dark' : ''}` on ``
305 |
306 | Hide Menu
307 | - ControlsPanel receives `visible` prop from AppContext
308 | - Hide button flips it
309 |
310 | **Tests**
311 | - Toggle theme updates document.body class
312 | - Hide button toggles panel visibility
313 | ```
314 |
315 | ### Prompt 14 – Speed Slider Live Update
316 |
317 | ```text
318 | Modify useTokenStream to accept `speedRef` (React ref).
319 |
320 | On slider change, update ref; interval callback reads latest speed without recreating timer.
321 |
322 | **Tests**
323 | - Render window; change speed; assert `setInterval` frequency adapts.
324 | ```
325 |
326 | ### Prompt 15 – EOF Handling & Pause State
327 |
328 | ```text
329 | When token stream ends, stop interval and leave cursor blinking on last line.
330 |
331 | **Tests**
332 | - Provide small text; advance timers past EOF; assert no further lines appended.
333 | ```
334 |
335 | ### Prompt 16 – Edge-case & Error Tests
336 |
337 | ```text
338 | Add tests:
339 | - Drag window off-screen (allowed)
340 | - Resize beyond max (clamped)
341 | - Load 0 byte file (error state)
342 | - Slider spam does not create timer leak (jest spy on setInterval)
343 | ```
344 |
345 | ### Prompt 17 – Smoke Test & Build
346 |
347 | ```text
348 | Add Cypress or Playwright smoke test (optional local run).
349 |
350 | Ensure:
351 | - File loads
352 | - A set number of windows (e.g., 5) appear and animate in 'uniform' grid layout.
353 | - Controls (number of windows, layout toggle, speed, theme, hide) function successfully.
354 |
355 | Then run `npm run build` and provide build folder size stats.
356 | ```
357 |
358 | ### Prompt 18 – Deployment Setup
359 |
360 | ```text
361 | Add `vercel.json` (or Netlify `_redirects`) for static hosting.
362 |
363 | Update README with deploy instructions.
364 |
365 | No code changes beyond config.
366 | ```
367 |
368 | ---
369 |
370 | **Done.**
371 | Feed the prompts one-by-one to a code-gen LLM and you'll march safely from empty repo to fully working meme-terminal app, with tests guarding every increment.
372 |
--------------------------------------------------------------------------------
/src/pages/AsciiTyper.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useRef } from 'react';
2 | import { generateAsciiArt } from '../utils/asciiArt';
3 |
4 | interface TextLine {
5 | id: string;
6 | text: string;
7 | }
8 |
9 | const PRESET_COLORS = [
10 | { name: 'Black', value: '#000000' },
11 | { name: 'White', value: '#ffffff' },
12 | { name: 'Red', value: '#ef4444' },
13 | { name: 'Green', value: '#22c55e' },
14 | { name: 'Blue', value: '#3b82f6' },
15 | { name: 'Purple', value: '#a855f7' },
16 | { name: 'Pink', value: '#ec4899' },
17 | { name: 'Yellow', value: '#eab308' },
18 | { name: 'Cyan', value: '#06b6d4' },
19 | { name: 'Orange', value: '#f97316' },
20 | { name: 'Gray Dark', value: '#374151' },
21 | { name: 'Gray Light', value: '#9ca3af' },
22 | ];
23 |
24 | export function AsciiTyper() {
25 | const [lines, setLines] = useState([
26 | { id: '1', text: 'Gradient Text' }
27 | ]);
28 | const [backgroundColor, setBackgroundColor] = useState('#000000');
29 | const [textColor, setTextColor] = useState('#22c55e');
30 | const [useTextGradient, setUseTextGradient] = useState(false);
31 | const [textGradientStart, setTextGradientStart] = useState('#22c55e');
32 | const [textGradientEnd, setTextGradientEnd] = useState('#3b82f6');
33 | const [useBackgroundGradient, setUseBackgroundGradient] = useState(false);
34 | const [backgroundGradientStart, setBackgroundGradientStart] = useState('#000000');
35 | const [backgroundGradientEnd, setBackgroundGradientEnd] = useState('#1f2937');
36 | const previewRef = useRef(null);
37 |
38 | const addNewLine = () => {
39 | const newId = Date.now().toString();
40 | setLines(prev => [...prev, { id: newId, text: '' }]);
41 | };
42 |
43 | const updateLineText = (id: string, newText: string) => {
44 | setLines(prev => prev.map(line =>
45 | line.id === id ? { ...line, text: newText } : line
46 | ));
47 | };
48 |
49 | const removeLine = (id: string) => {
50 | if (lines.length > 1) {
51 | setLines(prev => prev.filter(line => line.id !== id));
52 | }
53 | };
54 |
55 | const generatePreview = () => {
56 | if (lines.every(line => line.text.trim() === '')) {
57 | return ['', '', '', '', '', ''];
58 | }
59 |
60 | const textLines = lines
61 | .filter(line => line.text.trim() !== '')
62 | .map(line => line.text.toUpperCase());
63 |
64 | const result: string[] = [];
65 |
66 | for (const textLine of textLines) {
67 | const asciiLines = generateAsciiArt(textLine);
68 | result.push(...asciiLines);
69 | // Add spacing between lines
70 | if (textLine !== textLines[textLines.length - 1]) {
71 | result.push('');
72 | }
73 | }
74 |
75 | return result;
76 | };
77 |
78 | const copyToClipboard = () => {
79 | const asciiText = generatePreview().join('\n');
80 | navigator.clipboard.writeText(asciiText);
81 | };
82 |
83 | const copyImageToClipboard = async () => {
84 | if (!previewRef.current) return;
85 |
86 | try {
87 | // Use html2canvas to capture the preview area
88 | const html2canvas = (await import('html2canvas')).default;
89 | const canvas = await html2canvas(previewRef.current, {
90 | backgroundColor: backgroundColor,
91 | scale: 2, // Higher resolution
92 | useCORS: true,
93 | });
94 |
95 | // Convert canvas to blob
96 | canvas.toBlob(async (blob) => {
97 | if (!blob) return;
98 |
99 | try {
100 | await navigator.clipboard.write([
101 | new ClipboardItem({ 'image/png': blob })
102 | ]);
103 | } catch (error) {
104 | console.error('Failed to copy image to clipboard:', error);
105 | // Fallback: create download link
106 | const url = URL.createObjectURL(blob);
107 | const a = document.createElement('a');
108 | a.href = url;
109 | a.download = 'ascii-art.png';
110 | a.click();
111 | URL.revokeObjectURL(url);
112 | }
113 | }, 'image/png');
114 | } catch (error) {
115 | console.error('Failed to capture image:', error);
116 | }
117 | };
118 |
119 | return (
120 |
124 |
125 | {/* Header */}
126 |
127 |
128 | ASCII Art Typer
129 |
130 |
131 | Type text and see it converted to ASCII art in real-time
132 |
133 |
134 |
135 | {/* ASCII Preview - Hero Section */}
136 |
137 |
138 |
139 | ASCII Preview
140 |
141 |
142 |
146 | Copy Text
147 |
148 |
152 | Copy Image
153 |
154 |
155 |
156 |
157 |
167 | {useTextGradient ? (
168 |
175 | {generatePreview().map((line, index) => (
176 |
177 | {line || '\u00A0'}
178 |
179 | ))}
180 |
181 | ) : (
182 |
183 | {generatePreview().map((line, index) => (
184 |
185 | {line || '\u00A0'}
186 |
187 | ))}
188 |
189 | )}
190 |
191 |
192 |
193 | {/* Text Input Lines */}
194 |
195 |
196 |
197 | Text Lines
198 |
199 |
203 | +
204 | Add Line
205 |
206 |
207 |
208 |
209 | {lines.map((line, index) => (
210 |
211 |
212 | {index + 1}:
213 |
214 | updateLineText(line.id, e.target.value)}
218 | placeholder="Type your text here..."
219 | className="flex-1 px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
220 | />
221 | {lines.length > 1 && (
222 | removeLine(line.id)}
224 | className="px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded transition-colors"
225 | >
226 | ×
227 |
228 | )}
229 |
230 | ))}
231 |
232 |
233 |
234 | {/* Color Controls */}
235 |
236 | {/* Background Color */}
237 |
238 |
239 |
240 | Background Color
241 |
242 |
251 |
252 |
253 | {!useBackgroundGradient ? (
254 | <>
255 |
256 | {PRESET_COLORS.map((color) => (
257 | setBackgroundColor(color.value)}
260 | className={`w-12 h-12 rounded border-2 transition-all hover:scale-110 ${
261 | backgroundColor === color.value ? 'border-white border-4' : 'border-gray-400'
262 | }`}
263 | style={{ backgroundColor: color.value }}
264 | title={color.name}
265 | />
266 | ))}
267 |
268 |
setBackgroundColor(e.target.value)}
272 | className="mt-2 w-full h-8 rounded cursor-pointer"
273 | />
274 | >
275 | ) : (
276 |
277 |
278 |
281 |
282 | {PRESET_COLORS.map((color) => (
283 | setBackgroundGradientStart(color.value)}
286 | className={`w-12 h-12 rounded border-2 transition-all hover:scale-110 ${
287 | backgroundGradientStart === color.value ? 'border-white border-4' : 'border-gray-400'
288 | }`}
289 | style={{ backgroundColor: color.value }}
290 | title={color.name}
291 | />
292 | ))}
293 |
294 |
setBackgroundGradientStart(e.target.value)}
298 | className="w-full h-8 rounded cursor-pointer"
299 | />
300 |
301 |
302 |
305 |
306 | {PRESET_COLORS.map((color) => (
307 | setBackgroundGradientEnd(color.value)}
310 | className={`w-12 h-12 rounded border-2 transition-all hover:scale-110 ${
311 | backgroundGradientEnd === color.value ? 'border-white border-4' : 'border-gray-400'
312 | }`}
313 | style={{ backgroundColor: color.value }}
314 | title={color.name}
315 | />
316 | ))}
317 |
318 |
setBackgroundGradientEnd(e.target.value)}
322 | className="w-full h-8 rounded cursor-pointer"
323 | />
324 |
325 |
326 | )}
327 |
328 |
329 | {/* Text Color */}
330 |
331 |
332 |
333 | Text Color
334 |
335 |
344 |
345 |
346 | {!useTextGradient ? (
347 | <>
348 |
349 | {PRESET_COLORS.map((color) => (
350 | setTextColor(color.value)}
353 | className={`w-12 h-12 rounded border-2 transition-all hover:scale-110 ${
354 | textColor === color.value ? 'border-white border-4' : 'border-gray-400'
355 | }`}
356 | style={{ backgroundColor: color.value }}
357 | title={color.name}
358 | />
359 | ))}
360 |
361 |
setTextColor(e.target.value)}
365 | className="mt-2 w-full h-8 rounded cursor-pointer"
366 | />
367 | >
368 | ) : (
369 |
370 |
371 |
374 |
375 | {PRESET_COLORS.map((color) => (
376 | setTextGradientStart(color.value)}
379 | className={`w-12 h-12 rounded border-2 transition-all hover:scale-110 ${
380 | textGradientStart === color.value ? 'border-white border-4' : 'border-gray-400'
381 | }`}
382 | style={{ backgroundColor: color.value }}
383 | title={color.name}
384 | />
385 | ))}
386 |
387 |
setTextGradientStart(e.target.value)}
391 | className="w-full h-8 rounded cursor-pointer"
392 | />
393 |
394 |
395 |
398 |
399 | {PRESET_COLORS.map((color) => (
400 | setTextGradientEnd(color.value)}
403 | className={`w-12 h-12 rounded border-2 transition-all hover:scale-110 ${
404 | textGradientEnd === color.value ? 'border-white border-4' : 'border-gray-400'
405 | }`}
406 | style={{ backgroundColor: color.value }}
407 | title={color.name}
408 | />
409 | ))}
410 |
411 |
setTextGradientEnd(e.target.value)}
415 | className="w-full h-8 rounded cursor-pointer"
416 | />
417 |
418 |
419 | )}
420 |
421 |
422 |
423 | {/* Instructions */}
424 |
425 |
• Type in the text fields above to see ASCII art generated in real-time
426 |
• Use the + button to add new lines
427 |
• Customize colors using the color pickers or preset swatches
428 |
• Copy the generated ASCII art as text or image to use elsewhere
429 |
430 |
431 |
432 | );
433 | }
--------------------------------------------------------------------------------
/src/data/terminalOutputs.ts:
--------------------------------------------------------------------------------
1 | import type { TerminalColorRole } from './colorThemes';
2 |
3 | export interface TerminalLine {
4 | text: string;
5 | color?: string; // Legacy support
6 | colorRole?: TerminalColorRole; // New semantic role
7 | bold?: boolean;
8 | delay?: number; // Delay before starting this line
9 | }
10 |
11 | export const sampleTerminalOutput: TerminalLine[] = [
12 | { text: "Initializing development environment...", colorRole: "muted" },
13 | { text: "✓ Loading configuration", colorRole: "success", delay: 100 },
14 | { text: "✓ Setting up virtual environment", colorRole: "success", delay: 100 },
15 | { text: "✓ Installing dependencies", colorRole: "success", delay: 200 },
16 | { text: "", delay: 50 }, // Empty line
17 | { text: "Running tests...", colorRole: "warning", bold: true, delay: 300 },
18 | { text: " ✓ App.test.tsx", colorRole: "success", delay: 100 },
19 | { text: " ✓ TerminalWindow.test.tsx", colorRole: "success", delay: 100 },
20 | { text: " ✓ ControlsPanel.test.tsx", colorRole: "success", delay: 100 },
21 | { text: "", delay: 50 },
22 | { text: "All tests passed! 🎉", colorRole: "success", bold: true, delay: 200 },
23 | { text: "", delay: 50 },
24 | { text: "Starting development server...", colorRole: "info", delay: 300 },
25 | { text: " VITE v5.4.19 ready in 316 ms", colorRole: "accent", delay: 200 },
26 | { text: "", delay: 50 },
27 | { text: " ➜ Local: http://localhost:5173/", colorRole: "accent", delay: 100 },
28 | { text: " ➜ Network: use --host to expose", colorRole: "accent", delay: 100 },
29 | { text: "", delay: 50 },
30 | { text: "Server running. Press h for help.", colorRole: "muted", delay: 200 }
31 | ];
32 |
33 | export const buildOutput: TerminalLine[] = [
34 | { text: "$ npm run build", colorRole: "command" },
35 | { text: "", delay: 100 },
36 | { text: "> multiplecodeterminals@0.0.0 build", colorRole: "muted", delay: 100 },
37 | { text: "> tsc -b && vite build", colorRole: "muted", delay: 100 },
38 | { text: "", delay: 200 },
39 | { text: "vite v5.4.19 building for production...", colorRole: "accent", delay: 300 },
40 | { text: "✓ 234 modules transformed.", colorRole: "success", delay: 500 },
41 | { text: "dist/index.html 0.45 kB │ gzip: 0.30 kB", colorRole: "secondary", delay: 100 },
42 | { text: "dist/assets/index-DiwuhE6o.css 23.45 kB │ gzip: 5.67 kB", colorRole: "secondary", delay: 100 },
43 | { text: "dist/assets/index-B3xqND6W.js 142.65 kB │ gzip: 45.82 kB", colorRole: "secondary", delay: 100 },
44 | { text: "✓ built in 1.38s", colorRole: "success", bold: true, delay: 200 },
45 | { text: "", delay: 50 },
46 | { text: "Build completed successfully! ✨", colorRole: "success", bold: true, delay: 300 }
47 | ];
48 |
49 | export const errorOutput: TerminalLine[] = [
50 | { text: "$ npm test", colorRole: "command" },
51 | { text: "", delay: 100 },
52 | { text: "Running test suite...", colorRole: "warning", delay: 200 },
53 | { text: " FAIL src/App.test.tsx", colorRole: "error", bold: true, delay: 300 },
54 | { text: " ● Test suite failed to run", colorRole: "error", delay: 100 },
55 | { text: "", delay: 50 },
56 | { text: " TypeError: Cannot read property 'useState' of null", colorRole: "error", delay: 100 },
57 | { text: " at Object. (src/App.tsx:15:32)", colorRole: "muted", delay: 50 },
58 | { text: " at Module._compile (node:internal/modules/cjs/loader:1120:14)", colorRole: "muted", delay: 50 },
59 | { text: "", delay: 100 },
60 | { text: "Test Suites: 1 failed, 1 total", colorRole: "error", delay: 200 },
61 | { text: "Tests: 0 total", colorRole: "muted", delay: 50 },
62 | { text: "Time: 0.512 s", colorRole: "muted", delay: 50 },
63 | { text: "", delay: 100 },
64 | { text: "ERROR: Tests failed ❌", colorRole: "error", bold: true, delay: 200 }
65 | ];
66 |
67 | // You can add more output scenarios here
68 | export const conversationOutput: TerminalLine[] = [
69 | { text: "Key Features:", colorRole: "accent", bold: true, delay: 200 },
70 | { text: "- ✅ No more crashes from undefined lines", colorRole: "success", delay: 150 },
71 | { text: "- ✅ Continuous looping animation", colorRole: "success", delay: 150 },
72 | { text: "- ✅ 3-second pause between loops", colorRole: "success", delay: 150 },
73 | { text: "- ✅ Each terminal cycles through its assigned output type", colorRole: "success", delay: 150 },
74 | { text: "- ✅ Smooth restart with cleared lines", colorRole: "success", delay: 150 },
75 | { text: "", delay: 100 },
76 | { text: "Now your terminals will continuously display their animations in a loop,", colorRole: "secondary", delay: 200 },
77 | { text: "creating that \"always active\" desktop feel you're going for!", colorRole: "secondary", delay: 200 },
78 | { text: "", delay: 150 },
79 | { text: "> /model", colorRole: "command", delay: 250 },
80 | { text: " ⎿ Set model to Default (claude-sonnet-4-20250514)", colorRole: "accent", delay: 200 },
81 | { text: "", delay: 100 },
82 | { text: "> Okay, it's looking good so far. One change I would like to make is", colorRole: "info", delay: 300 },
83 | { text: " instead of coming in character by character, I'd like it to be token -", colorRole: "info", delay: 200 },
84 | { text: " which is basically a random number of characters that come in all at", colorRole: "info", delay: 200 },
85 | { text: " once between 3 and 6 characters long. So basically it's like pick a", colorRole: "info", delay: 200 },
86 | { text: " random 3, bring it in, pick a random 4, bring it in, pick a random 5,", colorRole: "info", delay: 200 },
87 | { text: " bring it in. Can we switch it to that instead of just character by", colorRole: "info", delay: 200 },
88 | { text: " characters slowly but surely?", colorRole: "info", delay: 200 },
89 | { text: "", delay: 150 },
90 | { text: "⏺ Absolutely! Let me update the typewriter hook to use token-based", colorRole: "warning", bold: true, delay: 300 },
91 | { text: " animation instead of character-by-character. This will make it look", colorRole: "warning", delay: 200 },
92 | { text: " more like realistic terminal output.", colorRole: "warning", delay: 200 },
93 | { text: " ⎿ ☐ Update typewriter animation from character-by-character to", colorRole: "muted", delay: 150 },
94 | { text: " token-based (3-6 chars)", colorRole: "muted", delay: 150 },
95 | { text: "", delay: 100 },
96 | { text: "⏺ Update(src/hooks/useTypewriter.ts)", colorRole: "accent", bold: true, delay: 250 },
97 | { text: " ⎿ Updated src/hooks/useTypewriter.ts with 5 additions and 2 removals", colorRole: "success", delay: 200 },
98 | { text: " 9", colorRole: "command", delay: 50 },
99 | { text: " 10 interface UseTypewriterProps {", colorRole: "muted", delay: 50 },
100 | { text: " 11 lines: TypewriterLine[];", colorRole: "muted", delay: 50 },
101 | { text: " 12 speed?: number; // ms per character", colorRole: "error", delay: 50 },
102 | { text: " 12 speed?: number; // ms per token (3-6 chars)", colorRole: "success", delay: 50 },
103 | { text: " 13 enabled?: boolean;", colorRole: "muted", delay: 50 },
104 | { text: " 14 loop?: boolean; // Add loop option", colorRole: "muted", delay: 50 },
105 | { text: " 15 loopDelay?: number; // Delay before restarting", colorRole: "muted", delay: 50 },
106 | { text: " ...", colorRole: "command", delay: 50 },
107 | { text: "", delay: 100 },
108 | { text: "⏺ Now let me update the character processing logic to use tokens instead:", colorRole: "warning", bold: true, delay: 300 },
109 | { text: "", delay: 100 },
110 | { text: "⏺ Update(src/hooks/useTypewriter.ts)", colorRole: "accent", bold: true, delay: 250 },
111 | { text: " ⎿ Updated src/hooks/useTypewriter.ts with 7 additions and 4 removals", colorRole: "success", delay: 200 },
112 | { text: " 60 return;", colorRole: "muted", delay: 50 },
113 | { text: " 61 }", colorRole: "muted", delay: 50 },
114 | { text: " 62", colorRole: "muted", delay: 50 },
115 | { text: " 63 // Type out the current line", colorRole: "error", delay: 50 },
116 | { text: " 63 // Type out the current line using tokens", colorRole: "success", delay: 50 },
117 | { text: " 64 if (currentCharIndex <= currentLine.text.length) {", colorRole: "error", delay: 50 },
118 | { text: " 64 if (currentCharIndex < currentLine.text.length) {", colorRole: "success", delay: 50 },
119 | { text: " 65 timeoutRef.current = setTimeout(() => {", colorRole: "muted", delay: 50 },
120 | { text: " 66 const tokenSize = getTokenSize();", colorRole: "success", delay: 50 },
121 | { text: " 67 const nextCharIndex = Math.min(currentCharIndex + tokenSize, currentLine.text.length);", colorRole: "success", delay: 50 },
122 | { text: " ...", colorRole: "command", delay: 50 },
123 | { text: "", delay: 100 },
124 | { text: "⏺ Perfect! I've updated the typewriter animation to use token-based", colorRole: "warning", bold: true, delay: 300 },
125 | { text: " rendering instead of character-by-character. Here are the key changes:", colorRole: "warning", delay: 200 },
126 | { text: "", delay: 100 },
127 | { text: " Token-Based Animation:", colorRole: "accent", bold: true, delay: 200 },
128 | { text: " - Random token size: Each \"chunk\" is now 3-6 characters long", colorRole: "success", delay: 150 },
129 | { text: " - Realistic timing: 100ms per token instead of per character", colorRole: "success", delay: 150 },
130 | { text: " - Better flow: Text appears in more realistic chunks", colorRole: "success", delay: 150 },
131 | { text: "", delay: 100 },
132 | { text: " Key improvements:", colorRole: "accent", bold: true, delay: 200 },
133 | { text: " - ✅ More realistic terminal output simulation", colorRole: "success", delay: 150 },
134 | { text: " - ✅ Faster overall animation (chunks instead of individual chars)", colorRole: "success", delay: 150 },
135 | { text: " - ✅ Variable chunk sizes create natural typing rhythm", colorRole: "success", delay: 150 },
136 | { text: " - ✅ Still maintains all the color coding and formatting", colorRole: "success", delay: 150 },
137 | { text: " - ✅ Looping functionality preserved", colorRole: "success", delay: 150 },
138 | { text: "", delay: 200 },
139 | { text: "Now your terminals will display text in realistic chunks! 🚀", colorRole: "success", bold: true, delay: 300 }
140 | ];
141 |
142 | export const troubleshootingOutput: TerminalLine[] = [
143 | { text: "⏺ Read(src/hooks/useTypewriter.ts)", colorRole: "accent", bold: true, delay: 250 },
144 | { text: " ⎿ Read 30 lines (ctrl+r to expand)", colorRole: "success", delay: 200 },
145 | { text: "", delay: 100 },
146 | { text: "⏺ Let me check the displayedLines state:", colorRole: "warning", bold: true, delay: 300 },
147 | { text: "", delay: 100 },
148 | { text: "⏺ Read(src/hooks/useTypewriter.ts)", colorRole: "accent", bold: true, delay: 250 },
149 | { text: " ⎿ Read 10 lines (ctrl+r to expand)", colorRole: "success", delay: 200 },
150 | { text: "", delay: 100 },
151 | { text: "⏺ Update(src/hooks/useTypewriter.ts)", colorRole: "accent", bold: true, delay: 250 },
152 | { text: " ⎿ Updated src/hooks/useTypewriter.ts with 1 addition and 1 removal", colorRole: "success", delay: 200 },
153 | { text: " 16 loop = false,", colorRole: "muted", delay: 50 },
154 | { text: " 17 loopDelay = 2000", colorRole: "muted", delay: 50 },
155 | { text: " 18 }: UseTypewriterProps) {", colorRole: "muted", delay: 50 },
156 | { text: " 19 - const [displayedLines, setDisplayedLines] = useState([]);", colorRole: "error", delay: 50 },
157 | { text: " 19 + const [displayedLines, setDisplayedLines] = useState([]);", colorRole: "success", delay: 50 },
158 | { text: " 20 const [currentLineIndex, setCurrentLineIndex] = useState(0);", colorRole: "muted", delay: 50 },
159 | { text: " 21 const [currentCharIndex, setCurrentCharIndex] = useState(0);", colorRole: "muted", delay: 50 },
160 | { text: " 22 const timeoutRef = useRef(null);", colorRole: "muted", delay: 50 },
161 | { text: "", delay: 100 },
162 | { text: "⏺ Let me try building again:", colorRole: "warning", bold: true, delay: 300 },
163 | { text: "", delay: 100 },
164 | { text: "⏺ Bash(npm run build)", colorRole: "accent", bold: true, delay: 250 },
165 | { text: " ⎿ Error: > multiplecodeterminals@0.0.0 build", colorRole: "error", delay: 200 },
166 | { text: " > tsc -b && vite build", colorRole: "error", delay: 100 },
167 | { text: "", delay: 50 },
168 | { text: " src/components/ControlsPanel/__tests__/ControlsPanel.test.tsx(5,1): error TS6133: 'React' is declared but its value is never read.", colorRole: "error", delay: 50 },
169 | { text: " src/components/ControlsPanel/ControlsPanel.tsx(1,8): error TS6133: 'React' is declared but its value is never read.", colorRole: "error", delay: 50 },
170 | { text: " src/components/TerminalWindow/__tests__/TerminalWindow.test.tsx(4,1): error TS6133: 'React' is declared but its value is never read.", colorRole: "error", delay: 50 },
171 | { text: " src/contexts/__tests__/AppContext.test.tsx(4,1): error TS6133: 'React' is declared but its value is never read.", colorRole: "error", delay: 50 },
172 | { text: " src/contexts/__tests__/AppContext.test.tsx(100,13): error TS6133: 'incrementButton' is declared but its value is never read.", colorRole: "error", delay: 50 },
173 | { text: "", delay: 100 },
174 | { text: "⏺ Great! The main TypeScript issues are resolved. The remaining errors are just unused React imports in tests, which don't affect functionality. Let me test the application by running", colorRole: "warning", bold: true, delay: 300 },
175 | { text: " it:", colorRole: "warning", delay: 200 },
176 | { text: "", delay: 100 },
177 | { text: "⏺ Bash(npm run dev)", colorRole: "accent", bold: true, delay: 250 },
178 | { text: " ⎿ Error: Command timed out after 10s", colorRole: "error", delay: 200 },
179 | { text: "", delay: 50 },
180 | { text: " > multiplecodeterminals@0.0.0 dev", colorRole: "muted", delay: 50 },
181 | { text: " > vite", colorRole: "muted", delay: 50 },
182 | { text: "", delay: 50 },
183 | { text: " Port 5173 is in use, trying another one...", colorRole: "info", delay: 100 },
184 | { text: " Port 5174 is in use, trying another one...", colorRole: "info", delay: 100 },
185 | { text: "", delay: 50 },
186 | { text: " VITE v5.4.19 ready in 285 ms", colorRole: "accent", delay: 200 },
187 | { text: "", delay: 50 },
188 | { text: " … +2 lines (ctrl+r to see all)", colorRole: "muted", delay: 100 },
189 | { text: "", delay: 100 },
190 | { text: "⏺ Perfect! The development server is now running on port 5175.", colorRole: "warning", bold: true, delay: 300 },
191 | { text: "", delay: 150 },
192 | { text: " Summary", colorRole: "accent", bold: true, delay: 200 },
193 | { text: "", delay: 100 },
194 | { text: " I've successfully implemented a comprehensive theming system for your terminal application! Here's what was created:", colorRole: "secondary", delay: 200 },
195 | { text: "", delay: 100 },
196 | { text: " 🎨 Theme System Features:", colorRole: "accent", bold: true, delay: 200 },
197 | { text: "", delay: 100 },
198 | { text: " 1. 6 Different Themes:", colorRole: "primary", delay: 150 },
199 | { text: " - Dark - Classic terminal with green text on black background", colorRole: "secondary", delay: 100 },
200 | { text: " - Light - Clean white background with dark text", colorRole: "secondary", delay: 100 },
201 | { text: " - Minimal - Minimal grayscale theme", colorRole: "secondary", delay: 100 },
202 | { text: " - Retro Green - Classic monochrome green terminal look", colorRole: "secondary", delay: 100 },
203 | { text: " - Dracula - Bold colors on dark background", colorRole: "secondary", delay: 100 },
204 | { text: " - Solarized Dark - Muted earth tones on deep blue", colorRole: "secondary", delay: 100 },
205 | { text: " 2. Theme Toggle Button - Added to the controls panel to cycle through themes", colorRole: "primary", delay: 150 },
206 | { text: " 3. Semantic Color Roles - Instead of hardcoded colors, terminal content now uses semantic roles:", colorRole: "primary", delay: 150 },
207 | { text: " - muted - Gray, secondary text", colorRole: "secondary", delay: 100 },
208 | { text: " - success - Green, success messages", colorRole: "secondary", delay: 100 },
209 | { text: " - warning - Yellow/orange warnings", colorRole: "secondary", delay: 100 },
210 | { text: " - error - Red error messages", colorRole: "secondary", delay: 100 },
211 | { text: " - info - Blue informational text", colorRole: "secondary", delay: 100 },
212 | { text: " - accent - Purple/cyan highlights", colorRole: "secondary", delay: 100 },
213 | { text: " - primary - Main text color", colorRole: "secondary", delay: 100 },
214 | { text: " - secondary - Slightly dimmed text", colorRole: "secondary", delay: 100 },
215 | { text: " - command - Command/input text", colorRole: "secondary", delay: 100 },
216 | { text: "", delay: 100 },
217 | { text: " 🔧 Technical Implementation:", colorRole: "accent", bold: true, delay: 200 },
218 | { text: "", delay: 100 },
219 | { text: " - Theme Context - React context manages current theme state", colorRole: "success", delay: 150 },
220 | { text: " - Centralized Configuration - All themes defined in colorThemes.ts", colorRole: "success", delay: 150 },
221 | { text: " - Backward Compatibility - Legacy hardcoded colors still work", colorRole: "success", delay: 150 },
222 | { text: " - Dynamic Background - Terminal backgrounds change with themes", colorRole: "success", delay: 150 },
223 | { text: " - Real-time Switching - Theme changes apply instantly to all terminals", colorRole: "success", delay: 150 },
224 | { text: "", delay: 100 },
225 | { text: " 🎯 Key Benefits:", colorRole: "accent", bold: true, delay: 200 },
226 | { text: "", delay: 100 },
227 | { text: " - Easy Theme Creation - Add new themes by editing the config file", colorRole: "success", delay: 150 },
228 | { text: " - Consistent Styling - All terminals use the same color mapping", colorRole: "success", delay: 150 },
229 | { text: " - Performance Optimized - Themes use CSS classes, not inline styles", colorRole: "success", delay: 150 },
230 | { text: " - Flexible Design - Can easily add more color roles or themes", colorRole: "success", delay: 150 },
231 | { text: "", delay: 200 },
232 | { text: " The app is now running on http://localhost:5175/ - you can test the theme switching by clicking the \"Theme: [current]\" button in the controls panel! Each theme will completely", colorRole: "info", delay: 200 },
233 | { text: " transform the look and feel of all terminals while maintaining the same functionality.", colorRole: "info", delay: 200 },
234 | ];
235 |
236 | export const epicSession: TerminalLine[] = [
237 | /* -------------------- Setup / Discovery -------------------- */
238 | { text: "⏺ Starting Epic Bug Hunt debugging session…", colorRole: "accent", bold: true, delay: 300 },
239 | { text: " ⎿ Loading last-hour production error snapshots", colorRole: "success", delay: 200 },
240 | { text: "", delay: 100 },
241 |
242 | { text: "⏺ Bash(tail -n 50 /var/log/app/error.log)", colorRole: "accent", bold: true, delay: 280 },
243 | { text: " ⎿ 50 lines streamed to console (ctrl+r to expand)", colorRole: "success", delay: 180 },
244 | { text: "", delay: 100 },
245 |
246 | { text: "[error] 2025-06-13T14:05Z POST /api/orders 500 — Cannot read property 'status' of undefined", colorRole: "error", delay: 50 },
247 | { text: "[info] TraceID: 39ae2f9e…", colorRole: "muted", delay: 50 },
248 | { text: "", delay: 100 },
249 |
250 | { text: "⏺ Task(Identify failing endpoint source)", colorRole: "accent", bold: true, delay: 270 },
251 | { text: " ⎿ Task completed successfully — endpoint located at backend/routes/orders.ts", colorRole: "success", delay: 180 },
252 | { text: "", delay: 100 },
253 |
254 | { text: "⏺ Read(backend/routes/orders.ts)", colorRole: "accent", bold: true, delay: 260 },
255 | { text: " ⎿ Read 189 lines (ctrl+r to expand)", colorRole: "success", delay: 180 },
256 | { text: "", delay: 100 },
257 |
258 | { text: " 87 export const createOrder = async (req, res) => {", colorRole: "muted", delay: 50 },
259 | { text: " 88 const { userId, cart } = req.body;", colorRole: "muted", delay: 50 },
260 | { text: " 89 - const session = await database.startSession;", colorRole: "error", delay: 50 },
261 | { text: " 89 + const session = await database.startSession();", colorRole: "warning", delay: 50 },
262 | { text: " 90 // …", colorRole: "muted", delay: 50 },
263 | { text: "", delay: 100 },
264 |
265 | { text: "Found suspicious missing parentheses on line 89 — possible undefined session", colorRole: "warning", delay: 250 },
266 | { text: "", delay: 100 },
267 |
268 | { text: "⏺ Bash(git blame -L 80,100 backend/routes/orders.ts)", colorRole: "accent", bold: true, delay: 280 },
269 | { text: " ⎿ commit 7c1dafe0 (2 days ago) dev-intern \"quick hack to speed up orders\"", colorRole: "info", delay: 180 },
270 | { text: "", delay: 100 },
271 |
272 | { text: "Decision: dig deeper — race condition suspected between DB session init and order service", colorRole: "info", delay: 300 },
273 | { text: "", delay: 100 },
274 |
275 | /* -------------------- Investigation / Analysis -------------------- */
276 | { text: "⏺ Bash(rg \"startSession(\" -n backend | head)", colorRole: "accent", bold: true, delay: 270 },
277 | { text: " ⎿ backend/services/database/connection.ts:42:startSession(): Promise", colorRole: "success", delay: 180 },
278 | { text: " ⎿ backend/routes/orders.ts:89:const session = await database.startSession();", colorRole: "success", delay: 160 },
279 | { text: "", delay: 100 },
280 |
281 | { text: "⏺ Read(backend/services/database/connection.ts)", colorRole: "accent", bold: true, delay: 250 },
282 | { text: " ⎿ Read 112 lines (ctrl+r to expand)", colorRole: "success", delay: 170 },
283 | { text: "", delay: 100 },
284 |
285 | { text: " 39 export async function startSession(): Promise {", colorRole: "muted", delay: 50 },
286 | { text: " 40 if (!client) await init();", colorRole: "muted", delay: 50 },
287 | { text: " 41 return client.startSession();", colorRole: "muted", delay: 50 },
288 | { text: " 42 }", colorRole: "muted", delay: 50 },
289 | { text: "", delay: 100 },
290 |
291 | { text: "Note: startSession is async-safe, bug likely higher level — maybe unawaited promise chain", colorRole: "info", delay: 250 },
292 | { text: "", delay: 100 },
293 |
294 | { text: "⏺ Bash(rg \"createOrder(\" -n)", colorRole: "accent", bold: true, delay: 270 },
295 | { text: " ⎿ src/components/Checkout/CheckoutButton.tsx:78:createOrder(cart)", colorRole: "success", delay: 170 },
296 | { text: "", delay: 100 },
297 |
298 | { text: "⏺ Read(src/components/Checkout/CheckoutButton.tsx)", colorRole: "accent", bold: true, delay: 260 },
299 | { text: " ⎿ Read 152 lines (ctrl+r to expand)", colorRole: "success", delay: 180 },
300 | { text: "", delay: 100 },
301 |
302 | { text: " 74 const handleCheckout = async () => {", colorRole: "muted", delay: 50 },
303 | { text: " 75 setLoading(true);", colorRole: "muted", delay: 50 },
304 | { text: " 76 try {", colorRole: "muted", delay: 50 },
305 | { text: " 77 await createOrder(cart);", colorRole: "muted", delay: 50 },
306 | { text: " 78 router.push('/orders/success');", colorRole: "muted", delay: 50 },
307 | { text: " 79 } catch (err) {", colorRole: "muted", delay: 50 },
308 | { text: " 80 toast.error('Checkout failed');", colorRole: "muted", delay: 50 },
309 | { text: " 81 } finally {", colorRole: "muted", delay: 50 },
310 | { text: " 82 setLoading(false);", colorRole: "muted", delay: 50 },
311 | { text: " 83 }", colorRole: "muted", delay: 50 },
312 | { text: "", delay: 100 },
313 |
314 | { text: "Frontend looks solid; pivot back to server error stack trace", colorRole: "info", delay: 250 },
315 | { text: "", delay: 100 },
316 |
317 | { text: "⏺ Bash(grep -R \"Cannot read property 'status'\" backend | head)", colorRole: "accent", bold: true, delay: 280 },
318 | { text: " ⎿ backend/routes/payments.ts:133:order.status = 'PAID';", colorRole: "success", delay: 180 },
319 | { text: "", delay: 100 },
320 |
321 | { text: "Observation: orders.ts calls payment service; order may be undefined after failed DB session", colorRole: "info", delay: 300 },
322 | { text: "", delay: 100 },
323 |
324 | { text: "⏺ Read(backend/routes/payments.ts)", colorRole: "accent", bold: true, delay: 260 },
325 | { text: " ⎿ Read 211 lines (ctrl+r to expand)", colorRole: "success", delay: 170 },
326 | { text: "", delay: 100 },
327 |
328 | { text: " 120 const order = await Orders.findById(orderId);", colorRole: "muted", delay: 50 },
329 | { text: " 121 if (!order)", colorRole: "muted", delay: 50 },
330 | { text: " 122 - throw new Error('Order not found');", colorRole: "error", delay: 50 },
331 | { text: " 122 + return res.status(404).json({ error: 'Order not found' });", colorRole: "warning", delay: 50 },
332 | { text: " 123 order.status = 'PAID';", colorRole: "muted", delay: 50 },
333 | { text: "", delay: 100 },
334 |
335 | { text: "Payments route returning 404 instead of throwing — but bug manifests earlier → null order", colorRole: "info", delay: 270 },
336 | { text: "", delay: 100 },
337 |
338 | { text: "Hypothesis: createOrder transaction aborts silently when session fails, returning undefined id", colorRole: "warning", delay: 300 },
339 | { text: "Plan: audit transaction commit / abort logic", colorRole: "primary", delay: 300 },
340 | { text: "", delay: 100 },
341 |
342 | { text: "⏺ Read(backend/routes/orders.ts) — focus lines 100-140", colorRole: "accent", bold: true, delay: 260 },
343 | { text: " ⎿ Read 41 lines", colorRole: "success", delay: 180 },
344 | { text: "", delay: 100 },
345 |
346 | { text: " 108 await session.withTransaction(async () => {", colorRole: "muted", delay: 50 },
347 | { text: " 109 const newOrder = new Orders({ userId, cart, status: 'PENDING' });", colorRole: "muted", delay: 50 },
348 | { text: " 110 await newOrder.save();", colorRole: "muted", delay: 50 },
349 | { text: " 111 paymentService.enqueue(newOrder._id);", colorRole: "muted", delay: 50 },
350 | { text: " 112 });", colorRole: "muted", delay: 50 },
351 | { text: " 113 res.status(201).json({ id: newOrder._id });", colorRole: "muted", delay: 50 },
352 | { text: " 114 } catch (err) {", colorRole: "muted", delay: 50 },
353 | { text: " 115 - res.status(500).json({ error: err.message });", colorRole: "error", delay: 50 },
354 | { text: " 115 + await session?.abortTransaction();", colorRole: "warning", delay: 50 },
355 | { text: " 116 + res.status(500).json({ error: 'Order creation failed' });", colorRole: "warning", delay: 50 },
356 | { text: " 117 } finally {", colorRole: "muted", delay: 50 },
357 | { text: " 118 await session?.endSession();", colorRole: "muted", delay: 50 },
358 | { text: " 119 }", colorRole: "muted", delay: 50 },
359 | { text: "", delay: 100 },
360 |
361 | { text: "Key issue: newOrder goes out of scope; inside transaction callback we must capture insertId", colorRole: "warning", delay: 300 },
362 | { text: "", delay: 100 },
363 |
364 | { text: "⏺ Edit(backend/routes/orders.ts)", colorRole: "accent", bold: true, delay: 270 },
365 | { text: " ⎿ Opening file in patch mode", colorRole: "success", delay: 160 },
366 | { text: "", delay: 100 },
367 |
368 | /* -------------------- Implementation / Fixes -------------------- */
369 | { text: " 108 let createdOrder: OrdersType | null = null;", colorRole: "muted", delay: 50 },
370 | { text: " 109 await session.withTransaction(async () => {", colorRole: "muted", delay: 50 },
371 | { text: " 110 const draft = new Orders({ userId, cart, status: 'PENDING' });", colorRole: "muted", delay: 50 },
372 | { text: " 111 createdOrder = await draft.save({ session });", colorRole: "muted", delay: 50 },
373 | { text: " 112 paymentService.enqueue(createdOrder._id);", colorRole: "muted", delay: 50 },
374 | { text: " 113 });", colorRole: "muted", delay: 50 },
375 | { text: " 114 - res.status(201).json({ id: newOrder._id });", colorRole: "error", delay: 50 },
376 | { text: " 114 + if (createdOrder)", colorRole: "success", delay: 50 },
377 | { text: " 115 + res.status(201).json({ id: createdOrder._id });", colorRole: "success", delay: 50 },
378 | { text: " 116 + else", colorRole: "success", delay: 50 },
379 | { text: " 117 + throw new Error('Transaction rolled back');", colorRole: "success", delay: 50 },
380 | { text: "", delay: 100 },
381 |
382 | { text: " ⎿ Updated orders.ts with 7 additions and 1 removal", colorRole: "success", delay: 180 },
383 | { text: "", delay: 100 },
384 |
385 | { text: "⏺ Update(backend/routes/orders.ts)", colorRole: "accent", bold: true, delay: 260 },
386 | { text: " ⎿ File saved", colorRole: "success", delay: 160 },
387 | { text: "", delay: 100 },
388 |
389 | { text: "⏺ Edit(backend/services/database/connection.ts) — strengthen init guard", colorRole: "accent", bold: true, delay: 260 },
390 | { text: "", delay: 100 },
391 | { text: " 21 - if (!client) await init();", colorRole: "error", delay: 50 },
392 | { text: " 21 + if (!client || !client.isConnected()) await init();", colorRole: "success", delay: 50 },
393 | { text: " ⎿ Updated connection.ts with 1 addition and 1 removal", colorRole: "success", delay: 160 },
394 | { text: "", delay: 100 },
395 |
396 | { text: "⏺ Bash(npm run lint --silent)", colorRole: "accent", bold: true, delay: 280 },
397 | { text: " ⎿ ✔ All 274 files pass ESLint", colorRole: "success", delay: 170 },
398 | { text: "", delay: 100 },
399 |
400 | { text: "⏺ Bash(npm run test -w backend --silent)", colorRole: "accent", bold: true, delay: 280 },
401 | { text: " ⎿ 148 unit tests passed (17.4s)", colorRole: "success", delay: 180 },
402 | { text: "", delay: 100 },
403 |
404 | { text: "⏺ Edit(tests/integration/orders.test.js)", colorRole: "accent", bold: true, delay: 260 },
405 | { text: "", delay: 100 },
406 | { text: " 66 - expect(res.body).toHaveProperty('id');", colorRole: "error", delay: 50 },
407 | { text: " 66 + expect(res.body.id).toMatch(/[a-f0-9]{24}/);", colorRole: "success", delay: 50 },
408 | { text: " 67 + const order = await Orders.findById(res.body.id);", colorRole: "success", delay: 50 },
409 | { text: " 68 + expect(order.status).toBe('PENDING');", colorRole: "success", delay: 50 },
410 | { text: " ⎿ Updated orders.test.js with 3 additions and 1 removal", colorRole: "success", delay: 160 },
411 | { text: "", delay: 100 },
412 |
413 | { text: "⏺ Bash(npm run test -w tests --silent)", colorRole: "accent", bold: true, delay: 280 },
414 | { text: " ⎿ 152 integration tests passed (28.3s)", colorRole: "success", delay: 180 },
415 | { text: "", delay: 100 },
416 |
417 | { text: "⏺ Bash(docker-compose up -d --build)", colorRole: "accent", bold: true, delay: 290 },
418 | { text: " ⎿ Rebuilt backend and restarted 3 containers", colorRole: "success", delay: 170 },
419 | { text: "", delay: 100 },
420 |
421 | { text: "⏺ Bash(curl -X POST http://localhost:4000/api/orders -H \"Content-Type: application/json\" -d '{\"userId\":\"u123\",\"cart\":[{\"sku\":\"sku_9\",\"qty\":1}]}')", colorRole: "accent", bold: true, delay: 300 },
422 | { text: " ⎿ {\"id\":\"66430a1bad1b7d3c2a2f24e6\"}", colorRole: "success", delay: 180 },
423 | { text: "", delay: 100 },
424 |
425 | { text: "Manual curl succeeded — no 500 error 🎉", colorRole: "primary", delay: 300 },
426 | { text: "", delay: 100 },
427 |
428 | { text: "⏺ Task(Update payment flow tests for new 404 behaviour)", colorRole: "accent", bold: true, delay: 270 },
429 | { text: " ⎿ Task completed successfully — 12 payment tests updated", colorRole: "success", delay: 180 },
430 | { text: "", delay: 100 },
431 |
432 | { text: "⏺ Bash(npm run e2e --silent)", colorRole: "accent", bold: true, delay: 290 },
433 | { text: " ⎿ 84 end-to-end tests passed (Cypress 15.2s)", colorRole: "success", delay: 190 },
434 | { text: "", delay: 100 },
435 |
436 | { text: "⏺ Bash(git add . && git commit -m \"fix: robust order transaction & session guard\" --no-verify)", colorRole: "accent", bold: true, delay: 300 },
437 | { text: " ⎿ [bugfix/order-txn 9d760b4] fix: robust order transaction & session guard — 6 files changed", colorRole: "success", delay: 190 },
438 | { text: "", delay: 100 },
439 |
440 | { text: "⏺ Bash(git push origin bugfix/order-txn)", colorRole: "accent", bold: true, delay: 290 },
441 | { text: " ⎿ Pushed to origin — open PR #482", colorRole: "success", delay: 170 },
442 | { text: "", delay: 100 },
443 |
444 | /* -------------------- Testing / Validation -------------------- */
445 | { text: "⏺ Bash(kubectl rollout restart deployment/backend-api)", colorRole: "accent", bold: true, delay: 300 },
446 | { text: " ⎿ rollout triggered", colorRole: "success", delay: 180 },
447 | { text: "", delay: 100 },
448 |
449 | { text: "⏺ Bash(kubectl logs -f deploy/backend-api | head -n 5)", colorRole: "accent", bold: true, delay: 280 },
450 | { text: " ⎿ Server listening on :4000", colorRole: "success", delay: 170 },
451 | { text: "", delay: 100 },
452 |
453 | { text: "⏺ Bash(k6 run load/createOrder.js)", colorRole: "accent", bold: true, delay: 290 },
454 | { text: " ⎿ ✓ 1000 VUs for 60s — 0 errors, p(95)=203ms", colorRole: "success", delay: 190 },
455 | { text: "", delay: 100 },
456 |
457 | { text: "Load test shows stable latency under 250 ms at peak — previously spiked to 3 s", colorRole: "primary", delay: 300 },
458 | { text: "", delay: 100 },
459 |
460 | { text: "⏺ Bash(kubectl top pods | grep backend-api)", colorRole: "accent", bold: true, delay: 280 },
461 | { text: " ⎿ cpu 80m / mem 120Mi (steady)", colorRole: "success", delay: 170 },
462 | { text: "", delay: 100 },
463 |
464 | { text: "⏺ Bash(kubectl get events | tail -n 3)", colorRole: "accent", bold: true, delay: 275 },
465 | { text: " ⎿ No crash loops detected", colorRole: "success", delay: 160 },
466 | { text: "", delay: 100 },
467 |
468 | /* -------------------- Conclusion / Summary -------------------- */
469 | { text: "✅ Bug squashed: undefined order.status 500 now resolved", colorRole: "success", bold: true, delay: 350 },
470 | { text: "🚦 Root cause: un-awaited DB session & order scope leakage during transaction", colorRole: "info", delay: 350 },
471 | { text: "🛠 Fixes applied:", colorRole: "primary", delay: 300 },
472 | { text: " • Corrected startSession invocation", colorRole: "secondary", delay: 200 },
473 | { text: " • Captured createdOrder within transaction scope", colorRole: "secondary", delay: 200 },
474 | { text: " • Added defensive session guards & abort handling", colorRole: "secondary", delay: 200 },
475 | { text: " • Hardened payment route 404 flow", colorRole: "secondary", delay: 200 },
476 | { text: " • Expanded integration tests and e2e coverage", colorRole: "secondary", delay: 200 },
477 | { text: "", delay: 100 },
478 |
479 | { text: "📈 Metrics after patch:", colorRole: "info", delay: 300 },
480 | { text: " • Error rate 👉 0.00% (↓ from 2.3%)", colorRole: "success", delay: 200 },
481 | { text: " • P95 latency 👉 203 ms (↓ from 3 s)", colorRole: "success", delay: 200 },
482 | { text: "", delay: 100 },
483 |
484 | { text: "🔀 PR #482 awaiting review — tagging @team-backend for fast-track merge", colorRole: "accent", delay: 300 },
485 | { text: "", delay: 100 },
486 |
487 | { text: "⏺ Session complete — good job, carry on! 🐞➡️🦋", colorRole: "accent", bold: true, delay: 400 }
488 | ];
489 |
490 |
491 | export const terminalOutputs = {
492 | development: sampleTerminalOutput,
493 | build: buildOutput,
494 | error: errorOutput,
495 | conversation: conversationOutput,
496 | troubleshooting: troubleshootingOutput,
497 | epic: epicSession
498 | };
--------------------------------------------------------------------------------