Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions .github/workflows/tennis-watch-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
name: Tennis Watch CI

on:
push:
branches: [main]
paths: [tennis-watch/**, .github/workflows/tennis-watch-ci.yml, .github/workflows/tennis-watch-release.yml]
pull_request:
paths: [tennis-watch/**, .github/workflows/tennis-watch-ci.yml, .github/workflows/tennis-watch-release.yml]
workflow_dispatch:

permissions:
actions: read
contents: read

jobs:
release-version:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
permissions:
contents: write
uses: ./.github/workflows/semantic-release-version.yml
with:
application-directory: tennis-watch
tag-prefix: tennis-watch-v
secrets: inherit

build-test-apk:
needs: release-version
if: always() && (needs.release-version.result == 'success' || needs.release-version.result == 'skipped')
runs-on: ubuntu-latest
timeout-minutes: 30
defaults:
run:
working-directory: tennis-watch
env:
CI: true
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- uses: actions/setup-dotnet@v5
with:
global-json-file: tennis-watch/global.json
- uses: actions/setup-dotnet@v5
with:
global-json-file: tennis-watch/src/TennisWatch/global.json
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 17
- name: Install Android workload
working-directory: tennis-watch/src/TennisWatch
run: dotnet workload install maui-android --skip-manifest-update
- name: Build, test, and package
env:
RELEASE_VERSION: ${{ needs.release-version.outputs.version }}
run: |
if [[ -n "$RELEASE_VERSION" ]]; then
./build.sh --release-version "$RELEASE_VERSION"
else
./build.sh
fi
- name: Detect covered input changes
id: covered
if: github.event_name == 'pull_request'
env:
TARGET_SHA: ${{ github.event.pull_request.base.sha }}
run: |
if git cat-file -e "$TARGET_SHA:tennis-watch/tests/TennisWatch.Tests/TennisWatch.Tests.csproj" 2>/dev/null &&
! git diff --quiet "$TARGET_SHA"...HEAD -- src/TennisWatch.Core tests Directory.Build.props Directory.Packages.props global.json; then
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- name: Fetch base coverage
id: base-coverage
if: steps.covered.outputs.changed == 'true'
continue-on-error: true
env:
CRAP_SCORE_API_URL: ${{ github.api_url }}
CRAP_SCORE_GITHUB_TOKEN: ${{ github.token }}
run: >-
dotnet run --project ../tools/CrapScore/CrapScore.csproj -c Release --no-build --
download-artifact ${{ github.repository }} ${{ github.event.pull_request.base.sha }}
../artifacts/tennis-watch-base-coverage tennis-watch-crap-score
- uses: actions/checkout@v7
if: steps.covered.outputs.changed == 'true' && steps.base-coverage.outcome == 'failure'
with:
ref: ${{ github.event.pull_request.base.sha }}
path: artifacts/tennis-watch-base
- uses: actions/setup-dotnet@v5
if: steps.covered.outputs.changed == 'true' && steps.base-coverage.outcome == 'failure'
with:
global-json-file: artifacts/tennis-watch-base/tennis-watch/global.json
- name: Measure base coverage
if: steps.covered.outputs.changed == 'true' && steps.base-coverage.outcome == 'failure'
working-directory: artifacts/tennis-watch-base/tennis-watch
run: >-
dotnet test tests/TennisWatch.Tests/TennisWatch.Tests.csproj -c Release
--collect:"XPlat Code Coverage"
--results-directory ${{ github.workspace }}/artifacts/tennis-watch-base-coverage
- name: Check CRAP reduction
if: steps.covered.outputs.changed == 'true'
run: >-
dotnet run --project ../tools/CrapScore/CrapScore.csproj -c Release --no-build --
artifacts/test-results --base-reports ../artifacts/tennis-watch-base-coverage --target 5
- uses: actions/upload-artifact@v7
if: always()
with:
name: tennis-watch-build
path: |
tennis-watch/artifacts/TennisWatch.apk
tennis-watch/artifacts/coverage/crap-score.md
tennis-watch/artifacts/release-version.txt
if-no-files-found: error
- uses: actions/upload-artifact@v7
if: always()
with:
name: tennis-watch-crap-score-${{ github.event.pull_request.head.sha || github.sha }}
path: tennis-watch/artifacts/test-results/**/coverage.cobertura.xml
if-no-files-found: error
60 changes: 60 additions & 0 deletions .github/workflows/tennis-watch-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
name: Tennis Watch release

on:
workflow_run:
workflows:
- Tennis Watch CI
types:
- completed

concurrency:
group: tennis-watch-release
cancel-in-progress: false

permissions:
contents: read

jobs:
release:
name: Publish Tennis Watch APK
if: >-
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == 'main'
runs-on: ubuntu-latest
permissions:
actions: read
contents: write

steps:
- name: Check out tested commit
uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.event.workflow_run.head_sha }}

- name: Download tested Tennis Watch build
uses: actions/download-artifact@v7
with:
name: tennis-watch-build
path: tennis-watch/artifacts
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}

- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: 24

- name: Install locked release tooling
run: npm ci --prefix .github/release

- name: Bootstrap Tennis Watch version history
run: .github/release/bootstrap-version-tag.sh tennis-watch-v

- name: Publish release
working-directory: tennis-watch
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SEMANTIC_RELEASE_BIN: ${{ github.workspace }}/.github/release/node_modules/.bin/semantic-release
run: '"$SEMANTIC_RELEASE_BIN"'
11 changes: 11 additions & 0 deletions tennis-watch/.editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 4

[*.{json,yml}]
indent_size = 2
3 changes: 3 additions & 0 deletions tennis-watch/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
artifacts/
.impeccable/review/
.aspire/
165 changes: 165 additions & 0 deletions tennis-watch/.impeccable/design.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
{
"schemaVersion": 2,
"generatedAt": "2026-09-12T17:14:09Z",
"title": "Design System: Tennis Watch",
"extensions": {
"colorMeta": {
"background": {
"role": "neutral",
"displayName": "OLED black",
"tonalRamp": [
"oklch(15% 0.0000 0.0)",
"oklch(26% 0.0000 0.0)",
"oklch(38% 0.0000 0.0)",
"oklch(49% 0.0000 0.0)",
"oklch(61% 0.0000 0.0)",
"oklch(72% 0.0000 0.0)",
"oklch(84% 0.0000 0.0)",
"oklch(95% 0.0000 0.0)"
]
},
"score": {
"role": "neutral",
"displayName": "Score off-white",
"tonalRamp": [
"oklch(15% 0.0057 128.5)",
"oklch(26% 0.0057 128.5)",
"oklch(38% 0.0057 128.5)",
"oklch(49% 0.0057 128.5)",
"oklch(61% 0.0057 128.5)",
"oklch(72% 0.0057 128.5)",
"oklch(84% 0.0057 128.5)",
"oklch(95% 0.0057 128.5)"
]
},
"control-fill": {
"role": "neutral",
"displayName": "Graphite fill",
"tonalRamp": [
"oklch(15% 0.0111 156.4)",
"oklch(26% 0.0111 156.4)",
"oklch(38% 0.0111 156.4)",
"oklch(49% 0.0111 156.4)",
"oklch(61% 0.0111 156.4)",
"oklch(72% 0.0111 156.4)",
"oklch(84% 0.0111 156.4)",
"oklch(95% 0.0111 156.4)"
]
},
"secondary": {
"role": "neutral",
"displayName": "History gray",
"tonalRamp": [
"oklch(15% 0.0145 152.5)",
"oklch(26% 0.0145 152.5)",
"oklch(38% 0.0145 152.5)",
"oklch(49% 0.0145 152.5)",
"oklch(61% 0.0145 152.5)",
"oklch(72% 0.0145 152.5)",
"oklch(84% 0.0145 152.5)",
"oklch(95% 0.0145 152.5)"
]
},
"separator": {
"role": "neutral",
"displayName": "Separator gray",
"tonalRamp": [
"oklch(15% 0.0127 153.5)",
"oklch(26% 0.0127 153.5)",
"oklch(38% 0.0127 153.5)",
"oklch(49% 0.0127 153.5)",
"oklch(61% 0.0127 153.5)",
"oklch(72% 0.0127 153.5)",
"oklch(84% 0.0127 153.5)",
"oklch(95% 0.0127 153.5)"
]
},
"divider": {
"role": "neutral",
"displayName": "Divider gray",
"tonalRamp": [
"oklch(15% 0.0085 159.6)",
"oklch(26% 0.0085 159.6)",
"oklch(38% 0.0085 159.6)",
"oklch(49% 0.0085 159.6)",
"oklch(61% 0.0085 159.6)",
"oklch(72% 0.0085 159.6)",
"oklch(84% 0.0085 159.6)",
"oklch(95% 0.0085 159.6)"
]
}
},
"typographyMeta": {
"display": {
"displayName": "Point score",
"purpose": "Native condensed bold text; uniform fitting from 24sp to the display size in 1sp steps."
},
"games": {
"displayName": "Current games",
"purpose": "Medium weight and score color distinguish the current set."
},
"separator": {
"displayName": "Point separator",
"purpose": "Regular multiplication sign between point scores."
},
"completed-set": {
"displayName": "Completed set",
"purpose": "Regular secondary text precedes current games."
}
}
},
"components": [
{
"name": "Point control",
"kind": "button",
"refersTo": "point-control",
"description": "Native SVG in a 48dp touch area. Pressed opacity is 0.55. The outline on keyboard focus is for this browser preview.",
"html": "<div class=\"ds-point-preview\"><button class=\"ds-control ds-point\" type=\"button\" aria-label=\"Add point to left side\"><svg aria-hidden=\"true\" xmlns=\"http://www.w3.org/2000/svg\" width=\"48\" height=\"48\" viewBox=\"0 0 48 48\">\n <rect x=\"5\" y=\"3\" width=\"38\" height=\"42\" rx=\"16\" fill=\"#252B27\"/>\n <path d=\"M24 16v16M16 24h16\" fill=\"none\" stroke=\"#F4F6F2\" stroke-width=\"2.3\" stroke-linecap=\"round\"/>\n</svg></button></div>",
"css": ".ds-control { display: block; width: 48px; height: 48px; padding: 0; border: 0; background: transparent; cursor: pointer; } .ds-control svg { display: block; } .ds-control:focus-visible { outline: 1px solid #F4F6F2; outline-offset: 2px; } .ds-control:active { opacity: 0.55; } .ds-point-preview { width: 48px; background: #000000; } .ds-point:hover { opacity: 1; } .ds-point:active { opacity: 0.55; }"
},
{
"name": "Undo control",
"kind": "button",
"refersTo": "undo-control",
"description": "Enabled and disabled native undo artwork. Opacity is 0.8 enabled, 0.55 pressed, and 0.3 disabled. The focus outline belongs to the browser preview.",
"html": "<div class=\"ds-undo-preview\"><button class=\"ds-control ds-undo\" type=\"button\" aria-label=\"Undo last point\"><svg aria-hidden=\"true\" xmlns=\"http://www.w3.org/2000/svg\" width=\"48\" height=\"48\" viewBox=\"0 0 48 48\">\n <path d=\"M15 19a10 10 0 1 1 1.5 13M15 12v7h7\" fill=\"none\" stroke=\"#929B94\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg></button><button class=\"ds-control ds-undo\" type=\"button\" aria-label=\"Undo last point unavailable\" disabled><svg aria-hidden=\"true\" xmlns=\"http://www.w3.org/2000/svg\" width=\"48\" height=\"48\" viewBox=\"0 0 48 48\">\n <path d=\"M15 19a10 10 0 1 1 1.5 13M15 12v7h7\" fill=\"none\" stroke=\"#929B94\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg></button></div>",
"css": ".ds-control { display: block; width: 48px; height: 48px; padding: 0; border: 0; background: transparent; cursor: pointer; } .ds-control svg { display: block; } .ds-control:focus-visible { outline: 1px solid #F4F6F2; outline-offset: 2px; } .ds-control:active { opacity: 0.55; } .ds-undo-preview { display: flex; gap: 8px; width: max-content; background: #000000; } .ds-undo { opacity: 0.8; } .ds-undo:hover { opacity: 0.8; } .ds-undo:active { opacity: 0.55; } .ds-undo:disabled { opacity: 0.3; cursor: default; }"
},
{
"name": "Round scoring face",
"kind": "custom",
"description": "Layout preview at 225px across with completed-set history. Browser fonts approximate Android; native text fitting and font padding follow DESIGN.md. Preview buttons show control states without changing the score.",
"html": "<div class=\"ds-face\" role=\"group\" aria-label=\"Tennis score\"><button class=\"ds-control ds-left-plus\" type=\"button\" aria-label=\"Add point to left side\"><svg aria-hidden=\"true\" xmlns=\"http://www.w3.org/2000/svg\" width=\"48\" height=\"48\" viewBox=\"0 0 48 48\">\n <rect x=\"5\" y=\"3\" width=\"38\" height=\"42\" rx=\"16\" fill=\"#252B27\"/>\n <path d=\"M24 16v16M16 24h16\" fill=\"none\" stroke=\"#F4F6F2\" stroke-width=\"2.3\" stroke-linecap=\"round\"/>\n</svg></button><span class=\"ds-label ds-points ds-left-points\">15</span><span class=\"ds-label ds-separator\">\u00d7</span><span class=\"ds-label ds-points ds-right-points\">30</span><button class=\"ds-control ds-right-plus\" type=\"button\" aria-label=\"Add point to right side\"><svg aria-hidden=\"true\" xmlns=\"http://www.w3.org/2000/svg\" width=\"48\" height=\"48\" viewBox=\"0 0 48 48\">\n <rect x=\"5\" y=\"3\" width=\"38\" height=\"42\" rx=\"16\" fill=\"#252B27\"/>\n <path d=\"M24 16v16M16 24h16\" fill=\"none\" stroke=\"#F4F6F2\" stroke-width=\"2.3\" stroke-linecap=\"round\"/>\n</svg></button><div class=\"ds-divider\"></div><div class=\"ds-label ds-games\"><span class=\"ds-completed\">6 \u00d7 0</span><span>0 \u00d7 1</span></div><button class=\"ds-control ds-undo\" type=\"button\" aria-label=\"Undo last point\"><svg aria-hidden=\"true\" xmlns=\"http://www.w3.org/2000/svg\" width=\"48\" height=\"48\" viewBox=\"0 0 48 48\">\n <path d=\"M15 19a10 10 0 1 1 1.5 13M15 12v7h7\" fill=\"none\" stroke=\"#929B94\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg></button></div>",
"css": ".ds-control { display: block; width: 48px; height: 48px; padding: 0; border: 0; background: transparent; cursor: pointer; } .ds-control svg { display: block; } .ds-control:focus-visible { outline: 1px solid #F4F6F2; outline-offset: 2px; } .ds-control:active { opacity: 0.55; } .ds-face { position: relative; width: 225px; height: 225px; overflow: hidden; border-radius: 50%; background: #000000; color: #F4F6F2; font-family: sans-serif; } .ds-face .ds-control { position: absolute; top: 43%; transform: translate(-50%, -50%); } .ds-face .ds-left-plus { left: 12.5%; } .ds-face .ds-right-plus { left: 87.5%; } .ds-face .ds-undo { left: 50%; top: 82.5%; opacity: 0.8; } .ds-face .ds-control:active { opacity: 0.55; } .ds-face .ds-label { position: absolute; display: flex; align-items: center; justify-content: center; transform: translate(-50%, -50%); white-space: nowrap; font-variant-numeric: tabular-nums; font-feature-settings: \"tnum\"; } .ds-face .ds-points { top: 43%; width: 25%; height: 66px; font-family: \"Roboto Condensed\", \"Arial Narrow\", sans-serif; font-stretch: condensed; font-size: 48px; font-weight: 700; line-height: 1; } .ds-face .ds-left-points { left: 33.2%; } .ds-face .ds-right-points { left: 66.8%; } .ds-face .ds-separator { left: 50%; top: 43%; width: 7.5%; height: 40px; color: #8B938D; font-size: 13px; font-weight: 400; } .ds-face .ds-divider { position: absolute; left: 25%; top: calc(57.5% - 0.375px); width: 50%; height: 0.75px; background: #343936; } .ds-face .ds-games { left: 50%; top: 65.5%; width: 76%; height: 32px; gap: 8px; font-size: 18px; font-weight: 500; } .ds-face .ds-completed { color: #929B94; font-size: 12px; font-weight: 400; }"
}
],
"narrative": {
"northStar": "Monochrome sports instrument",
"overview": "Condensed bold point scores and filled graphite plus controls lead a black watch face. Current games, completed sets, and a small gray undo arrow sit below. The composition supports a quick glance and a tap on court.",
"keyCharacteristics": [
"Condensed point numerals hold the strongest visual emphasis.",
"Fixed touch areas surround compact artwork, leaving the upper watch face open."
],
"rules": [
{
"name": "The score hierarchy rule",
"body": "Points lead; current games are larger and brighter than completed sets. Keep the multiplication separator smaller and dimmer than the points.",
"section": "typography"
},
{
"name": "The touch area rule",
"body": "Keep the fixed control dimensions in the frontmatter when adjusting screen proportions. The artwork is smaller than its touch area.",
"section": "layout"
}
],
"dos": [
"Do preserve the filled plus controls and condensed bold points as the scoring screen's main row.",
"Do retain native text fitting, tabular figures, and fixed touch targets when adjusting proportions.",
"Do keep secondary scoring and undo visually quieter than the points."
],
"donts": [
"Don't add names, menus, banners, extra controls, or a completion screen to this scoring view.",
"Don't draw a hardware bezel inside the app."
]
}
}
34 changes: 34 additions & 0 deletions tennis-watch/.releaserc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"branches": [
"main"
],
"tagFormat": "tennis-watch-v${version}",
"plugins": [
[
"../.github/release/application-semantic-release.mjs",
{
"preset": "conventionalcommits",
"scope": "tennis-watch"
}
],
[
"@semantic-release/exec",
{
"verifyReleaseCmd": "if [ -n \"$NEXT_RELEASE_VERSION_FILE\" ]; then printf '%s\\n' '${nextRelease.version}' > \"$NEXT_RELEASE_VERSION_FILE\"; else bash ../.github/release/verify-application-assets.sh '${nextRelease.version}' artifacts/release-version.txt artifacts/TennisWatch.apk; fi"
}
],
[
"@semantic-release/github",
{
"assets": [
{
"path": "artifacts/TennisWatch.apk",
"label": "Tennis Watch Android APK"
}
],
"successComment": false,
"failComment": false
}
]
]
}
Loading