diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6202d13..be23c48 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -38,6 +38,8 @@ jobs:
run: pip install -e '.[dev]'
- name: Install frontend browser dependencies
run: npm ci
+ - name: Install deterministic CJK screenshot font
+ run: sudo apt-get update && sudo apt-get install -y fonts-noto-cjk
- name: Install Playwright Firefox
run: npx playwright install --with-deps firefox
- name: Validate frontend script syntax
@@ -46,6 +48,16 @@ jobs:
run: python -m pytest tests/test_frontend_contract.py -q
- name: Multi-viewport browser regression test
run: npm run test:browser
+ - name: Upload visual baselines and failure diffs
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: theme-release-gate
+ if-no-files-found: ignore
+ path: |
+ tests/browser/*-snapshots/*.png
+ test-results/**/*.png
+ test-results/**/*.zip
test:
runs-on: ubuntu-latest
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5605eca..38a3a89 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,16 @@
# Changelog
+## v4.6.29 - 2026-08-15
+
+- Unified public chat, Support, status, settings, account, onboarding, and
+ operational state colors behind one light/dark semantic token contract.
+- Split info/success/warning/error foreground, surface, and opaque border roles
+ so components cannot accidentally reuse a background token as text.
+- Added a browser release gate for computed alpha-composited contrast across
+ desktop and 390x844, plus checked-in visual baselines and CI diff artifacts.
+- Fixed previously untested low-contrast borders on recommendation chips and
+ settings cards, and added an explicit accessible name to account settings.
+
## Unreleased
## v4.6.28 - 2026-08-15
diff --git a/docs/theme-system.md b/docs/theme-system.md
new file mode 100644
index 0000000..1ffaaa1
--- /dev/null
+++ b/docs/theme-system.md
@@ -0,0 +1,55 @@
+# Theme system and visual release gate
+
+The public UI has one semantic color contract in the `:root` and
+`html[data-theme="light"]` blocks of `web/styles.css`. Components must consume
+those tokens; they must not encode a light or dark palette locally.
+
+## Token roles
+
+- `--bg`, `--bg-deep`: page canvas.
+- `--surface`, `--surface-solid`, `--surface-soft`, `--surface-muted`,
+ `--surface-raised`, `--surface-inset`: elevation and containment.
+- `--text-primary`, `--text-secondary`, `--muted`: text hierarchy.
+- `--line`, `--line-strong`: decorative separators.
+- `--control-border`, `--focus-ring`: interactive boundaries and keyboard focus.
+- `--info-*`, `--success-*`, `--warning-*`, `--error-*`: separate
+ `surface`, `text`, and `border` roles. Never reuse a surface token as text.
+- `--action-*`, `--send-*`: composer actions and their state changes.
+
+SVG icons use `currentColor`. Selected, processing, disabled, error, and
+success states require a non-color cue such as a border, ring, icon, or label.
+
+## Release gate
+
+`tests/browser/onboarding-responsive.spec.js` exercises both themes at desktop
+and 390x844. It audits final computed colors after alpha composition for the
+chat landing page, a completed answer with Support references, system status,
+settings, account forms, and success/warning/error states.
+
+The gate enforces:
+
+- normal text contrast of at least 4.5:1;
+- tested control boundaries of at least 3:1;
+- no horizontal overflow at either viewport;
+- checked-in Firefox screenshot baselines for the light/dark status route.
+
+Run the gate with:
+
+```bash
+npm ci
+sudo apt-get install fonts-noto-cjk
+npx playwright install firefox
+npx playwright test tests/browser/onboarding-responsive.spec.js
+```
+
+Intentional visual changes require local review before updating snapshots with
+`--update-snapshots`. CI uploads the baselines and failure diffs for review.
+
+## New component checklist
+
+1. Use semantic tokens only; do not add component-local black/white RGBA colors.
+2. Verify default, hover, focus-visible, selected, processing, disabled,
+ loading, empty, success, warning, and error states where applicable.
+3. Add the component's rendered selectors to the computed-style audit.
+4. Check light/dark at desktop and 390x844, including keyboard focus.
+5. Update a screenshot baseline only after inspecting the rendered image.
diff --git a/pyproject.toml b/pyproject.toml
index af64dc2..16b6f88 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "sage-mate"
-version = "4.6.28"
+version = "4.6.29"
description = "Sage Mate multi-profile local and hosted assistant built on SAGE and vllm-hust"
readme = "README.md"
license = {file = "LICENSE"}
diff --git a/src/sage_faculty_twin/__init__.py b/src/sage_faculty_twin/__init__.py
index 16a18be..44d79d7 100644
--- a/src/sage_faculty_twin/__init__.py
+++ b/src/sage_faculty_twin/__init__.py
@@ -1,5 +1,5 @@
"""Sage Mate application package."""
-__version__ = "4.6.28"
+__version__ = "4.6.29"
__all__ = ["__version__"]
diff --git a/src/sage_faculty_twin/api.py b/src/sage_faculty_twin/api.py
index 327a54b..8977a9a 100644
--- a/src/sage_faculty_twin/api.py
+++ b/src/sage_faculty_twin/api.py
@@ -1269,7 +1269,7 @@ async def home() -> FileResponse:
@llm_app.api_route("/styles.css", methods=["GET", "HEAD"], include_in_schema=False)
@llm_app.api_route("/styles.4217.css", methods=["GET", "HEAD"], include_in_schema=False)
-@llm_app.api_route("/styles.4220.css", methods=["GET", "HEAD"], include_in_schema=False)
+@llm_app.api_route("/styles.4221.css", methods=["GET", "HEAD"], include_in_schema=False)
async def styles() -> FileResponse:
return frontend_asset("styles.css")
@@ -1284,7 +1284,7 @@ async def homepage_redirect() -> RedirectResponse:
@llm_app.api_route("/app.js", methods=["GET", "HEAD"], include_in_schema=False)
@llm_app.api_route("/app.4217.js", methods=["GET", "HEAD"], include_in_schema=False)
@llm_app.api_route("/app.4218.js", methods=["GET", "HEAD"], include_in_schema=False)
-@llm_app.api_route("/app.4220.js", methods=["GET", "HEAD"], include_in_schema=False)
+@llm_app.api_route("/app.4221.js", methods=["GET", "HEAD"], include_in_schema=False)
async def app_js() -> FileResponse:
return frontend_asset("app.js")
diff --git a/src/sage_faculty_twin/web/index.html b/src/sage_faculty_twin/web/index.html
index 805638b..adcca49 100644
--- a/src/sage_faculty_twin/web/index.html
+++ b/src/sage_faculty_twin/web/index.html
@@ -21,7 +21,7 @@
-
+