diff --git a/.github/workflows/build-whisper-stt.yml b/.github/workflows/build-whisper-stt.yml new file mode 100644 index 000000000..46b528460 --- /dev/null +++ b/.github/workflows/build-whisper-stt.yml @@ -0,0 +1,199 @@ +name: Build whisper-stt binaries + +# Builds the whisper.cpp-based `whisper-stt-server` helper for each desktop +# platform and uploads the binary + ggml backend sidecars as GitHub artifacts +# so `build.yml` can bundle them into installers. +# +# Triggered: +# * manually via workflow_dispatch (release-blocking binary refresh) +# * automatically when the helper, build script, or this workflow changes +# +# ponytail: one binary per platform is enough because whisper.cpp selects the +# right backend at runtime (Metal on Apple Silicon, Vulkan on Windows/Linux, +# CPU fallback when no GPU/driver is available). A CUDA variant is supported +# by the build script but is not built by default; Vulkan already accelerates +# NVIDIA cards. + +on: + workflow_dispatch: + inputs: + enable_cuda: + description: "Also build a CUDA variant when the host has an nvcc toolchain" + required: false + default: "false" + type: choice + options: + - "true" + - "false" + push: + paths: + - "scripts/build-whisper-stt.sh" + - "electron/native/whisper-stt/**" + - ".github/workflows/build-whisper-stt.yml" + +permissions: + contents: read + +jobs: + build: + name: ${{ matrix.label }} + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + arch: arm64 + # Matches `os_arch_tag()` in scripts/build-whisper-stt.sh — this is + # the directory name the build script actually stages into + # (electron/native/bin//), NOT `${{ matrix.os }}-${{ matrix.arch }}`. + tag: darwin-arm64 + label: macOS arm64 (Metal) + vulkan: false + - os: macos-15-intel + arch: x64 + tag: darwin-x64 + label: macOS x64 (CPU) + vulkan: false + - os: ubuntu-latest + arch: x64 + tag: linux-x64 + label: Linux x64 (Vulkan + CPU fallback) + vulkan: true + - os: windows-latest + arch: x64 + tag: win32-x64 + label: Windows x64 (Vulkan + CPU fallback) + vulkan: true + runs-on: ${{ matrix.os }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: ./.github/actions/setup + + - name: Install Ninja (Linux) + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get update + sudo apt-get install -y ninja-build build-essential + + - name: Install Ninja (macOS) + if: startsWith(matrix.os, 'macos') + run: brew install ninja + + - name: Setup MSVC (Windows) + if: matrix.os == 'windows-latest' + uses: ilammy/msvc-dev-cmd@v1 + with: + arch: x64 + + - name: Install Vulkan SDK + if: matrix.vulkan + # ponytail: humbletim/setup-vulkan-sdk builds Glslang from source via + # CMake ExternalProject, which fails ("ENABLE_OPT set but SPIR-V + # tools not found") because Glslang's build needs a sibling + # SPIRV-Tools checkout it doesn't fetch on its own — reproduced on + # both the Linux and Windows legs. jakoch/install-vulkan-sdk-action + # downloads LunarG's official prebuilt SDK (glslc included, no + # compilation), which is both more reliable and much faster. + uses: jakoch/install-vulkan-sdk-action@v1 + with: + vulkan_version: 1.4.304.1 + install_runtime: false + cache: true + + - name: Put glslc on PATH + if: matrix.vulkan + shell: bash + # Belt-and-braces: the action exports VULKAN_SDK but its own PATH + # handling isn't documented in enough detail to rely on for a tool + # (glslc) the ggml Vulkan backend's CMake configure step needs to find. + run: | + set -euo pipefail + for cand in "${VULKAN_SDK}/Bin" "${VULKAN_SDK}/bin"; do + if [[ -d "${cand}" ]]; then + echo "${cand}" >> "$GITHUB_PATH" + fi + done + + - name: Install SPIRV-Headers (Windows only) + if: matrix.os == 'windows-latest' + shell: bash + # ponytail: ggml-vulkan's CMakeLists does + # `find_package(SPIRV-Headers CONFIG REQUIRED)`. The Linux LunarG SDK + # tarball bundles that CMake config alongside the SDK, so the Linux + # leg resolves it for free; the Windows installer .exe (as fetched by + # jakoch/install-vulkan-sdk-action) does not ship it at all, so + # find_package fails with "Could not find a package configuration + # file". vcpkg (preinstalled on GitHub's windows-latest image) ships + # a spirv-headers port that provides the missing config; point + # CMAKE_PREFIX_PATH at its install dir so ggml's own + # `if (DEFINED ENV{VULKAN_SDK}) list(APPEND CMAKE_PREFIX_PATH ...)` + # logic has a second, working prefix to fall back to. + run: | + set -euo pipefail + VCPKG_ROOT_DIR="$(dirname "$(command -v vcpkg)")" + "${VCPKG_ROOT_DIR}/vcpkg" install spirv-headers:x64-windows + echo "CMAKE_PREFIX_PATH=${VCPKG_ROOT_DIR}/installed/x64-windows" >> "$GITHUB_ENV" + + - name: Cache whisper.cpp build tree + uses: actions/cache@v4 + with: + # Matches scripts/build-whisper-stt.sh's own BUILD_ROOT default for + # each OS (short `C:/wstbuild` on Windows to dodge the vulkan-shaders-gen + # MAX_PATH issue; `.cache/whisper-stt-build` elsewhere) — cache the + # FetchContent checkout + object files directly, no env override needed. + path: ${{ runner.os == 'Windows' && 'C:/wstbuild' || '.cache/whisper-stt-build' }} + # Keyed on the pinned WHISPER_REF/backend flags in CMakeLists.txt so a + # bump there invalidates the cache instead of silently reusing a stale + # FetchContent checkout; falls back to the newest cache for the same + # platform + runner image on a miss so incremental compilation still + # helps. The runner image version ($ImageOS/$ImageVersion) is part of + # the key AND the restore-keys prefix because CMake bakes absolute + # toolchain paths (e.g. the Xcode SDK's libz.tbd) into the cached build + # tree — when GitHub rolls the image's Xcode/SDK, those paths vanish and + # a restored tree fails with "No rule to make target …libz.tbd". Scoping + # the cache to the image version auto-busts it on every toolchain roll. + key: whisper-stt-build-${{ matrix.tag }}-${{ env.ImageOS }}-${{ env.ImageVersion }}-${{ hashFiles('electron/native/whisper-stt/CMakeLists.txt') }} + restore-keys: | + whisper-stt-build-${{ matrix.tag }}-${{ env.ImageOS }}-${{ env.ImageVersion }}- + + - name: Run whisper-stt build script + env: + ENABLE_CUDA: ${{ github.event.inputs.enable_cuda || 'false' }} + run: bash scripts/build-whisper-stt.sh + + - name: Stage binaries for upload + shell: bash + run: | + set -euo pipefail + BAG="whisper-stt-${{ matrix.tag }}" + mkdir -p "$BAG" + # Copy the whole per-platform directory: the helper executable plus + # every ggml backend sidecar/library it needs at runtime. + cp -v "electron/native/bin/${{ matrix.tag }}"/* "$BAG/" + tar -czf "${BAG}.tar.gz" "$BAG" + echo "Staged ${BAG}.tar.gz" + + - name: Upload binaries + uses: actions/upload-artifact@v4 + with: + name: whisper-stt-${{ matrix.tag }} + path: whisper-stt-${{ matrix.tag }}.tar.gz + if-no-files-found: error + retention-days: 30 + + - name: Workflow summary + if: always() + # Explicit shell: the bash `{ ... } >> file` grouping syntax below is + # not valid PowerShell, which is the default `run:` shell on Windows + # runners — this step silently failed on Windows without this. + shell: bash + run: | + { + echo "## whisper-stt build" + echo "" + echo "- Matrix: \`${{ matrix.label }}\`" + echo "- Result: ${{ job.status }}" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6487cbb17..625966ab3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,399 +1,406 @@ -name: Build Electron App - -on: - push: - tags: - - "v*" - workflow_dispatch: - inputs: - arch: - description: "macOS architecture to build" - required: true - default: "both" - type: choice - options: - - arm64 - - x64 - - both - release_tag: - description: "Optional release tag to create or update, e.g. v1.5.0" - required: false - type: string - -permissions: - contents: write - -concurrency: - group: build-${{ github.ref_name }}-${{ github.event.inputs.release_tag || 'artifacts' }} - cancel-in-progress: false - -jobs: - build-windows: - name: Windows installer - runs-on: windows-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: ./.github/actions/setup - - - name: Cache caption assets - uses: actions/cache@v4 - with: - path: caption-assets - key: caption-assets-${{ runner.os }}-${{ hashFiles('scripts/fetch-caption-model.mjs') }} - - - name: Build Windows app - run: npm run build:win -- --publish never - - - name: Upload Windows installer - uses: actions/upload-artifact@v4 - with: - name: openscreen-windows - path: release/**/Openscreen.Setup.*.exe - if-no-files-found: error - retention-days: 30 - - build-macos: - name: macOS ${{ matrix.arch }} DMG - runs-on: macos-latest - strategy: - fail-fast: false - matrix: - arch: ${{ fromJSON((github.event_name == 'workflow_dispatch' && github.event.inputs.arch != 'both') && format('["{0}"]', github.event.inputs.arch) || '["arm64", "x64"]') }} - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: ./.github/actions/setup - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - - name: Ensure sharp prebuilt - run: npm rebuild sharp - env: - npm_config_build_from_source: "false" - - - name: Cache caption assets - uses: actions/cache@v4 - with: - path: caption-assets - key: caption-assets-${{ runner.os }}-${{ hashFiles('scripts/fetch-caption-model.mjs') }} - - - name: Resolve macOS signing - id: signing - env: - MAC_CERTIFICATE_P12: ${{ secrets.MAC_CERTIFICATE_P12 }} - MAC_CERTIFICATE_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }} - MAC_CSC_NAME: ${{ secrets.MAC_CSC_NAME }} - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} - run: | - if [[ -n "$MAC_CERTIFICATE_P12" && -n "$MAC_CERTIFICATE_PASSWORD" && -n "$MAC_CSC_NAME" && -n "$APPLE_ID" && -n "$APPLE_TEAM_ID" && -n "$APPLE_APP_SPECIFIC_PASSWORD" ]]; then - echo "enabled=true" >> "$GITHUB_OUTPUT" - else - echo "enabled=false" >> "$GITHUB_OUTPUT" - fi - - - name: Import code signing certificate - if: steps.signing.outputs.enabled == 'true' - env: - MAC_CERTIFICATE_P12: ${{ secrets.MAC_CERTIFICATE_P12 }} - MAC_CERTIFICATE_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }} - run: | - KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db" - KEYCHAIN_PASSWORD="$(openssl rand -base64 32)" - - security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" - security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - - echo "$MAC_CERTIFICATE_P12" | base64 --decode > "$RUNNER_TEMP/certificate.p12" - security import "$RUNNER_TEMP/certificate.p12" \ - -k "$KEYCHAIN_PATH" \ - -P "$MAC_CERTIFICATE_PASSWORD" \ - -T /usr/bin/codesign \ - -T /usr/bin/security - - security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | tr -d '"') - security find-identity -v -p codesigning "$KEYCHAIN_PATH" - rm -f "$RUNNER_TEMP/certificate.p12" - - - name: Build Vite + Electron - run: npx tsc && npx vite build - - - name: Build native macOS helpers - run: npm run build:native:mac - env: - OPENSCREEN_MAC_HELPER_ARCHS: ${{ matrix.arch }} - - - name: Package .app bundle - run: npx electron-builder --mac --${{ matrix.arch }} --dir --publish never - env: - CSC_NAME: ${{ secrets.MAC_CSC_NAME }} - CSC_IDENTITY_AUTO_DISCOVERY: ${{ steps.signing.outputs.enabled == 'true' && 'true' || 'false' }} - - - name: Get version - id: version - run: | - VERSION="$(node -e "console.log(require('./package.json').version)")" - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - - - name: Find .app bundle - id: find_app - run: | - VERSION="${{ steps.version.outputs.version }}" - APP_BUNDLE="$(find "release/${VERSION}" -maxdepth 4 -name "*.app" -type d | head -n1)" - if [[ -z "$APP_BUNDLE" ]]; then - echo "::error::No .app bundle found in release/${VERSION}/" - find "release/${VERSION}" -maxdepth 4 -print || true - exit 1 - fi - echo "app_bundle=$APP_BUNDLE" >> "$GITHUB_OUTPUT" - - - name: Verify .app code signature - if: steps.signing.outputs.enabled == 'true' - run: codesign --verify --deep --strict "${{ steps.find_app.outputs.app_bundle }}" - - - name: Create DMG - id: dmg - run: | - VERSION="${{ steps.version.outputs.version }}" - ARCH="${{ matrix.arch }}" - DMG_NAME="Openscreen-Mac-${ARCH}-${VERSION}.dmg" - RELEASE_DIR="release/${VERSION}" - DMG_OUTPUT="${RELEASE_DIR}/${DMG_NAME}" - STAGING="${RELEASE_DIR}/dmg-staging" - - rm -rf "$STAGING" - rm -f "$DMG_OUTPUT" - mkdir -p "$STAGING" - cp -R "${{ steps.find_app.outputs.app_bundle }}" "$STAGING/" - ln -s /Applications "$STAGING/Applications" - - hdiutil create \ - -srcfolder "$STAGING" \ - -volname "Openscreen" \ - -fs HFS+ \ - -fsargs "-c c=64,a=16,e=16" \ - -format UDBZ \ - "$DMG_OUTPUT" - - rm -rf "$STAGING" - echo "dmg_path=$DMG_OUTPUT" >> "$GITHUB_OUTPUT" - - - name: Sign DMG - if: steps.signing.outputs.enabled == 'true' && !contains(github.ref_name, '-') - run: | - codesign --force \ - --sign "${{ secrets.MAC_CSC_NAME }}" \ - --timestamp \ - "${{ steps.dmg.outputs.dmg_path }}" - - - name: Notarize DMG - if: steps.signing.outputs.enabled == 'true' && !contains(github.ref_name, '-') - run: | - xcrun notarytool submit "${{ steps.dmg.outputs.dmg_path }}" \ - --apple-id "${{ secrets.APPLE_ID }}" \ - --team-id "${{ secrets.APPLE_TEAM_ID }}" \ - --password "${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}" \ - --wait - timeout-minutes: 15 - - - name: Staple notarization ticket - if: steps.signing.outputs.enabled == 'true' && !contains(github.ref_name, '-') - run: xcrun stapler staple "${{ steps.dmg.outputs.dmg_path }}" - - - name: Validate stapled DMG - if: steps.signing.outputs.enabled == 'true' && !contains(github.ref_name, '-') - run: | - xcrun stapler validate "${{ steps.dmg.outputs.dmg_path }}" - spctl -a -vv -t install "${{ steps.dmg.outputs.dmg_path }}" - - - name: Upload macOS DMG - uses: actions/upload-artifact@v4 - with: - name: openscreen-mac-${{ matrix.arch }} - path: ${{ steps.dmg.outputs.dmg_path }} - if-no-files-found: error - retention-days: 30 - - - name: Cleanup keychain - if: always() && steps.signing.outputs.enabled == 'true' - run: security delete-keychain "$RUNNER_TEMP/build.keychain-db" || true - - build-linux: - name: Linux packages - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: ./.github/actions/setup - - - name: Install pacman build dependencies - run: sudo apt-get update && sudo apt-get install -y libarchive-tools - - - name: Cache caption assets - uses: actions/cache@v4 - with: - path: caption-assets - key: caption-assets-${{ runner.os }}-${{ hashFiles('scripts/fetch-caption-model.mjs') }} - - - name: Build Linux app - run: npm run build:linux -- --publish never - - - name: Upload Linux packages - uses: actions/upload-artifact@v4 - with: - name: openscreen-linux - path: | - release/**/*.AppImage - release/**/*.zsync - release/**/*.deb - release/**/*.pacman - if-no-files-found: error - retention-days: 30 - - publish-release: - name: Publish GitHub release - runs-on: ubuntu-latest - needs: - - build-windows - - build-macos - - build-linux - if: ${{ (github.event_name == 'push' && github.ref_type == 'tag') || (github.event_name == 'workflow_dispatch' && github.event.inputs.release_tag != '') }} - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Resolve release tag - id: release - env: - INPUT_TAG: ${{ github.event.inputs.release_tag }} - run: | - if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then - TAG="${GITHUB_REF_NAME}" - else - TAG="${INPUT_TAG}" - fi - - if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-(rc|beta|alpha)\.[0-9]+)?$ ]]; then - echo "::error::Release tag must look like v1.5.0 or v1.5.0-rc.1; got '${TAG}'" - exit 1 - fi - - VERSION="${TAG#v}" - # For an RC tag (e.g. v1.5.0-rc.1) package.json is at the pre-release version - # (1.5.0-rc.1), not the stable version (1.5.0). Compare against the full tag version. - PACKAGE_VERSION="$(node -p 'require("./package.json").version')" - if [[ "$PACKAGE_VERSION" != "$VERSION" ]]; then - echo "::error::package.json version ${PACKAGE_VERSION} does not match ${VERSION} from tag ${TAG}" - exit 1 - fi - - if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-(rc|beta|alpha)\.[0-9]+$ ]]; then - PRERELEASE_FLAG="--prerelease" - IS_PRERELEASE="true" - else - PRERELEASE_FLAG="" - IS_PRERELEASE="false" - fi - - # Compute the previous stable tag for auto-generated release notes. We don't use - # GitHub's "most recent prior release by date" because the fork carries re-published - # upstream releases whose published_at is more recent than the fork's own first release. - # For SemVer X.Y.Z: previous is vX.Y.(Z-1) if Z>0, else vX.(Y-1).0, else v(X-1).0.0. - STABLE_VERSION="${VERSION%%-*}" - IFS='.' read -r PX PY PZ <<< "$STABLE_VERSION" - if (( PZ > 0 )); then - NOTES_START_TAG="v${PX}.${PY}.$((PZ - 1))" - elif (( PY > 0 )); then - NOTES_START_TAG="v${PX}.$((PY - 1)).0" - else - NOTES_START_TAG="v$((PX - 1)).0.0" - fi - echo "Computed notes_start_tag=${NOTES_START_TAG} for tag=${TAG}" - - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "stable_version=$STABLE_VERSION" >> "$GITHUB_OUTPUT" - echo "is_prerelease=$IS_PRERELEASE" >> "$GITHUB_OUTPUT" - echo "prerelease_flag=$PRERELEASE_FLAG" >> "$GITHUB_OUTPUT" - echo "notes_start_tag=$NOTES_START_TAG" >> "$GITHUB_OUTPUT" - - - name: Download Windows installer - uses: actions/download-artifact@v4 - with: - name: openscreen-windows - path: artifacts/windows - - - name: Download macOS arm64 DMG - continue-on-error: true - uses: actions/download-artifact@v4 - with: - name: openscreen-mac-arm64 - path: artifacts/mac-arm64 - - - name: Download macOS x64 DMG - continue-on-error: true - uses: actions/download-artifact@v4 - with: - name: openscreen-mac-x64 - path: artifacts/mac-x64 - - - name: Download Linux packages - uses: actions/download-artifact@v4 - with: - name: openscreen-linux - path: artifacts/linux - - - name: Publish release assets - env: - GH_TOKEN: ${{ secrets.OPENSCREEN_RELEASE_TOKEN }} - TAG: ${{ steps.release.outputs.tag }} - PRERELEASE_FLAG: ${{ steps.release.outputs.prerelease_flag }} - NOTES_START_TAG: ${{ steps.release.outputs.notes_start_tag }} - run: | - mapfile -t FILES < <(find artifacts -type f | sort) - if [[ "${#FILES[@]}" -eq 0 ]]; then - echo "::error::No installer artifacts were downloaded" - exit 1 - fi - - if gh release view "$TAG" >/dev/null 2>&1; then - gh release upload "$TAG" "${FILES[@]}" --clobber - else - # --notes-start-tag controls which previous tag GitHub compares against - # when auto-generating the release notes. Default behaviour (most recent - # prior release by date) doesn't work for this fork because the v1.4.0 - # release in the fork was re-published after v1.5.0, which makes GitHub - # pick v1.4.0 as the "previous" for any v1.5.x release. - # shellcheck disable=SC2086 - gh release create "$TAG" "${FILES[@]}" \ - --target "$GITHUB_SHA" \ - --title "$TAG" \ - --generate-notes \ - --notes-start-tag "$NOTES_START_TAG" \ - $PRERELEASE_FLAG - fi - - if [[ -n "$PRERELEASE_FLAG" ]]; then - gh release edit "$TAG" \ - --draft=false \ - --latest=false \ - --title "$TAG" - else - gh release edit "$TAG" \ - --draft=false \ - --latest \ - --title "$TAG" - fi +name: Build Electron App + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + arch: + description: "macOS architecture to build" + required: true + default: "both" + type: choice + options: + - arm64 + - x64 + - both + release_tag: + description: "Optional release tag to create or update, e.g. v1.5.0" + required: false + type: string + +permissions: + contents: write + +concurrency: + group: build-${{ github.ref_name }}-${{ github.event.inputs.release_tag || 'artifacts' }} + cancel-in-progress: false + +jobs: + build-windows: + name: Windows installer + runs-on: windows-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: ./.github/actions/setup + + - name: Cache caption assets + uses: actions/cache@v4 + with: + path: caption-assets + key: caption-assets-${{ runner.os }}-${{ hashFiles('scripts/fetch-caption-model.mjs') }} + + # ponytail: STT is handled by the bundled whisper-stt-server (whisper.cpp + # with native DTW token timestamps). No separate VAD model is fetched + # here. See docs/engineering/stt-spec.md for the full architecture. + + - name: Build Windows app + run: npm run build:win -- --publish never + + - name: Upload Windows installer + uses: actions/upload-artifact@v4 + with: + name: openscreen-windows + path: release/**/Openscreen.Setup.*.exe + if-no-files-found: error + retention-days: 30 + + build-macos: + name: macOS ${{ matrix.arch }} DMG + runs-on: macos-latest + strategy: + fail-fast: false + matrix: + arch: ${{ fromJSON((github.event_name == 'workflow_dispatch' && github.event.inputs.arch != 'both') && format('["{0}"]', github.event.inputs.arch) || '["arm64", "x64"]') }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: ./.github/actions/setup + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Ensure sharp prebuilt + run: npm rebuild sharp + env: + npm_config_build_from_source: "false" + + - name: Cache caption assets + uses: actions/cache@v4 + with: + path: caption-assets + key: caption-assets-${{ runner.os }}-${{ hashFiles('scripts/fetch-caption-model.mjs') }} + + # ponytail: STT is handled by the bundled whisper-stt-server (see the + # build-windows comment); no VAD model is fetched here. + - name: Resolve macOS signing + id: signing + env: + MAC_CERTIFICATE_P12: ${{ secrets.MAC_CERTIFICATE_P12 }} + MAC_CERTIFICATE_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }} + MAC_CSC_NAME: ${{ secrets.MAC_CSC_NAME }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + run: | + if [[ -n "$MAC_CERTIFICATE_P12" && -n "$MAC_CERTIFICATE_PASSWORD" && -n "$MAC_CSC_NAME" && -n "$APPLE_ID" && -n "$APPLE_TEAM_ID" && -n "$APPLE_APP_SPECIFIC_PASSWORD" ]]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + else + echo "enabled=false" >> "$GITHUB_OUTPUT" + fi + + - name: Import code signing certificate + if: steps.signing.outputs.enabled == 'true' + env: + MAC_CERTIFICATE_P12: ${{ secrets.MAC_CERTIFICATE_P12 }} + MAC_CERTIFICATE_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }} + run: | + KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db" + KEYCHAIN_PASSWORD="$(openssl rand -base64 32)" + + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + echo "$MAC_CERTIFICATE_P12" | base64 --decode > "$RUNNER_TEMP/certificate.p12" + security import "$RUNNER_TEMP/certificate.p12" \ + -k "$KEYCHAIN_PATH" \ + -P "$MAC_CERTIFICATE_PASSWORD" \ + -T /usr/bin/codesign \ + -T /usr/bin/security + + security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | tr -d '"') + security find-identity -v -p codesigning "$KEYCHAIN_PATH" + rm -f "$RUNNER_TEMP/certificate.p12" + + - name: Build Vite + Electron + run: npx tsc && npx vite build + + - name: Build native macOS helpers + run: npm run build:native:mac + env: + OPENSCREEN_MAC_HELPER_ARCHS: ${{ matrix.arch }} + + - name: Package .app bundle + run: npx electron-builder --mac --${{ matrix.arch }} --dir --publish never + env: + CSC_NAME: ${{ secrets.MAC_CSC_NAME }} + CSC_IDENTITY_AUTO_DISCOVERY: ${{ steps.signing.outputs.enabled == 'true' && 'true' || 'false' }} + + - name: Get version + id: version + run: | + VERSION="$(node -e "console.log(require('./package.json').version)")" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Find .app bundle + id: find_app + run: | + VERSION="${{ steps.version.outputs.version }}" + APP_BUNDLE="$(find "release/${VERSION}" -maxdepth 4 -name "*.app" -type d | head -n1)" + if [[ -z "$APP_BUNDLE" ]]; then + echo "::error::No .app bundle found in release/${VERSION}/" + find "release/${VERSION}" -maxdepth 4 -print || true + exit 1 + fi + echo "app_bundle=$APP_BUNDLE" >> "$GITHUB_OUTPUT" + + - name: Verify .app code signature + if: steps.signing.outputs.enabled == 'true' + run: codesign --verify --deep --strict "${{ steps.find_app.outputs.app_bundle }}" + + - name: Create DMG + id: dmg + run: | + VERSION="${{ steps.version.outputs.version }}" + ARCH="${{ matrix.arch }}" + DMG_NAME="Openscreen-Mac-${ARCH}-${VERSION}.dmg" + RELEASE_DIR="release/${VERSION}" + DMG_OUTPUT="${RELEASE_DIR}/${DMG_NAME}" + STAGING="${RELEASE_DIR}/dmg-staging" + + rm -rf "$STAGING" + rm -f "$DMG_OUTPUT" + mkdir -p "$STAGING" + cp -R "${{ steps.find_app.outputs.app_bundle }}" "$STAGING/" + ln -s /Applications "$STAGING/Applications" + + hdiutil create \ + -srcfolder "$STAGING" \ + -volname "Openscreen" \ + -fs HFS+ \ + -fsargs "-c c=64,a=16,e=16" \ + -format UDBZ \ + "$DMG_OUTPUT" + + rm -rf "$STAGING" + echo "dmg_path=$DMG_OUTPUT" >> "$GITHUB_OUTPUT" + + - name: Sign DMG + if: steps.signing.outputs.enabled == 'true' && !contains(github.ref_name, '-') + run: | + codesign --force \ + --sign "${{ secrets.MAC_CSC_NAME }}" \ + --timestamp \ + "${{ steps.dmg.outputs.dmg_path }}" + + - name: Notarize DMG + if: steps.signing.outputs.enabled == 'true' && !contains(github.ref_name, '-') + run: | + xcrun notarytool submit "${{ steps.dmg.outputs.dmg_path }}" \ + --apple-id "${{ secrets.APPLE_ID }}" \ + --team-id "${{ secrets.APPLE_TEAM_ID }}" \ + --password "${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}" \ + --wait + timeout-minutes: 15 + + - name: Staple notarization ticket + if: steps.signing.outputs.enabled == 'true' && !contains(github.ref_name, '-') + run: xcrun stapler staple "${{ steps.dmg.outputs.dmg_path }}" + + - name: Validate stapled DMG + if: steps.signing.outputs.enabled == 'true' && !contains(github.ref_name, '-') + run: | + xcrun stapler validate "${{ steps.dmg.outputs.dmg_path }}" + spctl -a -vv -t install "${{ steps.dmg.outputs.dmg_path }}" + + - name: Upload macOS DMG + uses: actions/upload-artifact@v4 + with: + name: openscreen-mac-${{ matrix.arch }} + path: ${{ steps.dmg.outputs.dmg_path }} + if-no-files-found: error + retention-days: 30 + + - name: Cleanup keychain + if: always() && steps.signing.outputs.enabled == 'true' + run: security delete-keychain "$RUNNER_TEMP/build.keychain-db" || true + + build-linux: + name: Linux packages + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: ./.github/actions/setup + + - name: Install pacman build dependencies + run: sudo apt-get update && sudo apt-get install -y libarchive-tools + + - name: Cache caption assets + uses: actions/cache@v4 + with: + path: caption-assets + key: caption-assets-${{ runner.os }}-${{ hashFiles('scripts/fetch-caption-model.mjs') }} + + # ponytail: no VAD fetch here either (see build-windows comment). + - name: Build Linux app + run: npm run build:linux -- --publish never + + - name: Upload Linux packages + uses: actions/upload-artifact@v4 + with: + name: openscreen-linux + path: | + release/**/*.AppImage + release/**/*.zsync + release/**/*.deb + release/**/*.pacman + if-no-files-found: error + retention-days: 30 + + publish-release: + name: Publish GitHub release + runs-on: ubuntu-latest + needs: + - build-windows + - build-macos + - build-linux + if: ${{ (github.event_name == 'push' && github.ref_type == 'tag') || (github.event_name == 'workflow_dispatch' && github.event.inputs.release_tag != '') }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Resolve release tag + id: release + env: + INPUT_TAG: ${{ github.event.inputs.release_tag }} + run: | + if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then + TAG="${GITHUB_REF_NAME}" + else + TAG="${INPUT_TAG}" + fi + + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-(rc|beta|alpha)\.[0-9]+)?$ ]]; then + echo "::error::Release tag must look like v1.5.0 or v1.5.0-rc.1; got '${TAG}'" + exit 1 + fi + + VERSION="${TAG#v}" + # For an RC tag (e.g. v1.5.0-rc.1) package.json is at the pre-release version + # (1.5.0-rc.1), not the stable version (1.5.0). Compare against the full tag version. + PACKAGE_VERSION="$(node -p 'require("./package.json").version')" + if [[ "$PACKAGE_VERSION" != "$VERSION" ]]; then + echo "::error::package.json version ${PACKAGE_VERSION} does not match ${VERSION} from tag ${TAG}" + exit 1 + fi + + if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-(rc|beta|alpha)\.[0-9]+$ ]]; then + PRERELEASE_FLAG="--prerelease" + IS_PRERELEASE="true" + else + PRERELEASE_FLAG="" + IS_PRERELEASE="false" + fi + + # Compute the previous stable tag for auto-generated release notes. We don't use + # GitHub's "most recent prior release by date" because the fork carries re-published + # upstream releases whose published_at is more recent than the fork's own first release. + # For SemVer X.Y.Z: previous is vX.Y.(Z-1) if Z>0, else vX.(Y-1).0, else v(X-1).0.0. + STABLE_VERSION="${VERSION%%-*}" + IFS='.' read -r PX PY PZ <<< "$STABLE_VERSION" + if (( PZ > 0 )); then + NOTES_START_TAG="v${PX}.${PY}.$((PZ - 1))" + elif (( PY > 0 )); then + NOTES_START_TAG="v${PX}.$((PY - 1)).0" + else + NOTES_START_TAG="v$((PX - 1)).0.0" + fi + echo "Computed notes_start_tag=${NOTES_START_TAG} for tag=${TAG}" + + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "stable_version=$STABLE_VERSION" >> "$GITHUB_OUTPUT" + echo "is_prerelease=$IS_PRERELEASE" >> "$GITHUB_OUTPUT" + echo "prerelease_flag=$PRERELEASE_FLAG" >> "$GITHUB_OUTPUT" + echo "notes_start_tag=$NOTES_START_TAG" >> "$GITHUB_OUTPUT" + + - name: Download Windows installer + uses: actions/download-artifact@v4 + with: + name: openscreen-windows + path: artifacts/windows + + - name: Download macOS arm64 DMG + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: openscreen-mac-arm64 + path: artifacts/mac-arm64 + + - name: Download macOS x64 DMG + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: openscreen-mac-x64 + path: artifacts/mac-x64 + + - name: Download Linux packages + uses: actions/download-artifact@v4 + with: + name: openscreen-linux + path: artifacts/linux + + - name: Publish release assets + env: + GH_TOKEN: ${{ secrets.OPENSCREEN_RELEASE_TOKEN }} + TAG: ${{ steps.release.outputs.tag }} + PRERELEASE_FLAG: ${{ steps.release.outputs.prerelease_flag }} + NOTES_START_TAG: ${{ steps.release.outputs.notes_start_tag }} + run: | + mapfile -t FILES < <(find artifacts -type f | sort) + if [[ "${#FILES[@]}" -eq 0 ]]; then + echo "::error::No installer artifacts were downloaded" + exit 1 + fi + + if gh release view "$TAG" >/dev/null 2>&1; then + gh release upload "$TAG" "${FILES[@]}" --clobber + else + # --notes-start-tag controls which previous tag GitHub compares against + # when auto-generating the release notes. Default behaviour (most recent + # prior release by date) doesn't work for this fork because the v1.4.0 + # release in the fork was re-published after v1.5.0, which makes GitHub + # pick v1.4.0 as the "previous" for any v1.5.x release. + # shellcheck disable=SC2086 + gh release create "$TAG" "${FILES[@]}" \ + --target "$GITHUB_SHA" \ + --title "$TAG" \ + --generate-notes \ + --notes-start-tag "$NOTES_START_TAG" \ + $PRERELEASE_FLAG + fi + + if [[ -n "$PRERELEASE_FLAG" ]]; then + gh release edit "$TAG" \ + --draft=false \ + --latest=false \ + --title "$TAG" + else + gh release edit "$TAG" \ + --draft=false \ + --latest \ + --title "$TAG" + fi diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 000000000..9164f164d --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,65 @@ +name: Docs + +on: + pull_request: + branches: [main] + paths: + - "website/**" + - ".github/workflows/docs.yml" + push: + branches: [main] + paths: + - "website/**" + - ".github/workflows/docs.yml" + workflow_dispatch: + +# Cancel in-flight runs on the same ref so fast follow-up pushes +# don't queue stale builds. +concurrency: + group: docs-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + build: + name: Build site + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + with: + persist-credentials: false + - uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 + with: + node-version: 22 + cache: npm + cache-dependency-path: website/package-lock.json + - name: Install dependencies + working-directory: website + run: npm ci + - name: Type-check + working-directory: website + run: npm run typecheck + - name: Build + working-directory: website + run: npm run build + - name: Upload artifact + uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1 + with: + path: website/build + + deploy: + name: Deploy to GitHub Pages + runs-on: ubuntu-latest + needs: build + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + permissions: + pages: write + id-token: write + steps: + - id: deployment + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 61dc9d2fc..d687e2c66 100644 --- a/.gitignore +++ b/.gitignore @@ -1,68 +1,90 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-electron -dist-ssr -*.local -.env - -# Native helper build outputs -/electron/native/wgc-capture/build/ -/electron/native/screencapturekit/build/ -/electron/native/screencapturekit/.build/ -/electron/native/screencapturekit/.swiftpm/ -/electron/native/bin/ - -# Native macOS generated files -DerivedData/ -*.xcuserstate -xcuserdata/ - -# Editor directories and files -.vscode/* -.zed/ -!.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? -release/** -*.kiro/ -.claude/ -# npx electron-builder --mac --win - -# Playwright -test-results -playwright-report/ - - -# Vitest browser mode screenshots -__screenshots__/ - -# shell files -/shell.sh -# Nix -result -result-* -.direnv/ - -#kilocode -.kilo/ - -#others - -**/*.import - -# Auto-caption model + ORT wasm — regenerated at build by scripts/fetch-caption-model.mjs -/caption-assets/ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-electron +dist-ssr +*.local +.env + +# Native helper build outputs +/electron/native/wgc-capture/build/ +/electron/native/screencapturekit/build/ +/electron/native/screencapturekit/.build/ +/electron/native/screencapturekit/.swiftpm/ +/electron/native/bin/ + +# Native macOS generated files +DerivedData/ +*.xcuserstate +xcuserdata/ + +# Editor directories and files +.vscode/* +.zed/ +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +release/** +*.kiro/ +.claude/ +.worktrees/ +# npx electron-builder --mac --win + +# Playwright +test-results +playwright-report/ + + +# Vitest browser mode screenshots +__screenshots__/ + +# shell files +/shell.sh +# Nix +result +result-* +.direnv/ + +#kilocode +.kilo/ + +#others + +**/*.import + +# Auto-caption model + ORT wasm — regenerated at build by scripts/fetch-caption-model.mjs +/caption-assets/ + +# Native STT artifacts — whisper-stt-server binaries + ggml backend sidecars. +/electron/native/bin/ +/electron/native/models/ + +# Build cache for the native STT server (FetchContent clones + CMake build). +/.cache/ + +# whisper.cpp DTW POC (tools/stt-eval/whispercpp-dtw-poc) — vendored engine +# clone, downloaded GGML models, and generated build/results artifacts. +# See docs/engineering/stt-whispercpp-dtw-poc-plan.md. +/tools/stt-eval/whispercpp-dtw-poc/whisper.cpp/ +/tools/stt-eval/whispercpp-dtw-poc/build-cpu/ +/tools/stt-eval/whispercpp-dtw-poc/build-vulkan/ +/tools/stt-eval/whispercpp-dtw-poc/build-cuda/ +/tools/stt-eval/whispercpp-dtw-poc/build-harness/ +/tools/stt-eval/whispercpp-dtw-poc/fixtures/*.wav +/tools/stt-eval/whispercpp-dtw-poc/results/ + +opencode.json +opencode.json diff --git a/.opencode/prompts/playwright-test-generator.md b/.opencode/prompts/playwright-test-generator.md new file mode 100644 index 000000000..08f19919d --- /dev/null +++ b/.opencode/prompts/playwright-test-generator.md @@ -0,0 +1,53 @@ +You are a Playwright Test Generator, an expert in browser automation and end-to-end testing. +Your specialty is creating robust, reliable Playwright tests that accurately simulate user interactions and validate +application behavior. + +# For each test you generate +- Obtain the test plan with all the steps and verification specification +- Run the `generator_setup_page` tool to set up page for the scenario +- For each step and verification in the scenario, do the following: + - Use Playwright tool to manually execute it in real-time. + - Use the step description as the intent for each Playwright tool call. +- Retrieve generator log via `generator_read_log` +- Immediately after reading the test log, invoke `generator_write_test` with the generated source code + - File should contain single test + - File name must be fs-friendly scenario name + - Test must be placed in a describe matching the top-level test plan item + - Test title must match the scenario name + - Includes a comment with the step text before each step execution. Do not duplicate comments if step requires + multiple actions. + - Always use best practices from the log when generating tests. + + + For following plan: + + ```markdown file=specs/plan.md + ### 1. Adding New Todos + **Seed:** `tests/seed.spec.ts` + + #### 1.1 Add Valid Todo + **Steps:** + 1. Click in the "What needs to be done?" input field + + #### 1.2 Add Multiple Todos + ... + ``` + + Following file is generated: + + ```ts file=add-valid-todo.spec.ts + // spec: specs/plan.md + // seed: tests/seed.spec.ts + + test.describe('Adding New Todos', () => { + test('Add Valid Todo', async { page } => { + // 1. Click in the "What needs to be done?" input field + await page.click(...); + + ... + }); + }); + ``` + + +Context: User wants to generate a test for the test plan item. \ No newline at end of file diff --git a/.opencode/prompts/playwright-test-healer.md b/.opencode/prompts/playwright-test-healer.md new file mode 100644 index 000000000..70173c4d3 --- /dev/null +++ b/.opencode/prompts/playwright-test-healer.md @@ -0,0 +1,37 @@ +You are the Playwright Test Healer, an expert test automation engineer specializing in debugging and +resolving Playwright test failures. Your mission is to systematically identify, diagnose, and fix +broken Playwright tests using a methodical approach. + +Your workflow: +1. **Initial Execution**: Run all tests using `test_run` tool to identify failing tests +2. **Debug failed tests**: For each failing test run `test_debug`. +3. **Error Investigation**: When the test pauses on errors, use available Playwright MCP tools to: + - Examine the error details + - Capture page snapshot to understand the context + - Analyze selectors, timing issues, or assertion failures +4. **Root Cause Analysis**: Determine the underlying cause of the failure by examining: + - Element selectors that may have changed + - Timing and synchronization issues + - Data dependencies or test environment problems + - Application changes that broke test assumptions +5. **Code Remediation**: Edit the test code to address identified issues, focusing on: + - Updating selectors to match current application state + - Fixing assertions and expected values + - Improving test reliability and maintainability + - For inherently dynamic data, utilize regular expressions to produce resilient locators +6. **Verification**: Restart the test after each fix to validate the changes +7. **Iteration**: Repeat the investigation and fixing process until the test passes cleanly + +Key principles: +- Be systematic and thorough in your debugging approach +- Document your findings and reasoning for each fix +- Prefer robust, maintainable solutions over quick hacks +- Use Playwright best practices for reliable test automation +- If multiple errors exist, fix them one at a time and retest +- Provide clear explanations of what was broken and how you fixed it +- You will continue this process until the test runs successfully without any failures or errors. +- If the error persists and you have high level of confidence that the test is correct, mark this test as test.fixme() + so that it is skipped during the execution. Add a comment before the failing step explaining what is happening instead + of the expected behavior. +- Do not ask user questions, you are not interactive tool, do the most reasonable thing possible to pass the test. +- Never wait for networkidle or use other discouraged or deprecated apis diff --git a/.opencode/prompts/playwright-test-planner.md b/.opencode/prompts/playwright-test-planner.md new file mode 100644 index 000000000..59c50f171 --- /dev/null +++ b/.opencode/prompts/playwright-test-planner.md @@ -0,0 +1,44 @@ +You are an expert web test planner with extensive experience in quality assurance, user experience testing, and test +scenario design. Your expertise includes functional testing, edge case identification, and comprehensive test coverage +planning. + +You will: + +1. **Navigate and Explore** + - Invoke the `planner_setup_page` tool once to set up page before using any other tools + - Explore the browser snapshot + - Do not take screenshots unless absolutely necessary + - Use `browser_*` tools to navigate and discover interface + - Thoroughly explore the interface, identifying all interactive elements, forms, navigation paths, and functionality + +2. **Analyze User Flows** + - Map out the primary user journeys and identify critical paths through the application + - Consider different user types and their typical behaviors + +3. **Design Comprehensive Scenarios** + + Create detailed test scenarios that cover: + - Happy path scenarios (normal user behavior) + - Edge cases and boundary conditions + - Error handling and validation + +4. **Structure Test Plans** + + Each scenario must include: + - Clear, descriptive title + - Detailed step-by-step instructions + - Expected outcomes where appropriate + - Assumptions about starting state (always assume blank/fresh state) + - Success criteria and failure conditions + +5. **Create Documentation** + + Submit your test plan using `planner_save_plan` tool. + +**Quality Standards**: +- Write steps that are specific enough for any tester to follow +- Include negative testing scenarios +- Ensure scenarios are independent and can be run in any order + +**Output Format**: Always save the complete test plan as a markdown file with clear headings, numbered steps, and +professional formatting suitable for sharing with development and QA teams. diff --git a/biome.json b/biome.json index 517be7287..b7c87298d 100644 --- a/biome.json +++ b/biome.json @@ -1,7 +1,10 @@ { "$schema": "https://biomejs.dev/schemas/2.4.12/schema.json", "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, - "files": { "ignoreUnknown": false, "includes": ["**", "!**/*.css"] }, + "files": { + "ignoreUnknown": false, + "includes": ["**", "!**/*.css", "!**/design/**", "!**/.worktrees/**"] + }, "formatter": { "enabled": true, "indentStyle": "tab", @@ -92,7 +95,14 @@ "useGetterReturn": "error" } }, - "includes": ["**", "**/dist", "**/.eslintrc.cjs", "!**/*.css"] + "includes": [ + "**", + "**/dist", + "**/.eslintrc.cjs", + "!**/*.css", + "!**/design/**", + "!**/.worktrees/**" + ] }, "javascript": { "formatter": { "quoteStyle": "double" } }, "overrides": [ diff --git a/design/DESIGN.md b/design/DESIGN.md new file mode 100644 index 000000000..8450dcb04 --- /dev/null +++ b/design/DESIGN.md @@ -0,0 +1,156 @@ +# OpenScreen Design System + +> Category: Video & Productivity Tools +> Surface: Desktop web (Electron) +> Source: tokens extracted from `openscreen-editor-2.html` (canonical) + +A high-fidelity, professional dark-and-light desktop application design system +optimized for video recording, post-processing editing, and timeline +manipulation. Mint is the single brand accent; red is reserved strictly for +REC / cut / skip / trim / transcript-highlight states; amber covers warnings. + +--- + +## Color Palette + +### Light theme (`:root`) + +| Role | Token | Hex | Notes | +|---|---|---|---| +| Canvas | `--bg` | `#fafbfc` | Off-white, never pure white; soft radial gradient | +| Panel | `--surface` | `#ffffff` | Raised panels on canvas | +| Card | `--surface-1` | `#f7f8fa` | Tier 1 raised | +| Tier 2 | `--surface-2` | `#f3f5f8` | Raised on panel | +| Hover | `--surface-3` | `#eef0f3` | Active/hover state | +| Popover | `--surface-hi` | `#ffffff` | Highest elevation | +| Border | `--border` | `#e7e9ee` | Soft hairline | +| Border soft | `--border-soft` | `#f1f2f5` | Subtle dividers | +| Border hi | `--border-hi` | `#d1d5db` | Emphasis lines | +| Foreground | `--fg` | `#1f2937` | Slate, not pure black | +| Foreground emphasis | `--fg-emphasis` | `#111827` | Headlines | +| Muted | `--muted` | `#6b7280` | Body / labels | +| Meta | `--meta` | `#9ca3af` | Timestamps, captions | +| Brand (mint) | `--accent` | `#10b981` | Primary action, focus ring, toggle-on | +| Brand glow | `--brand-glow` | `rgba(16,185,129,0.35)` | Focus / hover | +| Danger | `--danger` | `#ef4444` | REC, skip, trim, transcript highlight | +| Warning | `--warn` | `#f59e0b` | Soft amber | +| Success | `--success` | `#10b981` | Alias of brand | + +### Dark theme (`:root[data-theme="dark"]`) + +| Role | Token | Hex | Notes | +|---|---|---|---| +| Canvas | `--bg` | `#0a0d12` | Near-black with faint blue cast | +| Panel | `--surface` | `#14181f` | Tier 1 panel | +| Card | `--surface-1` | `#14181f` | Same as panel base | +| Tier 2 | `--surface-2` | `#1c2029` | Raised | +| Hover | `--surface-3` | `#252a35` | Hover/active | +| Popover | `--surface-hi` | `#2e3440` | Highest | +| Border | `--border` | `#252a35` | Reads on canvas | +| Foreground | `--fg` | `#e6e9ef` | Off-white, never pure `#ffffff` | +| Muted | `--muted` | `#8b95a3` | Tuned for AA on dark | +| Brand (mint) | `--accent` | `#10b981` | Same hue, brighter tone on `--brand-lo: #34d399` | +| Danger | `--danger` | `#f87171` | Softer red for dark bg | + +### Traffic lights (macOS chrome) + +| Token | Hex | +|---|---| +| `--light-red` | `#ff5f57` | +| `--light-yellow` | `#febc2e` | +| `--light-green` | `#28c840` | + +--- + +## Typography + +- **Display:** system-ui stack — `system-ui, -apple-system, "Segoe UI", "Helvetica Neue", Arial, sans-serif` +- **Body:** system-ui stack (same as display) +- **Mono:** `ui-monospace, "SF Mono", Menlo, Monaco, Consolas, monospace` + +### Type scale (desktop editor, base 13px) + +| Token | Size | Use | +|---|---|---| +| `--fs-app` | 13px | Base UI | +| `--fs-app-sm` | 12px | Secondary | +| `--fs-app-lg` | 14px | Primary UI | +| `--fs-title` | 16px | Panel titles | +| `--fs-section` | 11px | Section headers (uppercase, tracked) | +| `--fs-display` | 24px | Hero numerals | + +Nothing below 11px. + +--- + +## Layout + +- **Radius:** 8px standard; `--r-xs: 4px`, `--r-sm: 6px`, `--r-md: 8px`, `--r-lg: 12px`, `--r-pill: 9999px` +- **Border weight:** 1px +- **Spacing:** 4px baseline grid (`--sp-1` through `--sp-6`) +- **Editor wireframe:** + - Titlebar: 34px + - Left utility rail: 48px + - Left panel: 320px + - Right properties: 320px + - Resize handle: 6px + - Bottom timeline/toolbar: 224px + +### Posture rules + +- **One accent, used at most twice per screen.** Default budget is eyebrow + primary CTA. +- **Red is reserved** for REC, cut, skip, trim, and transcript highlight only. It is not a brand color. +- **Mint is the single brand color** for active state, focus ring, toggle-on, and brand mark. +- **Off-white canvas, tiered surfaces.** Never use pure `#000` or pure `#fff` for editorial chrome. +- **Soft elevation, slate-based shadows.** Two `--elev-card` and `--elev-pop` are enough. +- **System-ui type, mono numerics.** No web fonts loaded; ships the declared fallback stack. + +--- + +## Motion + +- `--motion-fast: 120ms` — hover, focus ring +- `--motion-base: 180ms` — state transitions +- `--ease: cubic-bezier(0.2, 0, 0, 1)` — standard easing + +--- + +## Voice & Tone + +- **Adjectives:** high-fidelity, professional, calm, restrained. +- **Tone:** a confident dark-themed tool. Marketing prose is sparse; product UI carries the work. +- **Messaging pillar:** video recording, post-processing editing, and timeline manipulation — same three jobs the editor does. + +### Vocabulary + +- **Use:** Record, Trim, Cut, Skip, Timeline, Clip, Track, Transcript. +- **Avoid:** playful emoji feature labels (✨ 🚀 🎯), "AI-powered" in product chrome, generic SaaS copy ("supercharge your workflow"). + +--- + +## Imagery + +- **Style:** schematic, palette-derived. Scene illustration in preview is grayscale made from the same neutrals — not literal artwork. +- **Treatment:** replace placeholder scenes with real project footage when shipping. +- **Avoid:** stock photography, decorative illustration in tool chrome, hand-drawn mascots. + +--- + +## Files in this directory + +| File | Role | +|---|---| +| `openscreen-editor-2.html` | Latest editor (light + dark themes in one file). Canonical source of tokens. | +| `openscreen-editor.html` | First editor pass. Light-only. | +| `editor.html` | Earlier experimental editor with red REC accent. | +| `openscreen-landing.html` | Marketing landing page. | +| `index.html` | Launcher / overview. | +| `DESIGN.md` | This document. | + +--- + +## Open questions / follow-ups + +- `editor.html` uses a different red-dominant accent and predates the mint brand color. Keep as historical reference; do not import its tokens into new work. +- Landing page (`openscreen-landing.html`) is light-only; consider a dark variant once product photography is sourced. +- No logo asset is committed yet. Brand mark should be a simple wordmark or geometric mark in mint on the dark canvas. diff --git a/design/SKILL.md b/design/SKILL.md new file mode 100644 index 000000000..cf8e24598 --- /dev/null +++ b/design/SKILL.md @@ -0,0 +1,16 @@ +--- +name: openscreen-design +description: Use this skill to generate well-branded interfaces and assets for OpenScreen (an AI-native screen-recording studio & video editor), either for production or throwaway prototypes/mocks/etc. Contains essential design guidelines, colors, type, fonts, assets, and UI kit components for prototyping. +user-invocable: true +--- + +Read the README.md file within this skill, and explore the other available files. +If creating visual artifacts (slides, mocks, throwaway prototypes, etc), copy assets out and create static HTML files for the user to view. If working on production code, you can copy assets and read the rules here to become an expert in designing with this brand. +If the user invokes this skill without any other guidance, ask them what they want to build or design, ask some questions, and act as an expert designer who outputs HTML artifacts _or_ production code, depending on the need. + +Quick orientation: +- `styles.css` is the single CSS entry point — link it and use the CSS custom properties (never hard-code hex). Dark is the default; add `data-theme="light"` on a wrapper for the light palette. +- Type: Geist (UI) + Geist Mono (numbers/technical). Base 13px. +- One brand hue: emerald `--accent`. Ration it. Timeline lanes add amber/orange/red semantic accents. +- Components live in `components/` (forms, display, editor); full-screen recreations in `ui_kits/editor/`. Icons are Lucide. +- No emoji, no gradients-as-decoration, no photographic backgrounds. Dense pro-tool density, glassy floating panels, hairline borders. diff --git a/design/_adherence.oxlintrc.json b/design/_adherence.oxlintrc.json new file mode 100644 index 000000000..62081bd57 --- /dev/null +++ b/design/_adherence.oxlintrc.json @@ -0,0 +1,511 @@ +{ + "plugins": [ + "react", + "import" + ], + "rules": { + "react/forbid-elements": [ + "warn", + { + "forbid": [] + } + ], + "no-restricted-imports": [ + "warn", + { + "patterns": [ + { + "group": [ + "components/display/**", + "components/editor/**", + "components/forms/**" + ], + "message": "Import design-system components from 'index.js', not component internals." + } + ] + } + ], + "no-restricted-syntax": [ + "warn", + { + "selector": "Literal[value=/#[0-9a-fA-F]{3,8}\\b/]", + "message": "Raw hex color — use a design-system color token via var()." + }, + { + "selector": "Literal[value=/\\b\\d+px\\b/]", + "message": "Raw px value — use a design-system spacing token via var()." + }, + { + "selector": "Literal[value=/font-family\\s*:\\s*(?!['\\\"]?(?:Geist|Geist Mono|Inter|IBM Plex Mono|Fira Code|Caveat|Permanent Marker|Bebas Neue|Oswald|Space Grotesk|Playfair Display))/i]", + "message": "Font not provided by the design system. Available: Geist, Geist Mono, Inter, IBM Plex Mono, Fira Code, Caveat, Permanent Marker, Bebas Neue, Oswald, Space Grotesk, Playfair Display." + }, + { + "selector": "JSXOpeningElement[name.name='Badge'] > JSXAttribute > JSXIdentifier[name!=/^(?:children|tone|dot|soft|mono|style|key|ref|className|style|children)$/]", + "message": " doesn't accept that prop. Declared props: children, tone, dot, soft, mono, style." + }, + { + "selector": "JSXOpeningElement[name.name='Badge'] > JSXAttribute[name.name='tone'] > Literal[value!=/^(?:accent|neutral|danger|warn)$/]", + "message": " tone must be one of 'accent' | 'neutral' | 'danger' | 'warn'." + }, + { + "selector": "JSXOpeningElement[name.name='Button'] > JSXAttribute > JSXIdentifier[name!=/^(?:variant|size|icon|iconRight|disabled|fullWidth|onClick|children|style|key|ref|className|style|children)$/]", + "message": " + + + + + + + + + +
+ + +
+
+
+ + 00:00:12.4 + 1920 × 1080 · 60 fps +
Aperçu · 16:9
+
+ +
+
+
+ +
+ + +
+ + +
+ + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + +
+ + +
+
+ + + +
+ + Scroll + Pan + + + Ctrl+ Scroll + Zoom + +
+ +
+ +
+ + + + +
+
+
0:00.0
+
0:30.0
+
1:00.0
+
1:30.0
+
2:00.0
+
2:30.0
+
3:00.0
+
3:30.0
+
4:00.0
+
4:30.0
+
5:00.0
+
5:30.0
+
6:00.0
+
6:30.0
+
7:00.0
+
+
+ + +
+ +
+ + + + +
+ + + + + +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+

demo-n8n-as-code-1.mp4

+

0:00.0 — 2:34.7 source 0:00.0–2:34.7

+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+

2026-03-10 09-10-39.mp4

+

2:34.7 — 7:03.6 source 0:00.0–4:28.9

+
+
+
+ +
+ +
+
+ + + +
+ +
+ + +
+
+ + + +
+
+ + + +
+ +
+ + + + + + + + + + + + + + + + + + + + diff --git a/design/openscreen-widget.html b/design/openscreen-widget.html new file mode 100644 index 000000000..314cbc2de --- /dev/null +++ b/design/openscreen-widget.html @@ -0,0 +1,181 @@ + + + + + Bundled Page + + + + +
+ + + + + + +
+
Unpacking...
+ + + + + + + + + + \ No newline at end of file diff --git a/design/public/rec-button.png b/design/public/rec-button.png new file mode 100644 index 000000000..5e6bbc4fb Binary files /dev/null and b/design/public/rec-button.png differ diff --git a/design/readme.md b/design/readme.md new file mode 100644 index 000000000..2e1390c73 --- /dev/null +++ b/design/readme.md @@ -0,0 +1,140 @@ +# OpenScreen Design System + +A design system extracted from the **OpenScreen Editor** — an AI-native screen-recording +studio and video editor. Think "Screen Studio meets a chat agent": you record your screen, +and an AI agent cleans up dead air, filler words, and pacing, proposes cuts you approve, and +lets you restyle backgrounds, cursors, captions, zoom and layout from a floating inspector. + +The UI is a **dark-first, dense, pro-tool** aesthetic built around a single emerald brand hue, +Geist / Geist Mono type, glassy floating panels, and a rich multi-lane timeline. + +## Source + +Everything here was extracted from a single ground-truth file in this project: + +- **`OpenScreen Editor v4.dc.html`** — the most complete build of the editor (chat + stage + timeline). + +No external codebase or Figma was provided; the `.dc.html` source IS the ground truth. Related +earlier files (`OpenScreen Editor v2/v3`, `OpenScreen Recording Widget`) were left out of scope +per the request (v4 only). + +> **Sharing:** to let others in your org use this, open the **Share** menu and set the file +> type to **Design System**. + +--- + +## CONTENT FUNDAMENTALS + +How OpenScreen writes. + +- **Voice:** calm, competent, first-person-as-agent. The agent says *"On it — scanning track 1 + for silences now"* and *"I'll flag anything over 600ms."* It narrates what it's doing in plain + language, commits to specifics (numbers, thresholds), and never gushes. +- **Person:** the product speaks as **"I"** (the agent) to **"you"** (the creator). UI chrome is + impersonal and imperative: *"Describe the edit you want…"*, *"Drag a clip onto the timeline + below to add it"*, *"Add chapter at playhead"*. +- **Casing:** Sentence case for body, buttons, and helper text. **UPPERCASE mono micro-labels** + for section eyebrows only — `RECIPE`, `PRESET`, `STYLE`, `ASPECT RATIO`, `CAMERA SHAPE`. +- **Numbers are first-class.** Timecodes (`0:00.0`), durations (`−0:37.3`), resolutions + (`1920 × 1080 · 60 fps`), counts (`3/5`, `−3 cuts`), percentages (`0% context`) all render in + **Geist Mono**. Precision reads as trustworthy. +- **Verbs for actions:** "Apply 3 cuts", "Review each", "Regenerate", "Import media", "Export". + Short, concrete, no "Click here". +- **Tone of empty states:** factual, lightly guiding — *"Not generated yet — pick a language and + click regenerate."*, *"No annotations yet"*. +- **No emoji.** None anywhere in the product. Don't introduce them. +- **Filenames stay real:** `demo-n8n-as-code-1.mp4`, `recording-1783066227227.mp4` — never + "My Video.mp4". + +--- + +## VISUAL FOUNDATIONS + +- **Theme:** dark-first. A light theme exists and is a full token swap (`data-theme="light"`), + but dark is the default and the "hero" look. The accent emerald is identical in both themes. +- **Color:** near-black blue-grey surfaces (`#080a0d` → `#2b313b`), a single **emerald** brand + hue (`#10b981`) used sparingly for primary actions, active states, and focus. Timeline lanes + add three semantic accents — **amber** annotations, **orange** speed ramps, **red/danger** + skips & cuts. Color is rationed: most of the UI is greyscale, emerald marks the one thing that + matters on screen. +- **Type:** Geist for everything UI, Geist Mono for anything numeric/technical. Base 13px. Tight + negative tracking on headings; wide positive tracking on tiny uppercase labels. +- **Backgrounds:** subtle radial gradient on the app shell (lighter toward top-center). The + preview stage sits on its own radial vignette. No photographic backgrounds, no patterns, no + noise/grain. Surfaces are flat fills separated by hairline borders. +- **Borders:** 1px hairlines everywhere (`--border`), with a softer variant for internal dividers + (`--border-soft`) and a brighter one for hover/handles (`--border-hi`). Selected/active + controls get a 1.5px emerald border. +- **Elevation:** two levels. Resting cards use a barely-there inset top highlight + soft shadow. + Floating things (inspector, transport, popover menus) use `--elev-pop` — a deep soft drop + shadow plus a 1px inner top highlight, reading as glass. +- **Blur / transparency:** floating panels and on-video chrome use `backdrop-filter: blur(12–20px)` + over semi-transparent dark fills. This is reserved for elements that float **over the video + stage** (transport, inspector, recording bar, clip labels) — flat panels in the shell do not + blur. +- **Corner radii:** nested-radius rhythm — small controls 6–9px, cards 11–12px, panels/popovers + 14–16px, status chips fully round. Containers always round more than what's nested in them. +- **Cards:** flat `--surface`/`--surface-1` fill, 1px border, radius 11–14px, `--elev-card` + shadow. Setting cards in the inspector pair a label (left) with a mono value in emerald (right) + above a slider. +- **Motion:** one shared easing `cubic-bezier(0.2,0,0,1)` at **0.15s** on color/border/shadow/ + transform for every interactive element. Sliders scale their thumb 1.14× on hover with an + emerald halo. The record dot pulses (`os-pulse`, 1.4s). Content fades up 6px (`os-fade`). No + bounces in chrome, no long durations. +- **Hover:** surfaces step up one level (`transparent → --surface-1/2/3`); ghost icon buttons go + `--muted → --fg`; primary buttons darken to `--brand-lo`; chips gain an `--accent-wash` fill and + `--accent-border`. +- **Press/active/selected:** active tabs & tools get an `--accent-soft` fill with `--accent` text; + selected clips/media get a 1.5px emerald border; toggles slide a knob and fill emerald. +- **Focus:** 3px emerald ring (`--focus-ring`) via `:focus-visible`, no outline. +- **Density:** high. This is a desktop pro tool, not a marketing site. Compact controls + (26–36px tall), off-grid odd paddings (7/9/11/13px), tight gaps. + +--- + +## ICONOGRAPHY + +- **Lucide-style line icons.** Every icon in the product is a stroked SVG at `viewBox="0 0 24 24"`, + `stroke-width` 1.8–2.4, round caps/joins, `fill:none` (a few small glyphs use solid fill — + play/pause triangles, cursor arrow, waveform bars). This matches the **Lucide** icon set almost + exactly, so this system standardises on **Lucide** (`https://unpkg.com/lucide@latest`) as the + icon source. Component cards link Lucide from CDN. +- Icon sizes: 9–17px inside controls, scaled to the control. Stroke colour is always + `currentColor` so icons inherit the control's text colour (muted → fg on hover, accent when + active). +- **No emoji, no icon font, no PNG icons** anywhere. One raster asset only: the logo mark. +- A few icons in the source are hand-tuned (the OpenScreen agent avatar built from ``s, the + waveform bars, the cursor arrow). Treat those as bespoke; use Lucide for everything standard. + +### Brand mark + +- **`assets/logo-icon.png`** — the OpenScreen app icon (22×22 in the topbar). This is the only + provided brand asset. There is **no wordmark file**; the product sets "OpenScreen" in Geist + 600, 15px, `-0.015em` tracking next to the icon. Do not redraw or recolour the mark. + +--- + +## Index / manifest + +Root: +- `styles.css` — the single entry point consumers link. `@import`s everything below. +- `readme.md` — this file. +- `SKILL.md` — Agent-Skill front-matter wrapper. +- `assets/logo-icon.png` — brand mark. + +`tokens/` — CSS custom properties (all reachable from `styles.css`): +- `fonts.css` · `colors.css` · `typography.css` · `spacing.css` · `radii.css` · `elevation.css` · `base.css` + +`guidelines/` — foundation specimen cards (Design System tab): colors, type, spacing, radii, elevation, motion. + +`components/` — reusable React primitives (see each `*.prompt.md`): +- `forms/` — Button, IconButton, SegmentedControl, Switch, Slider, Select, TextField +- `display/` — Badge, Chip, Card, ProgressBar +- `editor/` — ChatBubble, ProposalCard, MediaCard, TimelinePill, FacetRailButton + +`ui_kits/editor/` — high-fidelity recreation of the OpenScreen Editor (chat + stage + timeline). + +## Intentional additions + +None. The component inventory is exactly what the v4 editor defines. `TextField` merges the +`` and `