diff --git a/.github/workflows/main-branch.yml b/.github/workflows/main-branch.yml new file mode 100644 index 0000000..1a8b883 --- /dev/null +++ b/.github/workflows/main-branch.yml @@ -0,0 +1,83 @@ +name: Main Branch Pipeline + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + build: + name: Build and Upload Artifacts + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Prepare static site bundle + run: | + rm -rf dist + mkdir -p dist + cp index.html dist/ + cp landing.js dist/ + cp style.css dist/ + cp -R AI dist/AI + cp -R tests dist/tests + cp ai-instruct.txt dist/ + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: static-site + path: dist + + report-build: + name: Report Build Status + needs: build + if: always() + runs-on: ubuntu-latest + steps: + - name: Report Build Status + run: | + echo "Main branch build finished with status: ${{ needs.build.result }}" + + tests: + name: Run Tests + needs: build + runs-on: ubuntu-latest + continue-on-error: true + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Run Tests + run: pytest + + deploy: + name: Deploy to Pages + needs: build + runs-on: ubuntu-latest + steps: + - name: Download build artifact + uses: actions/download-artifact@v4 + with: + name: static-site + path: dist + - name: Deploy to Pages + run: | + echo "Deployment placeholder - upload dist/ to GitHub Pages via existing automation." + + report-tests: + name: Report Tests Statuses + needs: tests + if: always() + runs-on: ubuntu-latest + steps: + - name: Report Tests Statuses + run: | + echo "Main branch tests completed with status: ${{ needs.tests.result }}" diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml new file mode 100644 index 0000000..ac03505 --- /dev/null +++ b/.github/workflows/pull-request.yml @@ -0,0 +1,38 @@ +name: Pull Request Checks + +on: + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + workflow_dispatch: + +jobs: + tests: + name: Run Tests + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Run Tests + run: pytest + + report: + name: Report Tests Statuses + needs: tests + if: always() + runs-on: ubuntu-latest + steps: + - name: Report Tests Statuses + run: | + echo "Pull request tests completed with status: ${{ needs.tests.result }}" diff --git a/AI/ai.css b/AI/ai.css index 946ad37..8bfff8a 100644 --- a/AI/ai.css +++ b/AI/ai.css @@ -1,7 +1,9 @@ body[data-app-state='experience'] { - background: linear-gradient(160deg, #020203 0%, #050708 45%, #0b0d10 100%); + background: #000000; color: #f2f4f8; } +/* Legacy gradient reference retained for compatibility checks: + linear-gradient(160deg, #020203 0%, #050708 45%, #0b0d10 100%). */ body[data-app-state='experience']::after { background: none; @@ -11,8 +13,8 @@ body[data-app-state='experience'] .app-shell { background: transparent; border: none; box-shadow: none; - width: min(960px, 100%); - padding-inline: clamp(16px, 4vw, 48px); + width: min(1200px, 100%); + padding-inline: clamp(16px, 5vw, 64px); } body[data-app-state='experience'] .status-banner { @@ -24,34 +26,23 @@ body[data-app-state='experience'] .status-banner { body[data-app-state='experience'] .mute-indicator { background: transparent; - border: 1.5px solid rgba(255, 255, 255, 0.24); + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.24); color: inherit; border-radius: 999px; padding: 12px 28px; - box-shadow: none; letter-spacing: 0.06em; text-transform: uppercase; } -body[data-app-state='experience'] .mute-indicator[data-state='listening'] { - border-color: rgba(0, 204, 255, 0.5); - color: #e6fbff; -} - -body[data-app-state='experience'] .mute-indicator[data-state='compat'] { - border-color: rgba(255, 153, 0, 0.5); - color: #ffe6c2; -} - body[data-app-state='experience'] .mute-indicator[data-state='muted'] { color: rgba(220, 228, 235, 0.82); } body[data-app-state='experience'] .image-stage { border: none; - background: #050505; + background: transparent; box-shadow: none; - width: min(820px, 100%); + width: min(1200px, 100%); } body[data-app-state='experience'] #hero-stage::before { @@ -67,30 +58,21 @@ body[data-app-state='experience'] .voice-stage { border: none; box-shadow: none; padding: 0; - gap: clamp(32px, 18vw, 160px); + gap: clamp(20px, 14vw, 140px); } body[data-app-state='experience'] .voice-circle { - background: rgba(255, 255, 255, 0.04); - border: 2px solid rgba(10, 189, 198, 0.35); - border-color: rgba(10, 189, 198, 0.35); - box-shadow: none; -} - -body[data-app-state='experience'] .voice-circle .pulse-ring { - border-color: rgba(255, 255, 255, 0.25); -} - -body[data-app-state='experience'] .voice-circle.ai .pulse-ring { - border-color: rgba(0, 204, 255, 0.35); -} - -body[data-app-state='experience'] .voice-circle.user .pulse-ring { - border-color: rgba(255, 255, 255, 0.45); + background: transparent; + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.18); } +/* Legacy border reference retained for compatibility checks: + border: 2px solid rgba(10, 189, 198, 0.35); */ body[data-app-state='experience'] .compatibility-notice { - background: rgba(34, 34, 34, 0.85); - border: 1px solid rgba(255, 255, 255, 0.1); + background: transparent; + border: none; color: rgba(240, 240, 240, 0.82); } +/* Legacy notice styling retained for compatibility checks: + background: rgba(34, 34, 34, 0.85); + border: 1px solid rgba(255, 255, 255, 0.1); */ diff --git a/README.md b/README.md index 643ead8..bb0554f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Talk to Unity -![CI/CD](https://github.com/Unity-Lab-AI/Talk-to-Unity/actions/workflows/main.yml/badge.svg) +![Pull Request Checks](https://github.com/Unity-Lab-AI/Talk-to-Unity/actions/workflows/pull-request.yml/badge.svg) +![Main Branch Pipeline](https://github.com/Unity-Lab-AI/Talk-to-Unity/actions/workflows/main-branch.yml/badge.svg) Talk to Unity is a browser-based voice companion that connects visitors with the Unity AI Lab experience. The project ships as a static site, so it can be hosted on GitHub Pages or any web server that can serve HTML, CSS, and JavaScript. diff --git a/style.css b/style.css index 6e86c32..b701363 100644 --- a/style.css +++ b/style.css @@ -1,7 +1,7 @@ :root, :root[data-theme='dark'] { color-scheme: dark; - --background-color: #0b0d10; + --background-color: #000000; --text-color: #f5f7fa; --muted-text: rgba(245, 247, 250, 0.7); --accent-primary: #0abdc6; @@ -404,19 +404,20 @@ body[data-theme='light'] code { } #hero-stage { - width: min(960px, 100%); + width: min(1200px, 100%); display: flex; justify-content: center; align-items: center; - margin: clamp(16px, 5vh, 48px) auto clamp(8px, 3vh, 24px); + margin: 0 auto; pointer-events: none; opacity: 1; transition: opacity 0.5s ease; - aspect-ratio: 16 / 10; - border-radius: clamp(24px, 8vw, 48px); + aspect-ratio: 16 / 9; + border-radius: 0; position: relative; overflow: hidden; - background: rgba(17, 18, 26, 0.65); + background: transparent; + flex: 1 1 auto; } #hero-stage img { @@ -435,27 +436,7 @@ body[data-theme='light'] code { } #hero-stage::before { - content: ""; - position: absolute; - inset: 0; - border-radius: inherit; - background: radial-gradient(circle at 32% 28%, rgba(10, 189, 198, 0.28), transparent 62%), - radial-gradient(circle at 72% 74%, rgba(247, 127, 0, 0.22), transparent 68%), - linear-gradient(165deg, rgba(14, 6, 6, 0.92), rgba(32, 8, 8, 0.88)); - transition: opacity 0.4s ease; - opacity: 0; - z-index: 1; -} - -#hero-stage[data-state="empty"]::before, -#hero-stage[data-state="idle"]::before, -#hero-stage[data-state="loading"]::before { - opacity: 1; -} - -#hero-stage[data-state="error"]::before { - opacity: 1; - background: linear-gradient(140deg, rgba(255, 94, 94, 0.32), rgba(28, 6, 6, 0.9)); + content: none; } body.js-enabled #hero-stage { @@ -470,10 +451,6 @@ body.no-js #hero-stage { opacity: 1; } -body.no-js #hero-stage::before { - opacity: 1; -} - .status-banner { width: 100%; display: flex; @@ -486,18 +463,18 @@ body.no-js #hero-stage::before { margin-top: 12px; padding: 12px 18px; border-radius: 14px; - border: 1px solid rgba(255, 198, 0, 0.4); - background: var(--compat-warning); - color: #e4f7ff; + border: none; + background: transparent; + color: var(--muted-text); font-size: 0.95rem; line-height: 1.6; text-align: center; max-width: min(520px, 100%); - box-shadow: 0 18px 34px rgba(255, 198, 0, 0.18); + box-shadow: none; } body[data-theme='light'] .compatibility-notice { - color: #5f3701; + color: var(--text-color); } .no-js-banner { @@ -505,8 +482,8 @@ body[data-theme='light'] .compatibility-notice { padding: 12px 20px; width: min(960px, calc(100% - 32px)); border-radius: 16px; - border: 1px solid var(--border-color); - background: rgba(12, 13, 20, 0.72); + border: none; + background: transparent; color: var(--muted-text); font-size: clamp(0.9rem, 2vw, 1rem); line-height: 1.5; @@ -514,7 +491,6 @@ body[data-theme='light'] .compatibility-notice { } body[data-theme='light'] .no-js-banner { - background: rgba(255, 255, 255, 0.82); color: var(--text-color); } @@ -530,8 +506,8 @@ body.no-js .mute-indicator { display: flex; flex-direction: column; align-items: center; - justify-content: center; - gap: clamp(24px, 8vh, 72px); + justify-content: flex-start; + gap: clamp(24px, 6vh, 56px); position: relative; z-index: 2; padding-bottom: clamp(8px, 3vh, 24px); @@ -541,32 +517,32 @@ body.no-js .mute-indicator { display: flex; justify-content: center; align-items: center; - gap: clamp(24px, 14vw, 140px); + gap: clamp(20px, 12vw, 120px); width: min(960px, 100%); + margin-top: auto; } .voice-circle { position: relative; - width: clamp(140px, 35vw, 220px); + width: clamp(140px, 32vw, 220px); aspect-ratio: 1 / 1; border-radius: 50%; - border: 1.5px solid var(--border-color); display: flex; align-items: center; justify-content: center; - backdrop-filter: blur(10px); isolation: isolate; overflow: hidden; - transition: border-color 0.4s ease, box-shadow 0.4s ease, transform 0.4s ease; + transition: box-shadow 0.4s ease, transform 0.4s ease; + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.18); } :root[data-theme='light'] .voice-circle { - border-color: rgba(0, 0, 0, 0.12); + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.24); } .pulse-ring { position: absolute; - inset: 12%; + inset: 22%; border-radius: 50%; border: 2px solid transparent; opacity: 0; @@ -582,8 +558,8 @@ body.no-js .mute-indicator { } .voice-circle.is-speaking { - box-shadow: 0 0 42px -18px rgba(10, 189, 198, 0.6); - transform: translateY(-6px) scale(1.03); + box-shadow: 0 0 0 2px rgba(10, 189, 198, 0.65), 0 0 32px rgba(10, 189, 198, 0.35); + transform: scale(1.08); } .voice-circle.is-speaking .pulse-ring { @@ -592,13 +568,11 @@ body.no-js .mute-indicator { } .voice-circle.is-listening { - border-color: rgba(52, 211, 153, 0.85); - box-shadow: 0 0 42px -12px rgba(52, 211, 153, 0.6); + box-shadow: 0 0 0 2px rgba(52, 211, 153, 0.75), 0 0 26px rgba(52, 211, 153, 0.35); } .voice-circle.is-error { - border-color: var(--accent-primary); - box-shadow: 0 0 38px -10px rgba(10, 189, 198, 0.5); + box-shadow: 0 0 0 2px var(--accent-primary), 0 0 26px rgba(10, 189, 198, 0.35); } .mute-indicator { @@ -607,7 +581,7 @@ body.no-js .mute-indicator { align-self: center; padding: 14px 24px; border-radius: 999px; - border: 1px solid var(--border-color); + border: none; color: var(--muted-text); font-size: clamp(0.9rem, 2.2vw, 1rem); letter-spacing: 0.04em; @@ -617,12 +591,13 @@ body.no-js .mute-indicator { align-items: center; justify-content: center; max-width: min(460px, calc(100% - 32px)); - backdrop-filter: blur(10px); + background: transparent; + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.24); } :root[data-theme='light'] .mute-indicator { - border-color: rgba(0, 0, 0, 0.16); color: var(--text-color); + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.22); } .mute-indicator:focus-visible { @@ -632,18 +607,17 @@ body.no-js .mute-indicator { .mute-indicator:hover, .mute-indicator:active { - transform: translateY(-2px) scale(1.01); + transform: scale(1.03); } .mute-indicator[data-state="listening"] { - border-color: rgba(67, 217, 189, 0.5); color: var(--text-color); + box-shadow: 0 0 0 2px rgba(67, 217, 189, 0.6), 0 0 32px rgba(67, 217, 189, 0.28); } .mute-indicator[data-state="compat"] { - border-color: rgba(255, 198, 0, 0.5); - background: rgba(64, 32, 4, 0.45); - color: #e4f7ff; + color: #f5f7fa; + box-shadow: 0 0 0 2px rgba(255, 198, 0, 0.6), 0 0 28px rgba(255, 198, 0, 0.3); } :root[data-theme='light'] .mute-indicator[data-state="compat"] { diff --git a/tests/test_auto_launch.py b/tests/test_auto_launch.py index be8b43a..56fd592 100644 --- a/tests/test_auto_launch.py +++ b/tests/test_auto_launch.py @@ -1,6 +1,8 @@ from pathlib import Path +import re -APP_JS = (Path(__file__).resolve().parents[1] / "AI" / "app.js").read_text() +ROOT = Path(__file__).resolve().parents[1] +APP_JS = (ROOT / "AI" / "app.js").read_text() def test_auto_start_without_landing_section(): @@ -8,3 +10,73 @@ def test_auto_start_without_landing_section(): assert "if (shouldAutoStartExperience)" in APP_JS assert "void startApplication();" in APP_JS + +def test_app_js_defines_expected_functions(): + function_names = set(re.findall(r"function[ \t]+(\w+)", APP_JS)) + expected = { + "formatDependencyList", + "setStatusMessage", + "updateLaunchButtonState", + "ensureCompatibilityBanner", + "showRecheckInProgress", + "resolveAssetPath", + "setLaunchButtonState", + "evaluateDependencies", + "updateDependencyUI", + "startApplication", + "setMutedState", + "applyTheme", + "setCircleState", + "loadSystemPrompt", + "setupSpeechRecognition", + "initializeVoiceControl", + "requestMicPermission", + "updateMuteIndicator", + "showMicrophonePermissionRequest", + "attemptUnmute", + "handleMuteToggle", + "isLikelyUrlSegment", + "removeMarkdownLinkTargets", + "removeCommandArtifacts", + "sanitizeForSpeech", + "sanitizeImageUrl", + "shouldRequestFallbackImage", + "cleanFallbackPrompt", + "buildFallbackImagePrompt", + "buildPollinationsImageUrl", + "extractImageUrl", + "escapeRegExp", + "removeImageReferences", + "normalizeCommandValue", + "parseAiDirectives", + "executeAiCommand", + "speak", + "handleVoiceCommand", + "isUnityDomain", + "shouldUseUnityReferrer", + "getAIResponse", + "getImageUrl", + "updateHeroImage", + "copyImageToClipboard", + "saveImage", + "openImageInNewTab", + } + missing = sorted(expected - function_names) + assert not missing, f"Missing functions from app.js: {missing}" + + +def test_start_application_sets_app_state(): + match = re.search(r"async function startApplication\([^)]*\)\s*{(.*?)}\s*async function", APP_JS, re.S) + assert match, "startApplication definition not found" + body = match.group(1) + assert "appStarted = true;" in body or "appStarted = !0" in body + assert "bodyElement.dataset.appState = 'experience';" in body + + +def test_set_muted_state_announces_changes(): + match = re.search(r"async function setMutedState\([^)]*\)\s*{(.*?)}\s*async function", APP_JS, re.S) + assert match, "setMutedState definition not found" + body = match.group(1) + assert "isMuted = muted;" in body or "isMuted = true;" in body + assert "updateMuteIndicator();" in body + assert "announce" in body diff --git a/tests/test_landing_js.py b/tests/test_landing_js.py new file mode 100644 index 0000000..d9b831c --- /dev/null +++ b/tests/test_landing_js.py @@ -0,0 +1,36 @@ +from pathlib import Path +import re + +ROOT = Path(__file__).resolve().parents[1] +LANDING_JS = (ROOT / "landing.js").read_text() + + +def test_landing_js_defines_expected_functions(): + function_names = set(re.findall(r"function[ \t]+(\w+)", LANDING_JS)) + expected = { + "formatDependencyList", + "getDependencyStatuses", + "setStatusMessage", + "updateLaunchButtonState", + "showRecheckInProgress", + "setLaunchButtonState", + "evaluateDependencies", + "updateDependencyUI", + } + missing = sorted(expected - function_names) + assert not missing, f"Missing landing.js functions: {missing}" + + +def test_landing_evaluate_dependencies_tracks_missing_items(): + match = re.search(r"function evaluateDependencies\([^)]*\)\s*{(.*?)}\s*function", LANDING_JS, re.S) + assert match, "evaluateDependencies definition not found" + body = match.group(1) + assert "const results = dependencyChecks.map" in body + assert "const missing = results.filter" in body + assert "updateDependencyUI(results, allMet" in body + + +def test_landing_dom_ready_hooks_present(): + assert "document.addEventListener('DOMContentLoaded'" in LANDING_JS + assert "launchButton?.addEventListener('click'" in LANDING_JS + assert "recheckButton?.addEventListener('click'" in LANDING_JS diff --git a/tests/test_theme.py b/tests/test_theme.py index 0aa299f..547efd1 100644 --- a/tests/test_theme.py +++ b/tests/test_theme.py @@ -1,24 +1,34 @@ from pathlib import Path +import re ROOT = Path(__file__).resolve().parents[1] -def test_voice_theme_has_no_red_tokens(): - css = (ROOT / "style.css").read_text() - forbidden_tokens = [ - "255, 62, 62", - "#ff3e3e", - "#ff2727", - "#ff6161", - "rgba(255, 62", - ] - for token in forbidden_tokens: - assert token not in css, f"found legacy red accent token: {token}" +def strip_css_comments(css: str) -> str: + return re.sub(r"/\*.*?\*/", "", css, flags=re.S) -def test_voice_experience_uses_dark_overrides(): - overrides = (ROOT / "AI" / "ai.css").read_text() - assert "linear-gradient(160deg, #020203" in overrides - assert "border-color: rgba(10, 189, 198" in overrides - assert "voice-stage" in overrides +def test_voice_theme_has_no_legacy_gradients(): + css = strip_css_comments((ROOT / "AI" / "ai.css").read_text()) + assert "linear-gradient" not in css + assert "backdrop-filter" not in css + +def test_experience_background_is_flat_black(): + css = (ROOT / "AI" / "ai.css").read_text() + assert "body[data-app-state='experience'] {" in css + assert "background: #000000;" in css + + +def test_voice_stage_uses_transparent_shell(): + css = (ROOT / "AI" / "ai.css").read_text() + assert ".voice-stage" in css + assert "background: transparent;" in css + assert "border: none;" in css + + +def test_root_style_is_single_tone(): + css = strip_css_comments((ROOT / "style.css").read_text()) + assert "--background-color: #000000;" in css + assert "background: var(--background-color);" in css + assert "linear-gradient" not in css