From 14ddaf1e8ecd6779d49a4e6844fb1a001b743ac0 Mon Sep 17 00:00:00 2001 From: Vojtech <119944107+cvrysanek@users.noreply.github.com> Date: Wed, 13 May 2026 21:25:46 +0400 Subject: [PATCH] feat(brand): wire instance.logo_svg into header brand slot (release 0.54.6) (#289) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(brand): inline operator SVG logo + drop header subtitle (release 0.54.6) Three header tweaks, one PR: 1. _app_header.html drops the small uppercase subtitle line below the brand. instance.subtitle still flows into the CLAUDE.md preamble + init welcome template ("Operated by …"); only the web header chrome loses it. 2. get_instance_logo_svg() in app/instance_config.py reads instance.logo_svg (yaml) / AGNES_INSTANCE_LOGO_SVG (env). The yaml field was already documented in instance.yaml.example and the template already supported inline via {{ config.LOGO_SVG | safe }}, but router.py:344 hard-coded LOGO_SVG = "" — the middle wire was missing. Now operators can paste a lockup directly into their instance.yaml under instance.logo_svg: | and have it render in the header. Resolution mirrors get_instance_brand (env > yaml > ""). instance.name remains independent: drives browser tags + page h1s + CLAUDE.md heading; the SVG is the web-header visual only. 3. .app-header-logo svg gains max-height: 40px; width: auto; so any operator's lockup scales via its viewBox to fit the 72px header without per-asset width/height edits. Pairs with #2 — without the clamp, raw artwork (e.g. a 1600x430 lockup) overflows the chrome. Release-cut included per the same-PR rule (Unreleased contained only these bullets after rebase onto 0.54.5). * revert: keep app-header-subtitle span — out of scope for this PR Initial commit dropped the subtitle line on the assumption that the user wanted both the secondary header line AND the future-SVG brand cleaned up. The actual ask was narrower: drop the hostname suffix that renders inside instance.name ("Foundry AI (hostname)"), which is a startup.sh concern, not a template one. Restore the subtitle span and the CHANGELOG bullet that announced its removal. PR scope narrows to LOGO_SVG wiring + CSS clamp only. * fix(header): hide subtitle span when instance.subtitle is empty Pre-fix the template fell back to the literal string 'Data Analyst Portal' when INSTANCE_SUBTITLE was unset, so operators who left the field empty saw a stray hardcoded label below their brand. Switched to a Jinja {% if %} guard around the whole so an empty subtitle produces no element at all — clean header chrome instead of placeholder leak. * feat(home): hide install-hero once onboarded + X close button - Wrap the entire install-hero in `{% if not onboarded %}` so once `users.onboarded=true` (auto-flipped by `agnes init` POSTing /api/me/onboarded, or by the new X / existing fallback button) the blue hero disappears entirely. Pre-PR the onboarded branch reused the same shell with a "Welcome back" header + "Steps 1–4 done" badge + minimize toggle, which visually outweighed the actual nav hub. - Add a circular × close button (top-right of the hero, rendered only when not-onboarded). Click → window.confirm() asking the user to acknowledge onboarding → POST /api/me/onboarded → reload. The confirm string intentionally avoids the literal phrase "Mark me as offboarded" because cli/commands/onboarded.py::status scans /home's rendered HTML for that exact marker as a fallback for the api/me/profile check. - Lift the offboard escape hatch out of the hero into a discrete `.offboard-strip` rendered below, gated `{% if onboarded %}`. Lets the analyst flip back to the install view after wiping their workspace folder. - Centralize the /api/me/onboarded POST into a `postOnboarded()` JS helper reused by the hero X, the existing "Mark me as onboarded" fallback button, and the new offboard button. Tests updated to match the new behavior: - `test_home_onboarded_user_sees_nav_hub` — asserts the hero is gone and the offboard strip is the only setup-flow remnant. - `test_minimize_toggle_no_longer_rendered` (renamed) — asserts the minimize toggle is absent in both states (was previously rendered inside the now-hidden onboarded branch of the hero). - `test_home_no_auto_transition_after_post_until_reload` — checks offboard-strip presence post-flip instead of the removed "Welcome back" hero copy. * fix(home): X-close button used invalid source enum, hit 422 The X button's data-target-source was 'self_acknowledged_x' to give audit_log a separate marker for X-vs-button-driven flips. But app/api/me.py:38's OnboardedRequest pins source to a Literal of ['agnes_init', 'self_acknowledged', 'self_unmark'] — pydantic returned 422 on every X click. Confusing side effect: both buttons share self-mark-status as the status element, so the failed X click rendered 'Failed (422)' next to the still-functional 'Mark me as onboarded' button. Looked like the button itself broke. Fix: drop the _x suffix. Both surfaces now POST source='self_acknowledged'. Distinction in audit_log is not load-bearing — the source field captures user intent ('I'm onboarded'), not the specific UI affordance. --- CHANGELOG.md | 36 ++++ app/instance_config.py | 17 ++ app/web/router.py | 3 +- app/web/static/style-custom.css | 6 +- app/web/templates/_app_header.html | 2 +- app/web/templates/home_not_onboarded.html | 190 +++++++++++++++------- config/instance.yaml.example | 10 +- pyproject.toml | 2 +- tests/test_web_home_page.py | 80 +++++---- uv.lock | 2 +- 10 files changed, 239 insertions(+), 109 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a0b2a4..cb80d5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,42 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C ## [Unreleased] +## [0.54.6] — 2026-05-13 + +### Changed + +- Header brand: wired `instance.logo_svg` (yaml) / + `AGNES_INSTANCE_LOGO_SVG` (env) into the brand slot via a new + `get_instance_logo_svg()` helper in `app/instance_config.py`. + Previously the yaml field was documented in + `config/instance.yaml.example` and the template already supported + inline SVG via `config.LOGO_SVG | safe`, but the router + hard-coded `LOGO_SVG = ""` — operators can now drop inline SVG + markup into their `instance.yaml` and have it appear in the + header. `instance.name` continues to drive browser titles and + page headings; the two fields are independent. +- Header brand: clamped `.app-header-logo svg` to `max-height: 40px; + width: auto;` (was just `display: block;`) so any operator's + `logo_svg` scales via its viewBox to fit the 72px-tall header + without per-asset width/height edits. +- Header subtitle: empty `instance.subtitle` now renders nothing + (the whole `` is skipped) + instead of falling back to the literal placeholder string + "Data Analyst Portal". Operators who leave the field unset get a + clean header instead of a stray hardcoded label. +- `/home` install-hero now disappears entirely once the user is + onboarded (`users.onboarded=true`, set by `agnes init`'s POST to + `/api/me/onboarded` or by an explicit click). Pre-fix the hero + kept rendering a "Welcome back — you're set up" variant that + visually outweighed the actual nav hub. Adds a close (×) button + in the top-right of the hero — confirms with a `window.confirm()` + dialog asking the user to acknowledge onboarding before flipping + state, so a stray click won't hide the setup steps. The + offboarding escape hatch (previously living inside the hero's + onboarded branch) moves to a discrete strip below — visible only + when onboarded, so analysts who wipe `~/{{ workspace_dir }}` can + flip back without digging through settings. + ## [0.54.5] — 2026-05-13 ### Internal diff --git a/app/instance_config.py b/app/instance_config.py index 6dff3c1..15bb39d 100644 --- a/app/instance_config.py +++ b/app/instance_config.py @@ -278,6 +278,23 @@ def get_instance_brand() -> str: return value or "Agnes" +def get_instance_logo_svg() -> str: + """Raw inline ```` markup rendered into the header brand slot + (``_app_header.html``). When non-empty, replaces the text brand in + the header — typical use is a lockup that already contains the + brand wordmark. When empty, the header falls back to + :func:`get_instance_name` as text. + + Resolution: ``AGNES_INSTANCE_LOGO_SVG`` env > ``instance.logo_svg`` + YAML > ``""``. Mirrors :func:`get_instance_brand` so Terraform env + overrides work the same way. + """ + raw = os.environ.get("AGNES_INSTANCE_LOGO_SVG") + if raw is None: + raw = get_value("instance", "logo_svg", default="") + return (raw or "").strip() + + def get_workspace_dir_name() -> str: """Filesystem-safe folder name for the analyst's local workspace (``~/``). Defaults to :func:`get_instance_brand` diff --git a/app/web/router.py b/app/web/router.py index 09aa2e3..5cfa975 100644 --- a/app/web/router.py +++ b/app/web/router.py @@ -25,6 +25,7 @@ from app.instance_config import ( get_gws_oauth_credentials, get_home_automode_visibility, get_instance_admin_email, get_atlassian_base_url, get_instance_brand, get_workspace_dir_name, + get_instance_logo_svg, ) from app.web.connector_prompts import all_connector_prompts from src.repositories.sync_state import SyncStateRepository @@ -343,7 +344,7 @@ def _build_context( INSTANCE_NAME = get_instance_name() INSTANCE_SUBTITLE = get_instance_subtitle() INSTANCE_COPYRIGHT = "" - LOGO_SVG = "" + LOGO_SVG = get_instance_logo_svg() TELEGRAM_BOT_USERNAME = os.environ.get("TELEGRAM_BOT_USERNAME", "") SSH_ALIAS = "data-analyst" SERVER_HOST = os.environ.get("SERVER_HOST", "") diff --git a/app/web/static/style-custom.css b/app/web/static/style-custom.css index db68950..6485382 100644 --- a/app/web/static/style-custom.css +++ b/app/web/static/style-custom.css @@ -2109,7 +2109,11 @@ a.slack-badge:hover { font-weight: 600; font-size: 16px; } -.app-header-logo svg { display: block; } +.app-header-logo svg { + display: block; + max-height: 40px; + width: auto; +} a.app-header-logo:focus-visible { outline: 2px solid var(--primary, #6366f1); outline-offset: 2px; diff --git a/app/web/templates/_app_header.html b/app/web/templates/_app_header.html index e7e0a6d..4f1220f 100644 --- a/app/web/templates/_app_header.html +++ b/app/web/templates/_app_header.html @@ -6,7 +6,7 @@ - {{ config.INSTANCE_SUBTITLE or 'Data Analyst Portal' }} + {% if config.INSTANCE_SUBTITLE %}{{ config.INSTANCE_SUBTITLE }}{% endif %}
{% set _path = request.url.path %} diff --git a/app/web/templates/home_not_onboarded.html b/app/web/templates/home_not_onboarded.html index 623e0f0..5be2c0b 100644 --- a/app/web/templates/home_not_onboarded.html +++ b/app/web/templates/home_not_onboarded.html @@ -24,6 +24,7 @@ .home-mock * { box-sizing: border-box; } .home-mock .install-hero { + position: relative; background: linear-gradient(135deg, #0073D1 0%, #0056A3 100%); color: white; border-radius: 16px; @@ -31,6 +32,51 @@ margin-bottom: 22px; box-shadow: 0 8px 24px rgba(0, 86, 163, 0.18); } +.home-mock .install-hero-close { + position: absolute; + top: 14px; + right: 14px; + width: 32px; + height: 32px; + border: none; + border-radius: 50%; + background: rgba(255, 255, 255, 0.14); + color: white; + font-size: 18px; + line-height: 1; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: background 0.15s; +} +.home-mock .install-hero-close:hover, +.home-mock .install-hero-close:focus-visible { + background: rgba(255, 255, 255, 0.28); + outline: none; +} +.home-mock .offboard-strip { + margin: 0 0 22px; + padding: 10px 14px; + border: 1px solid var(--hp-border); + border-radius: 10px; + background: var(--hp-border-light); + color: var(--hp-text-secondary); + font-size: 13px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.home-mock .offboard-strip button { + border: 1px solid var(--hp-border); + background: white; + border-radius: 6px; + padding: 4px 10px; + font-size: 13px; + cursor: pointer; +} +.home-mock .offboard-strip button:hover { background: var(--hp-border-light); } .home-mock .install-hero .eyebrow { font-size: 11px; font-weight: 600; @@ -1154,29 +1200,22 @@ {% set display_name = (user.name or (user.email or "").split("@")[0] or "there") %} + {# Install-hero renders only for not-onboarded users. Once `agnes init` + POSTs /api/me/onboarded (or the user clicks the in-hero X) the hero + disappears entirely — the rest of /home (connector tiles, news, + etc.) stays. Offboarding escape hatch moved to a discrete strip + below; see `.offboard-strip`. #} + {% if not onboarded %}
- {% if onboarded %} -
Welcome back, {{ display_name }} — your workspace is ready
-

You're set up — keep this page handy

-

- Your local {{ instance_brand }} install is confirmed. The steps below stay useful for adding another machine, connecting more services, or turning on auto-accept mode. Skip whatever you don't need; nothing here re-runs unless you click it. -

- {% else %} +
Welcome, {{ display_name }} — let's get you set up

Connect Claude Code on your machine to your team's data

{{ instance_brand }} gives Claude Code on your computer access to your team's curated data, plugins, and shared knowledge — so you can ask questions and get answers in plain language, right from your terminal. This page walks you through the one-time setup (~10 minutes). Everything it installs lives in your home folder (~/{{ workspace_dir }}) and can be removed in one command.

- {% endif %} - {% if onboarded %} -
- - Steps 1–4 done — Claude Code installed, auto-mode set, workspace folder created, {{ instance_brand }} ready in ~/{{ workspace_dir }}. The full install steps stay one click away under the offboard control below. -
- {% endif %} - - {% if not onboarded %}
Step 1 — install Claude Code
@@ -1281,53 +1320,45 @@ Set-Location "$HOME\{{ workspace_dir }}"
- {% endif %} {# P1-6 — auto-detect badge is the PRIMARY affordance after the install-script copy: agnes-init's first POST to /api/me/onboarded flips state automatically and the page reloads. The manual "Mark me as onboarded" button below it stays as a fallback when auto-flip never lands. #} - {% if not onboarded %}
Waiting for your first agnes pull — auto-detects within ~30 s of the setup script finishing.
- {% endif %} - {# Self-mark control lives inside the blue hero in both states. - When onboarded, the install steps above are hidden so this is - the only thing rendered below the lead paragraph. #} + {# Self-mark fallback for the auto-flip. The hero's X close button + does the same thing more visibly; both target the not-onboarded + → onboarded direction. The onboarded → offboarded variant lives + below the hero (.offboard-strip) so it stays reachable once the + hero is gone. #}
- {% if onboarded %} - Wiped your workspace or want the full setup view back? - - {% else %} Already set this up? - {% endif %}
- - {% if onboarded %} - {# User-controlled minimize toggle for Connect-your-tools. - Default OFF (section renders flat). State persists in - localStorage so the choice is per-device. The agnes-init - auto-flip of users.onboarded never triggers a collapse on - its own — only an explicit click here does. The auto-mode - block used to be a peer collapsible (`step3`); it now lives - inside the install-hero as Step 2 and is not collapsible. #} -
- -
- {% endif %}
+ {% endif %} + + {% if onboarded %} + {# Offboarding escape hatch shown only after the hero has disappeared. + Lets the analyst (e.g. after wiping ~/FoundryAI) flip the + users.onboarded boolean back to false so the full install hero + renders again on next reload. Discrete by design — onboarded + users land on /home expecting the nav hub, not a setup screen. #} +
+ Workspace ready — wiped it and need the full setup view back? + + +
+ {% endif %} {# Auto-mode card used to live here as a `
` reference block; moved into the install-hero as the new Step 2 so users enable it @@ -1638,16 +1669,13 @@ Set-Location "$HOME\{{ workspace_dir }}" }); } - var btn = document.getElementById('self-mark-btn'); - var status = document.getElementById('self-mark-status'); - if (!btn) return; - btn.addEventListener('click', function () { - // Direction comes from data-attrs the template sets per render — - // onboarded view → flip to FALSE (offboard), not-onboarded → flip to TRUE. - var targetOnboarded = btn.getAttribute('data-target-onboarded') === 'true'; - var targetSource = btn.getAttribute('data-target-source') || 'self_acknowledged'; - btn.disabled = true; - status.textContent = targetOnboarded ? 'Marking…' : 'Resetting…'; + // Shared poster for /api/me/onboarded — reused by every UI surface + // that flips users.onboarded (in-hero X close, "Mark me as onboarded" + // fallback button, the offboard strip). Reloads on success so the + // template re-renders with the new state. + function postOnboarded(triggerBtn, statusEl, targetOnboarded, targetSource) { + if (triggerBtn) triggerBtn.disabled = true; + if (statusEl) statusEl.textContent = targetOnboarded ? 'Marking…' : 'Resetting…'; fetch('/api/me/onboarded', { method: 'POST', credentials: 'same-origin', @@ -1655,17 +1683,59 @@ Set-Location "$HOME\{{ workspace_dir }}" body: JSON.stringify({ source: targetSource, onboarded: targetOnboarded }), }).then(function (resp) { if (resp.ok) { - status.textContent = 'Done. Reloading…'; + if (statusEl) statusEl.textContent = 'Done. Reloading…'; window.location.reload(); } else { - status.textContent = 'Failed (' + resp.status + '). Try again.'; - btn.disabled = false; + if (statusEl) statusEl.textContent = 'Failed (' + resp.status + '). Try again.'; + if (triggerBtn) triggerBtn.disabled = false; } }).catch(function () { - status.textContent = 'Network error. Try again.'; - btn.disabled = false; + if (statusEl) statusEl.textContent = 'Network error. Try again.'; + if (triggerBtn) triggerBtn.disabled = false; }); - }); + } + + // "Mark me as onboarded" fallback button inside the hero (rendered + // only when not-onboarded — the X close button is the primary path). + var btn = document.getElementById('self-mark-btn'); + var status = document.getElementById('self-mark-status'); + if (btn) { + btn.addEventListener('click', function () { + postOnboarded( + btn, status, true, + btn.getAttribute('data-target-source') || 'self_acknowledged' + ); + }); + } + + // Hero X close — confirm first so a stray click doesn't flip state. + var heroClose = document.getElementById('installHeroClose'); + if (heroClose) { + heroClose.addEventListener('click', function () { + var ok = window.confirm( + "Are you already onboarded? Closing this will mark your account as onboarded " + + "and hide the setup steps. You can revert later from the strip below the hero." + ); + if (!ok) return; + postOnboarded( + heroClose, status, true, + heroClose.getAttribute('data-target-source') || 'self_acknowledged' + ); + }); + } + + // Offboarding strip — only rendered when onboarded. Flips back to + // the install hero on next reload. + var offBtn = document.getElementById('offboard-btn'); + var offStatus = document.getElementById('offboard-status'); + if (offBtn) { + offBtn.addEventListener('click', function () { + postOnboarded( + offBtn, offStatus, false, + offBtn.getAttribute('data-target-source') || 'self_unmark' + ); + }); + } // ── Minimize-setup toggle ──────────────────────────────────────── // Default OFF: sections render flat (no visible). diff --git a/config/instance.yaml.example b/config/instance.yaml.example index 0e58538..444e005 100644 --- a/config/instance.yaml.example +++ b/config/instance.yaml.example @@ -28,8 +28,14 @@ instance: # "FoundryAI"). Set explicitly only if you want a folder # name that differs from the auto-derivation. Env override: # AGNES_WORKSPACE_DIR_NAME. - # logo_svg: Full element for header logo (optional, default: Keboola logo) - # Example: 'Logo' + # logo_svg: | # Inline element rendered into the header brand slot. + # + # Logo + # + # # When set, the SVG replaces the text brand in the header. + # # `name` above still drives browser text and page + # # headings — keep it populated. Env override: + # # AGNES_INSTANCE_LOGO_SVG. # sync_interval: "1 hour" # Cadence shown in analyst CLAUDE.md (e.g., "1 hour", "30 minutes", "daily") # admin_email: "ops@acme.com" # Operator contact shown on /home GWS connector tile as # an "Email admin" mailto button (analysts whose operator diff --git a/pyproject.toml b/pyproject.toml index f3a10e9..8a92161 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agnes-the-ai-analyst" -version = "0.54.5" +version = "0.54.6" description = "Agnes — AI Data Analyst platform for AI analytical systems" requires-python = ">=3.11,<3.14" license = "MIT" diff --git a/tests/test_web_home_page.py b/tests/test_web_home_page.py index b4d2246..2db64eb 100644 --- a/tests/test_web_home_page.py +++ b/tests/test_web_home_page.py @@ -75,11 +75,14 @@ def test_home_not_onboarded_user_sees_setup_view(fresh_db): def test_home_onboarded_user_sees_nav_hub(fresh_db): - """A TRUE-onboarded user gets the post-onboarding view, identifiable by - the 'Welcome back' hero, the 'Step 1 & Step 2 done' completion badge, - the offboard control, and the absence of the inline Step 1 / Step 2 - install commands. Step 3 (auto-mode), connectors, and the rest stay - visible — they remain useful after onboarding.""" + """A TRUE-onboarded user gets the post-onboarding view: the blue + install-hero is gone entirely (no welcome banner, no completion + badge, no inline step commands), the offboard escape strip is the + only setup-flow remnant rendered, and the rest of /home (connector + tiles, news, etc.) stays. PR #289 collapsed the dual-state hero + into a single not-onboarded-only render — pre-PR the onboarded + branch reused the same `.install-hero` shell with welcome copy + and a "Steps 1–4 done" badge.""" from src.db import get_system_db, close_system_db conn = get_system_db() @@ -93,13 +96,11 @@ def test_home_onboarded_user_sees_nav_hub(fresh_db): resp = c.get("/home", cookies={"access_token": sess}) assert resp.status_code == 200 body = resp.text - assert "Welcome back" in body - # Banner copy updated when the explicit "create workspace folder" - # step was inserted between auto-mode and install-Agnes — completion - # badge now spans Steps 1-4 (install Claude Code, auto-mode, mkdir - # workspace, install Agnes from Claude Code). - assert "Steps 1–4 done" in body or "Steps 1–4 done" in body - assert "Mark me as offboarded" in body # offboard control visible + # Install hero entirely absent for onboarded users. + assert '
' not in body + # Offboard escape strip + its button replace the in-hero self-mark control. + assert '
' in body + assert "Mark me as offboarded" in body # All four inline install-blocks are hidden post-onboarding — the # labels rendered inside the install-block divs go away. assert "Step 1 — install Claude Code" not in body @@ -152,37 +153,30 @@ def test_connectors_render_flat_when_onboarded_by_default(fresh_db): assert 'class="home-mock"\n' in body or '
' in body -def test_minimize_toggle_visible_only_when_onboarded(fresh_db): - """The "Minimize setup view" toggle markup is rendered for onboarded - users (so they can opt into the collapsed view) and absent for - not-onboarded users (where the install steps already dominate).""" +def test_minimize_toggle_no_longer_rendered(fresh_db): + """The "Minimize setup view" toggle used to live inside the + onboarded-branch of the install-hero. PR #289 hides the hero + entirely once `users.onboarded=true`, so the minimize toggle + has no rendering site anymore — verify the markup is absent + from both states. (The localStorage `agnes_home_setup_minimized` + flag and its applyMinimize() JS handler stay in the page so a + stale flag from a pre-PR session no-ops cleanly.)""" from src.db import get_system_db, close_system_db - # Not-onboarded → no toggle button. - conn = get_system_db() - try: - _, sess = _make_user_and_session(conn, onboarded=False) - finally: - conn.close() - close_system_db() - c = _client() - resp = c.get("/home", cookies={"access_token": sess}) - assert resp.status_code == 200 - assert '