From 3f50fd977f5ea293ba04d893e773034ed2371e14 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sun, 12 Jul 2026 07:47:59 +0200 Subject: [PATCH 1/3] feat: restore review dashboard polish --- README.md | 9 +- changelog.d/33.changed.md | 5 + docs/access-control.md | 3 +- ...-front-end-product-readiness-boundaries.md | 153 ++++ docs/deployment.md | 5 +- docs/go-live-checklist.md | 2 +- docs/prerequisites.md | 2 +- docs/setup.md | 3 +- .../accounts/templates/accounts/account.html | 5 +- .../accounts/account_confirm_delete.html | 31 + .../templates/registration/login.html | 46 +- src/aces_scenario_workbench/accounts/views.py | 11 +- src/aces_scenario_workbench/settings.py | 19 +- src/aces_scenario_workbench/urls.py | 3 +- .../workbench/management/commands/doctor.py | 1 + .../workbench/static/workbench/app.css | 817 ++++++++++++++++-- .../workbench/static/workbench/app.js | 128 ++- .../workbench/static/workbench/brand-mark.svg | 7 + .../workbench/templates/404.html | 11 + .../workbench/templates/500.html | 11 + .../workbench/templates/workbench/base.html | 44 +- .../templates/workbench/dashboard.html | 8 +- .../templates/workbench/evidence_detail.html | 1 + .../templates/workbench/landing.html | 74 +- .../templates/workbench/privacy.html | 7 + .../workbench/revision_overview.html | 362 +++++--- .../templates/workbench/step_detail.html | 1 + .../templates/workbench/tactic_detail.html | 1 + .../templates/workbench/technique_detail.html | 1 + .../workbench/views.py | 66 +- tests/test_account.py | 12 +- tests/test_hardening.py | 55 +- tests/test_review.py | 8 + tests/test_views.py | 2 + 34 files changed, 1611 insertions(+), 303 deletions(-) create mode 100644 changelog.d/33.changed.md create mode 100644 docs/decisions/adrs/001-front-end-product-readiness-boundaries.md create mode 100644 src/aces_scenario_workbench/accounts/templates/accounts/account_confirm_delete.html create mode 100644 src/aces_scenario_workbench/workbench/static/workbench/brand-mark.svg create mode 100644 src/aces_scenario_workbench/workbench/templates/404.html create mode 100644 src/aces_scenario_workbench/workbench/templates/500.html diff --git a/README.md b/README.md index ee6f223..51e1c9f 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,9 @@ current directory). Point it at PostgreSQL by setting `DATABASE_URL`: export DATABASE_URL=postgres://user:pass@localhost:5432/workbench ``` -The Django admin (`/admin/`) is available to administrators for creating -projects and managing membership. +The Django admin defaults to `/control/` and is available to administrators for +creating projects and managing membership. Set `ACES_WORKBENCH_ADMIN_PATH` to +use a deployment-specific admin path. ## Documentation @@ -81,7 +82,7 @@ rate-limits password-reset and invitation requests. `aces-workbench doctor` reports the live posture. `aces-workbench serve` is the development server and runs in debug mode; host with the container path in [`docs/deployment.md`](docs/deployment.md). Keep the Django admin -(`/admin/`) restricted to administrators. +restricted to administrators. ## CLI @@ -95,7 +96,7 @@ aces-workbench manage [...] # any Django management command ``` Administrators create projects and invite members from the Django admin -(`/admin/`); import a scenario pack's ATLAS technique projection with +(`/control/` by default); import a scenario pack's ATLAS technique projection with `aces-workbench import --project `. ## Development diff --git a/changelog.d/33.changed.md b/changelog.d/33.changed.md new file mode 100644 index 0000000..8f7c2da --- /dev/null +++ b/changelog.d/33.changed.md @@ -0,0 +1,5 @@ +### Changed + +- Restored the authenticated review dashboard experience with branded chrome, + overview metrics, tactic bars, progression cards, richer module tiles, + clickable review rows, safer admin routing, and polished public/auth flows. diff --git a/docs/access-control.md b/docs/access-control.md index 4d9c300..903ec80 100644 --- a/docs/access-control.md +++ b/docs/access-control.md @@ -14,7 +14,8 @@ existence in exactly two ways, both driven by an administrator: ## Inviting someone -In the Django admin (`/admin/`): +In the Django admin (`/control/` by default, unless `ACES_WORKBENCH_ADMIN_PATH` +is set): 1. Go to **Invitations → Add**. 2. Enter the person's **email**, the **project**, and their **role**. diff --git a/docs/decisions/adrs/001-front-end-product-readiness-boundaries.md b/docs/decisions/adrs/001-front-end-product-readiness-boundaries.md new file mode 100644 index 0000000..b96198f --- /dev/null +++ b/docs/decisions/adrs/001-front-end-product-readiness-boundaries.md @@ -0,0 +1,153 @@ +# ADR-001: Front-End Product Readiness Boundaries + +Date: 2026-07-12 + +Status: Accepted + +## Context + +Issue #33 is a cross-cutting product-readiness repair. It touches public entry, +authenticated review chrome, dashboard density, branding, cookie notice, admin +exposure, accessibility, forms, error pages, and print output. + +The repository already has important boundaries that must remain intact: + +- ACES packs and review bundles are the source of scenario content; the + workbench database owns collaboration state only. +- Project membership is the authorization boundary, enforced in + `workbench.access` and `workbench.authz`. +- Account creation is invite-only through the existing accounts app. +- Security posture is centralized in Django settings, middleware, CSP, + django-axes, django-ratelimit, CSRF, and the `doctor` command. +- `prototype/` is non-authoritative seed material; production UI belongs in the + Django views, templates, and static assets. + +## Decision + +The front-end overhaul remains a server-rendered Django application. It must +restore the dashboard-quality experience through existing views, templates, and +static assets rather than introducing a separate client app, a duplicate domain +schema, or persisted reporting tables. + +Dashboard metrics, tactic bars, progression cards, digest/integrity details, and +print output are derived from the existing `Revision` object graph +(`tactics`, `steps`, `techniques`, `evidence`, `metadata`, +`content_digest`). They are presentation context, not new persistence. + +Brand, metadata, legal chrome, footer links, and default page structure are +centralized in `workbench/base.html` and static files under +`workbench/static/workbench/`. Assets and fonts must be same-origin static +assets; do not add CDN-hosted fonts, scripts, or images that would weaken the +current CSP and offline install story. + +The Django admin is not a public landing target. It is mounted through one +normalized setting such as `ACES_WORKBENCH_ADMIN_PATH`, with the root URL +configuration, CSP admin exclusion, documentation, `doctor`, and tests all +deriving from the same value. The default must not be the conventional +`admin/`. Treat the admin path as noise reduction, not as a secret or a +substitute for staff authentication and deployment-layer restriction. + +Public entry remains invite-only: anonymous users get a clear sign-in path, +privacy/cookie links, and product context, but not open registration. Staff-only +admin navigation may be shown after authentication using Django's `admin:index` +URL, never as a public call to action. + +Cookie notice is informational for the current cookie/storage surface: +session/CSRF cookies plus the local theme preference in `localStorage`. Do not +add analytics or non-essential storage as part of this issue. If future +non-essential storage is introduced, consent categories belong behind one +central legal/chrome seam rather than scattered per-template checks. + +Custom 404, 429, and 500 pages use the same branded base where safe and must not +leak exception details. The existing 429 rate-limit handling remains canonical. + +Print/PDF support is HTML-first: use print styles and, if needed, a print-mode +variant of the revision overview. Do not add a server-side PDF renderer or +headless browser dependency unless a later requirement explicitly needs it. + +## Required Cross-Cutting Contracts + +Security gates the implementation must pass: + +- Authentication: reuse Django auth views, the custom email user model, + password validators, django-axes, and django-ratelimit. Do not create open + registration or parallel login/reset flows. +- Authorization: use `login_required`, `access.member_project`, + `access.scoped_revision`, and `authz.can_contribute`; hiding links is not + authorization. +- CSRF and destructive actions: account deletion and all state-changing forms + remain POST plus CSRF. Add a confirmation step for account deletion without + bypassing the existing export/delete account contract. +- Admin exposure: validate the configured admin path as a relative URL path + without query strings, fragments, absolute URLs, backslashes, or `..` + segments. Keep the path out of public anonymous chrome. +- CSP: keep scripts same-origin plus nonce. No inline event handlers, inline + style attributes, or remote scripts/fonts. If a pre-paint theme script remains + inline, it must carry the existing per-request CSP nonce. +- Cookie/storage notice: disclose session, CSRF, and theme-preference storage. + Any banner dismissal storage must itself be covered by the notice. +- Error envelopes: UI errors render branded templates; API upload errors keep + the existing JSON `{"detail": ...}` shape and must not expose secrets or + stack traces. +- OS/runtime exposure: new operational knobs belong in environment variables + read by settings, not command-line arguments. Admin path configuration is not + a secret; do not print secrets, tokens, or configured secret values. + +Canonical incumbents to build on: + +- Settings/env helpers in `aces_scenario_workbench.settings`. +- Readiness reporting in `workbench.management.commands.doctor`. +- URL names from `accounts.urls`, `workbench.urls`, and Django `admin:index`. +- Account forms/views in `accounts.forms` and `accounts.views`. +- Project authorization helpers in `workbench.access` and `workbench.authz`. +- Collaboration context/actions in `workbench.collab`. +- Projection parsing/import rules in `workbench.ingest`. +- Existing test suites for hardening, auth, accounts, review views, collab, + ingest, CLI, and onboarding. +- Local gates: `make check`, `uv run ruff check .`, + `uv run ruff format --check .`, and `uv run pytest`. + +Extensibility seams: + +- Admin mount path: one normalized setting used by URLs, CSP, docs, doctor, and + tests. +- Legal chrome: one footer/cookie-notice surface for privacy, terms/cookie copy, + and any future consent categories. +- Brand metadata: base-template blocks/defaults for title, description, + Open Graph/Twitter text, theme color, and icon assets. +- Tier display: one CSS token/data-attribute vocabulary for `quick`, + `intermediate`, and `advanced`; do not encode colors independently in each + template. +- Print mode: a revision-overview rendering seam that can later feed a real PDF + service if required. + +## Non-Goals + +- No rewrite to a SPA or client-side routing architecture. +- No model rename or migration from `Step`/`path_step` to "Module" as part of + front-end polish; choose display labels deliberately while preserving storage + and URL contracts. +- No new ingestion schema, review-state workflow, role model, or authorization + hierarchy. +- No analytics, tracking pixels, third-party asset CDNs, or non-essential + cookies/storage. +- No server-side PDF generation dependency. +- No broad refactor of domain services, admin models, or migrations merely to + support visual polish. + +## Anti-Patterns To Avoid + +- Linking anonymous users to the admin or treating an obscured admin path as the + only protection. +- Recomputing authorization in templates instead of using the server-side + helpers. +- Duplicating review summary data in new tables when it can be derived from a + revision. +- Adding a second form-validation layer in templates or JavaScript that diverges + from Django forms and model choices. +- Hard-coding `/admin/`, tier colors, legal links, or metadata in multiple + templates. +- Using inline handlers, unsafe-inline CSP, remote fonts, or remote UI scripts + for convenience. +- Rendering false accessibility state from the server, such as a theme toggle + `aria-pressed` value that can only be corrected later by JavaScript. diff --git a/docs/deployment.md b/docs/deployment.md index 237957c..dfb5fe8 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -54,6 +54,7 @@ Set these in the container/host environment for a hosted deployment: | `ACES_WORKBENCH_ALLOWED_HOSTS` | comma-separated hostnames | | `ACES_WORKBENCH_CSRF_TRUSTED_ORIGINS` | comma-separated origins for the public URL | | `ACES_WORKBENCH_CACHE_URL` | shared cache for rate limiting behind multiple workers, e.g. `redis://host:6379/0` | +| `ACES_WORKBENCH_ADMIN_PATH` | admin URL path without leading slash (default `control`) | | `ACES_WORKBENCH_SECURE_COOKIES` | secure session/CSRF cookies (default on when debug is off) | | `ACES_WORKBENCH_SSL_REDIRECT` | redirect HTTP→HTTPS at the app (default on when debug is off) | | `ACES_WORKBENCH_HSTS_SECONDS` | HSTS max-age (default `31536000` when debug is off) | @@ -66,7 +67,9 @@ The HTTPS controls are on by default outside debug mode; the app trusts the `X-Forwarded-Proto` header so the redirect does not loop behind a TLS-terminating proxy. A Content-Security-Policy with a per-request script nonce (no inline scripts) is applied to every response except the Django admin, which ships inline -scripts it does not nonce — keep `/admin/` restricted to administrators. The +scripts it does not nonce — keep the configured admin path restricted to +administrators. The default path is `/control/`; set +`ACES_WORKBENCH_ADMIN_PATH` for a deployment-specific path. The in-memory rate-limit cache is per worker; set `ACES_WORKBENCH_CACHE_URL` to a shared cache so limits hold across processes. diff --git a/docs/go-live-checklist.md b/docs/go-live-checklist.md index 7b6abd6..1e6f5cc 100644 --- a/docs/go-live-checklist.md +++ b/docs/go-live-checklist.md @@ -30,7 +30,7 @@ Work through this before exposing an instance to the internet. See understand why a warning is acceptable for your deployment. - [ ] Migrations are applied (`aces-workbench migrate`). - [ ] The first administrator exists (`aces-workbench createadmin`). -- [ ] The Django admin (`/admin/`) is restricted to administrators. +- [ ] The configured Django admin path is restricted to administrators. - [ ] Sign in over HTTPS, create a project, and send yourself an invitation to confirm email delivery end to end. diff --git a/docs/prerequisites.md b/docs/prerequisites.md index 3027280..bb44147 100644 --- a/docs/prerequisites.md +++ b/docs/prerequisites.md @@ -46,7 +46,7 @@ Everything above, plus — decide and provision these up front: - **A shared cache** (for example Redis, via `ACES_WORKBENCH_CACHE_URL`) if you run more than one worker process, so login and request rate limits are counted across all workers. -- Somewhere to keep the Django admin (`/admin/`) restricted to administrators. +- Somewhere to keep the configured Django admin path restricted to administrators. !!! tip Set these as environment variables before the first public start, then run diff --git a/docs/setup.md b/docs/setup.md index 3a9b4c0..6f13731 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -37,7 +37,8 @@ used and sessions reset on every restart. ## 3. Create projects and add people -Sign in at `/accounts/login/` and open the Django admin at `/admin/`. +Sign in at `/accounts/login/` and open the Django admin at `/control/` unless +you changed `ACES_WORKBENCH_ADMIN_PATH`. - **Create a project**: Admin → Projects → Add. - **Add a member you can manage directly**: Admin → Users → Add (this sets a diff --git a/src/aces_scenario_workbench/accounts/templates/accounts/account.html b/src/aces_scenario_workbench/accounts/templates/accounts/account.html index a55fc84..4fa7b3d 100644 --- a/src/aces_scenario_workbench/accounts/templates/accounts/account.html +++ b/src/aces_scenario_workbench/accounts/templates/accounts/account.html @@ -22,10 +22,7 @@

Delete your account

This permanently deletes your account and the comments and decisions you authored. This cannot be undone.

-
- {% csrf_token %} - -
+

Review deletion steps

Privacy notice

diff --git a/src/aces_scenario_workbench/accounts/templates/accounts/account_confirm_delete.html b/src/aces_scenario_workbench/accounts/templates/accounts/account_confirm_delete.html new file mode 100644 index 0000000..eacf865 --- /dev/null +++ b/src/aces_scenario_workbench/accounts/templates/accounts/account_confirm_delete.html @@ -0,0 +1,31 @@ +{% extends "workbench/base.html" %} + +{% block title %}Delete account — ACES Scenario Workbench{% endblock %} + +{% block content %} + +
+

Delete your account

+

+ This permanently removes your account and the comments and decisions you + authored. Activity records remain for audit history with your identity + removed. This action cannot be undone. +

+ {% if messages %} + + {% endif %} +
+ {% csrf_token %} +
+ + +
+
+ + Cancel +
+
+
+{% endblock %} diff --git a/src/aces_scenario_workbench/accounts/templates/registration/login.html b/src/aces_scenario_workbench/accounts/templates/registration/login.html index 4f98133..6fe62a5 100644 --- a/src/aces_scenario_workbench/accounts/templates/registration/login.html +++ b/src/aces_scenario_workbench/accounts/templates/registration/login.html @@ -3,15 +3,39 @@ {% block title %}Sign in — ACES Scenario Workbench{% endblock %} {% block content %} -

Sign in

- {% if form.errors %} -

Your email and password did not match. Please try again.

- {% endif %} -
- {% csrf_token %} - {{ form.as_p }} - - -
-

Forgot your password?

+
+
+

Project workspace

+

Sign in

+

Use the account your workspace administrator created or invited.

+ {% if form.errors %} + + {% endif %} +
+ {% csrf_token %} +
+ + {{ form.username }} + {% if form.username.errors %}
{{ form.username.errors }}
{% endif %} +
+
+ + {{ form.password }} + {% if form.password.errors %}
{{ form.password.errors }}
{% endif %} +
+ + +
+

Forgot your password?

+
+ +
{% endblock %} diff --git a/src/aces_scenario_workbench/accounts/views.py b/src/aces_scenario_workbench/accounts/views.py index 98cbd9d..4b98853 100644 --- a/src/aces_scenario_workbench/accounts/views.py +++ b/src/aces_scenario_workbench/accounts/views.py @@ -7,7 +7,7 @@ from django.contrib.auth.decorators import login_required from django.http import HttpRequest, HttpResponse, JsonResponse from django.shortcuts import get_object_or_404, redirect, render -from django.views.decorators.http import require_GET, require_http_methods, require_POST +from django.views.decorators.http import require_GET, require_http_methods from django_ratelimit.decorators import ratelimit from .forms import AcceptInvitationForm @@ -113,9 +113,14 @@ def account_export(request: HttpRequest) -> JsonResponse: @login_required -@require_POST +@require_http_methods(["GET", "POST"]) def account_delete(request: HttpRequest) -> HttpResponse: - """Delete the signed-in user's account and personal content (GDPR erasure).""" + """Confirm and delete the signed-in user's account and personal content.""" + if request.method == "GET": + return render(request, "accounts/account_confirm_delete.html") + if request.POST.get("confirm_email", "").strip().lower() != request.user.email.lower(): + messages.error(request, "Enter your email address to confirm account deletion.") + return render(request, "accounts/account_confirm_delete.html", status=400) user = request.user logout(request) user.delete() diff --git a/src/aces_scenario_workbench/settings.py b/src/aces_scenario_workbench/settings.py index 3f6ddde..24739f0 100644 --- a/src/aces_scenario_workbench/settings.py +++ b/src/aces_scenario_workbench/settings.py @@ -13,6 +13,7 @@ import dj_database_url import django_cache_url from csp.constants import NONCE, SELF +from django.core.exceptions import ImproperlyConfigured from django.core.management.utils import get_random_secret_key BASE_DIR = Path(__file__).resolve().parent.parent.parent @@ -30,6 +31,20 @@ def _env_list(name: str, default: str) -> list[str]: return [item.strip() for item in os.environ.get(name, default).split(",") if item.strip()] +def _env_relative_path(name: str, default: str) -> str: + value = os.environ.get(name, default).strip().strip("/") + if ( + not value + or value.startswith(("http:", "https:")) + or "\\" in value + or "?" in value + or "#" in value + or any(part in {"", ".", ".."} for part in value.split("/")) + ): + raise ImproperlyConfigured(f"{name} must be a relative URL path such as 'control'.") + return f"{value}/" + + # A stable key must be provided in any non-local deployment; the random fallback # keeps local use zero-config without committing a secret. SECRET_KEY = os.environ.get("ACES_WORKBENCH_SECRET_KEY") or get_random_secret_key() @@ -141,6 +156,8 @@ def _env_list(name: str, default: str) -> list[str]: LOGIN_REDIRECT_URL = "dashboard" LOGOUT_REDIRECT_URL = "landing" +ADMIN_PATH = _env_relative_path("ACES_WORKBENCH_ADMIN_PATH", "control") + # Email. The console backend (prints messages) is the default so local use needs # no mail server; set the SMTP host to deliver for real. Credentials are read # from the environment, never source. @@ -191,7 +208,7 @@ def _env_list(name: str, default: str) -> list[str]: # unsafe-inline. The Django admin ships inline scripts it does not nonce, so it is # excluded from the policy — keep the admin access-restricted in any deployment. CONTENT_SECURITY_POLICY = { - "EXCLUDE_URL_PREFIXES": ("/admin/",), + "EXCLUDE_URL_PREFIXES": (f"/{ADMIN_PATH}",), "DIRECTIVES": { "default-src": [SELF], "script-src": [SELF, NONCE], diff --git a/src/aces_scenario_workbench/urls.py b/src/aces_scenario_workbench/urls.py index 894f556..73b8734 100644 --- a/src/aces_scenario_workbench/urls.py +++ b/src/aces_scenario_workbench/urls.py @@ -2,11 +2,12 @@ from __future__ import annotations +from django.conf import settings from django.contrib import admin from django.urls import include, path urlpatterns = [ - path("admin/", admin.site.urls), + path(settings.ADMIN_PATH, admin.site.urls), path("accounts/", include("aces_scenario_workbench.accounts.urls")), path("", include("aces_scenario_workbench.workbench.urls")), ] diff --git a/src/aces_scenario_workbench/workbench/management/commands/doctor.py b/src/aces_scenario_workbench/workbench/management/commands/doctor.py index bc1fff3..0167f22 100644 --- a/src/aces_scenario_workbench/workbench/management/commands/doctor.py +++ b/src/aces_scenario_workbench/workbench/management/commands/doctor.py @@ -50,6 +50,7 @@ def _report_hardening(self) -> None: self._report("Brute-force protection", "django-axes", "axes" in settings.INSTALLED_APPS) csp_on = "csp.middleware.CSPMiddleware" in settings.MIDDLEWARE self._report("Content-Security-Policy", "on" if csp_on else "off", csp_on) + self._report("Admin path", f"/{settings.ADMIN_PATH}", settings.ADMIN_PATH != "admin/") def _report_deployment_prereqs(self) -> None: # In debug these local defaults are fine; a public instance must set its diff --git a/src/aces_scenario_workbench/workbench/static/workbench/app.css b/src/aces_scenario_workbench/workbench/static/workbench/app.css index da94d6e..fef4602 100644 --- a/src/aces_scenario_workbench/workbench/static/workbench/app.css +++ b/src/aces_scenario_workbench/workbench/static/workbench/app.css @@ -1,173 +1,800 @@ :root { color-scheme: light; --ink: #182026; - --muted: #59636b; + --muted: #56616a; --line: #ccd2d6; - --line-strong: #9da7ad; + --line-strong: #929ea6; --paper: #f7f8f8; --surface: #ffffff; - --row-hover: #f2f7fa; + --surface-raised: #ffffff; + --row-hover: #eef6fa; --th-bg: #edf0f1; --nav: #20272c; --nav-ink: #ffffff; --nav-muted: #c9d0d4; + --brand: #44a06d; --accent: #1769aa; + --accent-ink: #ffffff; --link: #1769aa; --focus: #0b6fc2; + --danger: #a33a33; + --danger-ink: #ffffff; + --warning: #a25d08; --quick: #1f7a4d; --intermediate: #1769aa; --advanced: #8a3f75; - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, + --shadow: 0 16px 45px rgb(16 24 32 / 10%); + font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } -/* - * Dark palette. The theme is chosen before paint by a small script that sets - * data-theme from the stored preference, falling back to the OS setting, so a - * single declaration serves both the toggle and the system default. - */ :root[data-theme="dark"] { color-scheme: dark; --ink: #e6eaed; - --muted: #9aa4ac; + --muted: #b0bac2; --line: #333c43; - --line-strong: #4a555d; + --line-strong: #5e6a73; --paper: #14181b; --surface: #1c2226; - --row-hover: #232c32; + --surface-raised: #232a2f; + --row-hover: #253039; --th-bg: #242c31; --nav: #0f1316; --nav-ink: #f2f5f7; - --nav-muted: #9aa4ac; - --accent: #4a9fe0; - --link: #6db3ec; - --focus: #6db3ec; - --quick: #4ab887; - --intermediate: #4a9fe0; - --advanced: #c98bb6; + --nav-muted: #b8c1c8; + --brand: #62c992; + --accent: #77b7e8; + --accent-ink: #071018; + --link: #8bc8f4; + --focus: #8bc8f4; + --danger: #ff8f87; + --danger-ink: #190607; + --warning: #ffd798; + --quick: #62c992; + --intermediate: #77b7e8; + --advanced: #e4a4cf; + --shadow: 0 18px 50px rgb(0 0 0 / 30%); } * { box-sizing: border-box; } -body { margin: 0; color: var(--ink); background: var(--paper); } +[hidden] { display: none !important; } +html { min-height: 100%; } +body { + min-height: 100%; + margin: 0; + color: var(--ink); + background: + radial-gradient(circle at top left, rgb(68 160 109 / 10%), transparent 32rem), + var(--paper); +} +body, button, input, select, textarea { + font: inherit; + letter-spacing: 0; +} a { color: var(--link); } a:hover { text-decoration: underline; } -button { font: inherit; } +button, .button-primary, .button-secondary, .button-danger { + display: inline-flex; + min-height: 38px; + align-items: center; + justify-content: center; + gap: 8px; + border-radius: 3px; + padding: 8px 14px; + border: 1px solid var(--accent); + background: var(--accent); + color: var(--accent-ink); + font-weight: 750; + text-decoration: none; + cursor: pointer; +} +button:hover, .button-primary:hover, .button-secondary:hover, .button-danger:hover { + text-decoration: none; + filter: brightness(0.97); +} +.button-secondary { + background: var(--surface); + color: var(--ink); + border-color: var(--line-strong); +} +.button-danger { + background: var(--danger); + border-color: var(--danger); + color: var(--danger-ink); +} :focus-visible { - outline: 3px solid color-mix(in srgb, var(--focus) 45%, transparent); - outline-offset: 1px; + outline: 3px solid var(--focus); + outline-offset: 2px; } .skip-link { - position: absolute; left: -9999px; top: 0; background: var(--surface); - color: var(--ink); padding: 8px 12px; z-index: 10; + position: absolute; + left: -9999px; + top: 0; + background: var(--surface); + color: var(--ink); + padding: 8px 12px; + z-index: 20; } .skip-link:focus { left: 8px; top: 8px; } .topbar { - min-height: 64px; display: flex; align-items: center; gap: 20px; - padding: 10px 24px; background: var(--nav); color: var(--nav-ink); - border-bottom: 3px solid var(--quick); -} -.brand strong { display: block; font-size: 17px; font-weight: 700; } -.brand strong a { color: var(--nav-ink); text-decoration: none; } -.brand span { color: var(--nav-muted); font-size: 12px; } -.topbar-actions { margin-left: auto; display: flex; align-items: center; gap: 14px; } + min-height: 64px; + display: flex; + align-items: center; + gap: 20px; + padding: 10px 24px; + background: var(--nav); + color: var(--nav-ink); + border-bottom: 3px solid var(--brand); +} +.brand { min-width: 280px; } +.brand-lockup { + display: inline-flex; + align-items: center; + gap: 10px; + color: var(--nav-ink); + text-decoration: none; +} +.brand-lockup strong { + display: block; + color: var(--nav-ink); + font-size: 17px; + font-weight: 800; +} +.brand-lockup span span { + display: block; + color: var(--nav-muted); + font-size: 12px; +} +.status { + padding: 5px 8px; + border: 1px solid var(--warning); + color: var(--warning); + font-size: 12px; + font-weight: 800; + text-transform: uppercase; + white-space: nowrap; +} +.top-metrics { + display: flex; + gap: 22px; + margin-left: auto; +} +.top-metric { min-width: 68px; } +.top-metric b { + display: block; + color: var(--nav-ink); + font-size: 16px; + font-variant-numeric: tabular-nums; +} +.top-metric span { + color: var(--nav-muted); + font-size: 11px; + text-transform: uppercase; +} +.topbar-actions { + margin-left: auto; + display: flex; + align-items: center; + gap: 12px; +} +.top-metrics + .topbar-actions { margin-left: 0; } .topbar-actions a { color: var(--nav-ink); } -.theme-toggle { - border: 1px solid var(--line-strong); background: transparent; color: var(--nav-ink); - border-radius: 2px; padding: 7px 10px; cursor: pointer; +.theme-toggle, +.logout-inline button { + min-height: 34px; + border-color: var(--line-strong); + background: transparent; + color: var(--nav-ink); + padding: 6px 10px; } .logout-inline { display: inline; margin: 0; } -.logout-inline button { - border: 1px solid var(--line-strong); background: transparent; color: var(--nav-ink); - border-radius: 2px; padding: 7px 10px; cursor: pointer; + +.tabs { + display: flex; + gap: 2px; + padding: 0 24px; + background: var(--surface); + border-bottom: 1px solid var(--line); +} +.tab { + min-height: 45px; + border: 0; + border-bottom: 3px solid transparent; + border-radius: 0; + background: transparent; + color: var(--muted); + cursor: pointer; + font-weight: 700; +} +.tab[aria-selected="true"] { + color: var(--ink); + border-color: var(--accent); } -main { max-width: 1540px; margin: 0 auto; padding: 22px 24px 40px; } -h1 { margin: 0; font-size: 24px; } -h2 { margin: 22px 0 14px; font-size: 17px; } +main { + max-width: 1540px; + margin: 0 auto; + padding: 22px 24px 40px; +} +.view[hidden] { display: none; } +h1, h2, h3 { letter-spacing: 0; } +h1 { margin: 0; font-size: clamp(25px, 3vw, 42px); line-height: 1.05; } +h2 { margin: 0 0 14px; font-size: 17px; } h3 { margin: 0; font-size: 14px; } -.subhead { margin: 5px 0 20px; color: var(--muted); font-size: 13px; } -.breadcrumb { margin: 0 0 16px; font-size: 12px; color: var(--muted); } +p { line-height: 1.55; } +.readable { max-width: 78ch; } +.subhead { + margin: 6px 0 20px; + color: var(--muted); + font-size: 14px; +} +.breadcrumb { + margin: 0 0 16px; + color: var(--muted); + font-size: 13px; +} .breadcrumb a { color: var(--link); } +.eyebrow { + margin: 0 0 8px; + color: var(--accent); + font-size: 12px; + font-weight: 850; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.lede { + max-width: 64ch; + color: var(--muted); + font-size: 18px; +} + +.hero { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.65fr); + gap: 28px; + align-items: center; + padding: clamp(26px, 6vw, 78px) 0 36px; +} +.hero-copy h1 { max-width: 820px; } +.hero-actions, +.button-row { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 20px; +} +.hero-card { + padding: 22px; + background: var(--surface-raised); + border: 1px solid var(--line); + box-shadow: var(--shadow); +} +.hero-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; +} +.metric-list { + display: grid; + gap: 10px; + margin: 0; +} +.metric-list div { + display: flex; + justify-content: space-between; + gap: 16px; + border-bottom: 1px solid var(--line); + padding-bottom: 10px; +} +.metric-list div:last-child { border-bottom: 0; padding-bottom: 0; } +.metric-list dt { color: var(--muted); font-size: 12px; text-transform: uppercase; } +.metric-list dd { margin: 0; font-weight: 750; } +.feature-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; + margin-bottom: 24px; +} .summary-grid { - display: grid; grid-template-columns: repeat(4, minmax(150px, 1fr)); - border: 1px solid var(--line); background: var(--surface); margin-bottom: 22px; + display: grid; + grid-template-columns: repeat(4, minmax(150px, 1fr)); + border: 1px solid var(--line); + background: var(--surface); + margin-bottom: 22px; +} +.summary-item { + padding: 16px; + border-right: 1px solid var(--line); } -.summary-item { padding: 16px; border-right: 1px solid var(--line); } .summary-item:last-child { border-right: 0; } -.summary-item b { display: block; font-size: 25px; margin-bottom: 2px; } -.summary-item span { color: var(--muted); font-size: 12px; } - -.panel { background: var(--surface); border: 1px solid var(--line); padding: 18px; margin-bottom: 22px; } +.summary-item b { + display: block; + font-size: 25px; + margin-bottom: 2px; + font-variant-numeric: tabular-nums; +} +.summary-item span { + color: var(--muted); + font-size: 12px; +} +.split { + display: grid; + grid-template-columns: minmax(480px, 1fr) minmax(420px, 0.8fr); + gap: 24px; +} +.panel { + background: var(--surface); + border: 1px solid var(--line); + padding: 18px; + margin-bottom: 22px; +} +.bars { display: grid; gap: 7px; } +.bar-row { + display: grid; + grid-template-columns: minmax(145px, 220px) 1fr 46px; + gap: 10px; + align-items: center; + color: var(--ink); + text-decoration: none; +} +.bar-row:hover { text-decoration: none; } +.bar-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; +} +.bar-track { + height: 10px; + width: 100%; + background: color-mix(in srgb, var(--line) 70%, transparent); + border-left: 1px solid var(--line-strong); + border: 0; + appearance: none; +} +.bar-track::-webkit-progress-bar { + background: color-mix(in srgb, var(--line) 70%, transparent); +} +.bar-track::-webkit-progress-value { + background: var(--accent); +} +.bar-track::-moz-progress-bar { + background: var(--accent); +} +.bar-count { + text-align: right; + font-variant-numeric: tabular-nums; + font-size: 12px; +} +.progression { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} +.progress-step { + min-width: 0; + display: block; + border-left: 5px solid var(--line-strong); + padding: 7px 10px; + color: var(--ink); + text-decoration: none; + background: color-mix(in srgb, var(--surface) 88%, var(--paper)); +} +.progress-step:hover { text-decoration: none; background: var(--row-hover); } +.progress-step[data-tier="quick"] { border-color: var(--quick); } +.progress-step[data-tier="intermediate"] { border-color: var(--intermediate); } +.progress-step[data-tier="advanced"] { border-color: var(--advanced); } +.progress-step b { + display: block; + font-size: 12px; +} +.progress-step span { + display: block; + color: var(--muted); + font-size: 11px; + margin-top: 2px; +} +.integrity { + margin: 18px 0 0; + display: grid; + gap: 8px; +} +.integrity-row { + display: grid; + grid-template-columns: 150px 1fr; + gap: 12px; + font-size: 12px; +} +.integrity-row span { color: var(--muted); } +.integrity-row code { overflow-wrap: anywhere; } -.table-wrap { overflow: auto; border: 1px solid var(--line); background: var(--surface); } -table { width: 100%; border-collapse: collapse; font-size: 12px; } -caption { text-align: left; padding: 9px 10px; color: var(--muted); font-size: 12px; } +.toolbar { + display: grid; + grid-template-columns: minmax(240px, 1.7fr) repeat(3, minmax(150px, 0.7fr)) auto; + gap: 10px; + align-items: end; + margin: 0 0 14px; +} +.field label { + display: block; + margin-bottom: 5px; + color: var(--muted); + font-size: 12px; + font-weight: 800; + text-transform: uppercase; +} +.field input, +.field select, +.field textarea, +.stacked-form input, +.stacked-form select, +.stacked-form textarea { + width: 100%; + min-height: 40px; + border: 1px solid var(--line-strong); + border-radius: 3px; + padding: 8px 10px; + background: var(--surface); + color: var(--ink); +} +.field textarea, +.stacked-form textarea { min-height: 92px; resize: vertical; } +.field.actions { + display: flex; + gap: 8px; + align-items: end; +} +.clear { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 38px; + min-height: 38px; + border: 1px solid var(--line-strong); + border-radius: 3px; + background: var(--surface); + color: var(--ink); + font-weight: 850; + text-decoration: none; +} +.result-line { + display: flex; + justify-content: space-between; + gap: 12px; + color: var(--muted); + font-size: 13px; + margin: 0 0 8px; +} +.table-wrap { + overflow: auto; + border: 1px solid var(--line); + background: var(--surface); +} +.table-wrap-tall { max-height: calc(100vh - 260px); } +table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} +caption { + text-align: left; + padding: 9px 10px; + color: var(--muted); + font-size: 12px; +} th { - position: sticky; top: 0; z-index: 1; text-align: left; padding: 9px 10px; - background: var(--th-bg); border-bottom: 1px solid var(--line-strong); color: var(--muted); - font-size: 10px; text-transform: uppercase; + position: sticky; + top: 0; + z-index: 1; + text-align: left; + padding: 9px 10px; + background: var(--th-bg); + border-bottom: 1px solid var(--line-strong); + color: var(--ink); + font-size: 11px; + text-transform: uppercase; +} +td { + padding: 9px 10px; + border-bottom: 1px solid var(--line); + vertical-align: top; } -td { padding: 9px 10px; border-bottom: 1px solid var(--line); vertical-align: top; } tbody tr:hover { background: var(--row-hover); } -.tech-id { white-space: nowrap; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-weight: 700; } -.evidence { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11px; } -.tag { display: inline-block; margin: 0 4px 3px 0; padding: 2px 5px; border: 1px solid var(--line); background: var(--paper); white-space: nowrap; } -.tier { font-weight: 750; } +.clickable-row { cursor: pointer; } +.tech-id, +.evidence, +.evidence-chip, +code { + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; +} +.tech-id { + white-space: nowrap; + font-weight: 800; +} +.evidence, +.evidence-chip { font-size: 12px; } +.planned { + min-width: 360px; + line-height: 1.42; +} +.tag, +.evidence-chip { + display: inline-block; + margin: 0 4px 3px 0; + padding: 2px 5px; + border: 1px solid var(--line); + background: var(--paper); + color: var(--ink); + white-space: nowrap; + text-decoration: none; +} +.tier { font-weight: 800; } .tier[data-tier="quick"] { color: var(--quick); } .tier[data-tier="intermediate"] { color: var(--intermediate); } .tier[data-tier="advanced"] { color: var(--advanced); } - -.module-grid { display: grid; grid-template-columns: repeat(2, minmax(360px, 1fr)); gap: 14px; } +.module-grid { + display: grid; + grid-template-columns: repeat(2, minmax(360px, 1fr)); + gap: 14px; +} .module { - background: var(--surface); border: 1px solid var(--line); - border-left: 5px solid var(--line-strong); padding: 16px; + background: var(--surface); + border: 1px solid var(--line); + border-left: 5px solid var(--line-strong); + padding: 16px; } .module[data-tier="quick"] { border-left-color: var(--quick); } .module[data-tier="intermediate"] { border-left-color: var(--intermediate); } .module[data-tier="advanced"] { border-left-color: var(--advanced); } -.module-head { display: flex; gap: 12px; justify-content: space-between; align-items: start; } -.module-number { color: var(--muted); font-size: 11px; text-transform: uppercase; } -.module-objective { margin: 8px 0 12px; font-size: 13px; line-height: 1.4; } - -.facts { display: grid; grid-template-columns: 160px 1fr; gap: 6px 12px; font-size: 13px; } +.clickable-card { cursor: pointer; } +.clickable-card:hover { + box-shadow: var(--shadow); + transform: translateY(-1px); +} +.module-head { + display: flex; + gap: 12px; + justify-content: space-between; + align-items: start; +} +.module-head h2 { margin: 0; } +.module-number { + color: var(--muted); + font-size: 12px; + text-transform: uppercase; +} +.module-objective { + margin: 8px 0 12px; + font-size: 14px; + line-height: 1.45; +} +.facts { + display: grid; + grid-template-columns: 145px 1fr; + gap: 7px 12px; + font-size: 13px; +} .facts dt { color: var(--muted); } -.facts dd { margin: 0; overflow-wrap: anywhere; } - -.card-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; } -.empty { padding: 30px; text-align: center; color: var(--muted); } +.facts dd { + margin: 0; + overflow-wrap: anywhere; +} +.module-techniques { + margin-top: 13px; + padding-top: 11px; + border-top: 1px solid var(--line); + color: var(--muted); + font-size: 12px; +} +.card-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 8px; +} +.project-card a { + font-size: 18px; + font-weight: 800; +} +.empty { + padding: 30px; + text-align: center; + color: var(--muted); +} +.empty-action { + border: 1px dashed var(--line-strong); + background: var(--surface); +} +.pager { + display: flex; + gap: 14px; + align-items: center; + justify-content: center; + margin-top: 12px; +} +.pager .current { + color: var(--muted); + font-size: 13px; +} -.toolbar { +.auth-shell { display: grid; - grid-template-columns: minmax(220px, 1.7fr) repeat(3, minmax(140px, .7fr)) auto; - gap: 10px; align-items: end; margin: 12px 0 10px; + grid-template-columns: minmax(280px, 460px) minmax(260px, 1fr); + gap: 22px; + align-items: start; + max-width: 980px; + margin: 40px auto; } -.field label { display: block; margin-bottom: 5px; color: var(--muted); font-size: 11px; font-weight: 700; text-transform: uppercase; } -.field input, .field select { - width: 100%; min-height: 38px; border: 1px solid var(--line-strong); - border-radius: 2px; padding: 7px 9px; background: var(--surface); color: var(--ink); +.auth-card { box-shadow: var(--shadow); } +.auth-note { + border-left: 5px solid var(--brand); + padding: 18px; + background: var(--surface); + border-top: 1px solid var(--line); + border-right: 1px solid var(--line); + border-bottom: 1px solid var(--line); } -.field.actions { display: flex; gap: 8px; align-items: end; } -.field.actions button { - min-height: 38px; padding: 7px 14px; border: 1px solid var(--accent); - border-radius: 2px; background: var(--accent); color: #fff; font-weight: 700; cursor: pointer; +.stacked-form { + display: grid; + gap: 14px; } -.clear { - display: inline-flex; align-items: center; justify-content: center; - min-width: 38px; min-height: 38px; border: 1px solid var(--line-strong); - border-radius: 2px; background: var(--surface); color: var(--ink); font-weight: 800; text-decoration: none; +.stacked-form p { margin: 0; } +.stacked-form p label { + display: block; + margin-bottom: 5px; + color: var(--muted); + font-size: 12px; + font-weight: 800; + text-transform: uppercase; +} +.error-summary { + margin: 12px 0; + padding: 12px; + border-left: 5px solid var(--danger); + background: color-mix(in srgb, var(--danger) 12%, var(--surface)); +} +.field-error, +.errorlist { + margin: 6px 0 0; + color: var(--danger); + font-size: 13px; +} +.danger-zone { border-color: var(--danger); } + +.site-footer { + display: flex; + justify-content: space-between; + gap: 16px; + max-width: 1540px; + margin: 0 auto; + padding: 18px 24px 30px; + color: var(--muted); + font-size: 13px; +} +.site-footer nav { + display: flex; + gap: 14px; +} +.cookie-notice { + position: fixed; + right: 18px; + bottom: 18px; + z-index: 15; + display: flex; + max-width: 520px; + align-items: center; + gap: 14px; + padding: 14px; + background: var(--surface-raised); + border: 1px solid var(--line-strong); + box-shadow: var(--shadow); +} +.cookie-notice p { + margin: 4px 0 0; + color: var(--muted); + font-size: 13px; +} + +@media (prefers-reduced-motion: no-preference) { + .clickable-card, + button, + .button-primary, + .button-secondary, + .button-danger { + transition: 120ms ease; + } +} + +@media (max-width: 1000px) { + .topbar { + align-items: flex-start; + flex-wrap: wrap; + padding: 12px 16px; + } + .brand { min-width: min(100%, 280px); } + .top-metrics { + order: 3; + width: 100%; + margin: 0; + justify-content: space-between; + } + .topbar-actions { margin-left: auto; } + .hero, + .split, + .auth-shell { + grid-template-columns: 1fr; + } + .feature-grid { grid-template-columns: 1fr; } } -.result-line { color: var(--muted); font-size: 12px; margin: 0 0 8px; } -.pager { display: flex; gap: 14px; align-items: center; justify-content: center; margin-top: 12px; } -.pager .current { color: var(--muted); font-size: 12px; } @media (max-width: 900px) { - .topbar { align-items: flex-start; flex-wrap: wrap; padding: 12px 16px; } + .tabs { padding: 0 10px; } main { padding: 16px 12px 30px; } .summary-grid { grid-template-columns: repeat(2, 1fr); } + .summary-item:nth-child(2) { border-right: 0; } + .summary-item:nth-child(-n+2) { border-bottom: 1px solid var(--line); } .module-grid { grid-template-columns: 1fr; } - .facts { grid-template-columns: 1fr; } + .facts, + .integrity-row { grid-template-columns: 1fr; } .toolbar { grid-template-columns: 1fr 1fr; } .field-search { grid-column: 1 / -1; } + .progression { grid-template-columns: 1fr; } + .planned { min-width: 280px; } + .cookie-notice { + right: 10px; + left: 10px; + bottom: 10px; + max-width: none; + align-items: flex-start; + flex-direction: column; + } +} + +@media (max-width: 560px) { + .summary-grid, + .toolbar { + grid-template-columns: 1fr; + } + .summary-item { + border-right: 0; + border-bottom: 1px solid var(--line); + } + .summary-item:last-child { border-bottom: 0; } + .topbar-actions, + .site-footer { + width: 100%; + align-items: flex-start; + flex-direction: column; + } +} + +@media print { + .topbar, + .tabs, + .toolbar, + .pager, + .site-footer, + .cookie-notice { + display: none !important; + } + main { + max-width: none; + padding: 0; + } + .view[hidden] { display: block; } + .table-wrap { + max-height: none; + overflow: visible; + } + th { position: static; } + .panel, + .module, + .summary-grid { + break-inside: avoid; + } } diff --git a/src/aces_scenario_workbench/workbench/static/workbench/app.js b/src/aces_scenario_workbench/workbench/static/workbench/app.js index e7832eb..b150968 100644 --- a/src/aces_scenario_workbench/workbench/static/workbench/app.js +++ b/src/aces_scenario_workbench/workbench/static/workbench/app.js @@ -1,30 +1,122 @@ (function () { "use strict"; + const root = document.documentElement; - const button = document.querySelector("[data-theme-toggle]"); - if (!button) { - return; + + function prefersDark() { + return globalThis.matchMedia("(prefers-color-scheme: dark)").matches; + } + + function currentTheme() { + return root.dataset.theme || (prefersDark() ? "dark" : "light"); + } + + function persist(key, value) { + try { + globalThis.localStorage.setItem(key, value); + } catch (error) { + console.warn("Unable to persist browser preference", error); + } } - const prefersDark = () => globalThis.matchMedia("(prefers-color-scheme: dark)").matches; - const currentTheme = () => root.dataset.theme || (prefersDark() ? "dark" : "light"); - const syncButton = () => { - button.setAttribute("aria-pressed", currentTheme() === "dark" ? "true" : "false"); - }; - const persist = (value) => { + function stored(key) { try { - globalThis.localStorage.setItem("aces-theme", value); + return globalThis.localStorage.getItem(key); } catch (error) { - console.warn("Unable to persist theme preference", error); + console.warn("Unable to read browser preference", error); + return null; + } + } + + function setupThemeToggle() { + const button = document.querySelector("[data-theme-toggle]"); + if (!button) { + return; } - }; + const syncButton = () => { + const dark = currentTheme() === "dark"; + button.setAttribute("aria-pressed", dark ? "true" : "false"); + button.textContent = dark ? "Light theme" : "Dark theme"; + button.setAttribute("aria-label", dark ? "Switch to light theme" : "Switch to dark theme"); + }; + + button.addEventListener("click", () => { + const next = currentTheme() === "dark" ? "light" : "dark"; + root.dataset.theme = next; + persist("aces-theme", next); + syncButton(); + }); - button.addEventListener("click", () => { - const next = currentTheme() === "dark" ? "light" : "dark"; - root.dataset.theme = next; - persist(next); syncButton(); - }); + } + + function setupReviewTabs() { + const tabs = Array.from(document.querySelectorAll(".tab[data-view]")); + if (!tabs.length) { + return; + } + const views = Array.from(document.querySelectorAll(".view[id^='view-']")); + const names = new Set(tabs.map((tab) => tab.dataset.view)); + const selectView = (requested) => { + const selected = names.has(requested) ? requested : "overview"; + tabs.forEach((tab) => { + tab.setAttribute("aria-selected", String(tab.dataset.view === selected)); + }); + views.forEach((view) => { + view.hidden = view.id !== `view-${selected}`; + }); + }; + + tabs.forEach((tab) => { + tab.addEventListener("click", () => { + const view = tab.dataset.view; + history.replaceState(null, "", `#${view}`); + selectView(view); + }); + }); + + globalThis.addEventListener("hashchange", () => selectView(location.hash.slice(1))); + selectView(location.hash.slice(1)); + } + + function setupClickableContainers() { + document.querySelectorAll("[data-href]").forEach((node) => { + const open = (event) => { + const target = event.target; + if (target instanceof Element && target.closest("a, button, input, select, textarea")) { + return; + } + location.href = node.dataset.href; + }; + node.addEventListener("click", open); + node.addEventListener("keydown", (event) => { + const target = event.target; + if (target instanceof Element && target.closest("a, button, input, select, textarea")) { + return; + } + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + location.href = node.dataset.href; + } + }); + }); + } + + function setupCookieNotice() { + const notice = document.querySelector("[data-cookie-notice]"); + const button = document.querySelector("[data-cookie-accept]"); + if (!notice || !button || stored("aces-cookie-notice") === "accepted") { + return; + } + notice.hidden = false; + button.addEventListener("click", () => { + persist("aces-cookie-notice", "accepted"); + notice.hidden = true; + }); + } - syncButton(); + setupThemeToggle(); + setupReviewTabs(); + setupClickableContainers(); + setupCookieNotice(); })(); diff --git a/src/aces_scenario_workbench/workbench/static/workbench/brand-mark.svg b/src/aces_scenario_workbench/workbench/static/workbench/brand-mark.svg new file mode 100644 index 0000000..515801d --- /dev/null +++ b/src/aces_scenario_workbench/workbench/static/workbench/brand-mark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/aces_scenario_workbench/workbench/templates/404.html b/src/aces_scenario_workbench/workbench/templates/404.html new file mode 100644 index 0000000..85dd441 --- /dev/null +++ b/src/aces_scenario_workbench/workbench/templates/404.html @@ -0,0 +1,11 @@ +{% extends "workbench/base.html" %} + +{% block title %}Page not found — ACES Scenario Workbench{% endblock %} + +{% block content %} +
+

Page not found

+

The page you requested does not exist or is not available to your account.

+

Back to projects

+
+{% endblock %} diff --git a/src/aces_scenario_workbench/workbench/templates/500.html b/src/aces_scenario_workbench/workbench/templates/500.html new file mode 100644 index 0000000..b29e486 --- /dev/null +++ b/src/aces_scenario_workbench/workbench/templates/500.html @@ -0,0 +1,11 @@ +{% extends "workbench/base.html" %} + +{% block title %}Server error — ACES Scenario Workbench{% endblock %} + +{% block content %} +
+

Server error

+

The workbench could not complete this request. Please try again or contact an administrator.

+

Back to home

+
+{% endblock %} diff --git a/src/aces_scenario_workbench/workbench/templates/workbench/base.html b/src/aces_scenario_workbench/workbench/templates/workbench/base.html index 37a0410..1a1800c 100644 --- a/src/aces_scenario_workbench/workbench/templates/workbench/base.html +++ b/src/aces_scenario_workbench/workbench/templates/workbench/base.html @@ -4,7 +4,13 @@ - + + + + + + + {% block title %}ACES Scenario Workbench{% endblock %} diff --git a/src/aces_scenario_workbench/workbench/templates/workbench/dashboard.html b/src/aces_scenario_workbench/workbench/templates/workbench/dashboard.html index 3b8f14c..74290af 100644 --- a/src/aces_scenario_workbench/workbench/templates/workbench/dashboard.html +++ b/src/aces_scenario_workbench/workbench/templates/workbench/dashboard.html @@ -7,12 +7,16 @@

Your projects

{% if projects %}
    {% for project in projects %} -
  • +
  • {{ project.name }} + {% if project.description %}

    {{ project.description }}

    {% endif %}
  • {% endfor %}
{% else %} -

You are not a member of any projects yet.

+
+

No projects yet

+

You are not a member of any projects yet. Ask a workspace administrator for an invitation.

+
{% endif %} {% endblock %} diff --git a/src/aces_scenario_workbench/workbench/templates/workbench/evidence_detail.html b/src/aces_scenario_workbench/workbench/templates/workbench/evidence_detail.html index d8dc68e..5eeaa9e 100644 --- a/src/aces_scenario_workbench/workbench/templates/workbench/evidence_detail.html +++ b/src/aces_scenario_workbench/workbench/templates/workbench/evidence_detail.html @@ -4,6 +4,7 @@ {% block content %}

ACES Scenario Workbench

-

- A collaborative review surface for ACES scenario packs. Sign in to review - scenario objects, discuss them, and record review decisions. -

-

- Administration -

+
+
+

Multi-scenario review workspace

+

Turn validated ACES review bundles into a secure collaboration surface.

+

+ Inspect techniques, tactics, modules, evidence, and review decisions in + one authenticated workspace. Scenario packs remain the source of truth; + the workbench owns comments, decisions, and review state. +

+
+ {% if user.is_authenticated %} + Open projects + {% else %} + Sign in + {% endif %} + Privacy notice +
+
+
+
+ Review-ready + Dashboard core +
+
+
Scenarios
Project-scoped
+
Revisions
Immutable
+
Rows & tiles
Addressable
+
Review state
Auditable
+
+
+
+ +
+
+

Dashboard-first review

+

+ Each revision opens as a dense dashboard with overview charts, + progression tiles, searchable technique rows, and module cards. +

+
+
+

Stable object URLs

+

+ Techniques, tactics, modules, and evidence resolve to real routes so + teams can share and discuss specific scenario objects. +

+
+
+

Internet-safe collaboration

+

+ Sign-in, project membership, CSRF/session protections, rate limits, and + per-object comments keep review activity inside the workbench boundary. +

+
+
+ +
+

Cookies and browser storage

+

+ The workbench uses essential session and CSRF cookies for authentication + and request protection. It also stores theme and notice preferences in + your browser. It does not use advertising cookies. +

+
{% endblock %} diff --git a/src/aces_scenario_workbench/workbench/templates/workbench/privacy.html b/src/aces_scenario_workbench/workbench/templates/workbench/privacy.html index d8f0f60..991f1fb 100644 --- a/src/aces_scenario_workbench/workbench/templates/workbench/privacy.html +++ b/src/aces_scenario_workbench/workbench/templates/workbench/privacy.html @@ -20,4 +20,11 @@

Privacy

Scenario content itself originates from ACES scenario packs and is governed by those packs, not by the workbench.

+

Cookies and local storage

+

+ The workbench uses essential session and CSRF cookies to keep signed-in + requests secure, plus rate-limit state to protect authentication and invite + flows. Theme and cookie-notice preferences are stored in your browser. The + service does not use advertising cookies. +

{% endblock %} diff --git a/src/aces_scenario_workbench/workbench/templates/workbench/revision_overview.html b/src/aces_scenario_workbench/workbench/templates/workbench/revision_overview.html index ea8fe25..5bca00a 100644 --- a/src/aces_scenario_workbench/workbench/templates/workbench/revision_overview.html +++ b/src/aces_scenario_workbench/workbench/templates/workbench/revision_overview.html @@ -2,149 +2,245 @@ {% block title %}{{ scenario.name }} {{ revision.label }} — ACES Scenario Workbench{% endblock %} +{% block topbar_status %} +
Review bundle
+{% endblock %} + +{% block topbar_metrics %} +
+
{{ counts.techniques }}Techniques
+
{{ counts.tactics }}Tactics
+
{{ counts.steps }}Modules
+
{{ counts.quick_starts }}Quick starts
+
+{% endblock %} + +{% block secondary_nav %} + +{% endblock %} + {% block content %} -

{{ scenario.name }}

-

- Revision {{ revision.label }}{% if revision.framework_name %} · {{ revision.framework_name }} {{ revision.framework_release }}{% endif %} + {{ scenario.name }} / + {{ revision.label }}

-
-
{{ counts.tactics }}Tactics
-
{{ counts.techniques }}Techniques
-
{{ counts.steps }}Modules
-
{{ counts.evidence }}Evidence
-
+
+

Coverage overview

+

+ {{ scenario.name }} {{ revision.label }}{% if revision.framework_name %} · {{ revision.framework_name }} {{ revision.framework_release }}{% endif %} +

-

Techniques

-
-

Tactics

- + {% endblock %} diff --git a/src/aces_scenario_workbench/workbench/templates/workbench/step_detail.html b/src/aces_scenario_workbench/workbench/templates/workbench/step_detail.html index ee26c14..d444fbd 100644 --- a/src/aces_scenario_workbench/workbench/templates/workbench/step_detail.html +++ b/src/aces_scenario_workbench/workbench/templates/workbench/step_detail.html @@ -4,6 +4,7 @@ {% block content %}