onboarding: relax confirm flow and add element guides

This commit is contained in:
Brad Stein 2026-01-23 03:10:54 -03:00
parent 8a3377959c
commit 1b6e58f782
20 changed files with 85 additions and 238 deletions

View File

@ -9,13 +9,13 @@ import string
from typing import Any from typing import Any
from urllib.parse import quote from urllib.parse import quote
from flask import jsonify, request, g, redirect from flask import jsonify, request, redirect
import psycopg import psycopg
from .. import ariadne_client from .. import ariadne_client
from ..db import connect, configured from ..db import connect, configured
from ..keycloak import admin_client, oidc_client, require_auth from ..keycloak import admin_client, oidc_client
from ..mailer import MailerError, access_request_verification_body, send_text_email from ..mailer import MailerError, access_request_verification_body, send_text_email
from ..rate_limit import rate_limit_allow from ..rate_limit import rate_limit_allow
from ..provisioning import provision_access_request, provision_tasks_complete from ..provisioning import provision_access_request, provision_tasks_complete
@ -152,7 +152,6 @@ ONBOARDING_STEPS: tuple[str, ...] = (
"vaultwarden_mobile_app", "vaultwarden_mobile_app",
"keycloak_password_rotated", "keycloak_password_rotated",
"element_recovery_key", "element_recovery_key",
"element_recovery_key_stored",
"element_mobile_app", "element_mobile_app",
"mail_client_setup", "mail_client_setup",
"nextcloud_web_access", "nextcloud_web_access",
@ -181,7 +180,6 @@ ONBOARDING_REQUIRED_STEPS: tuple[str, ...] = (
"vaultwarden_mobile_app", "vaultwarden_mobile_app",
"keycloak_password_rotated", "keycloak_password_rotated",
"element_recovery_key", "element_recovery_key",
"element_recovery_key_stored",
"mail_client_setup", "mail_client_setup",
"nextcloud_web_access", "nextcloud_web_access",
"nextcloud_mail_integration", "nextcloud_mail_integration",
@ -204,7 +202,6 @@ ONBOARDING_STEP_PREREQUISITES: dict[str, set[str]] = {
"vaultwarden_mobile_app": {"vaultwarden_master_password"}, "vaultwarden_mobile_app": {"vaultwarden_master_password"},
"keycloak_password_rotated": {"vaultwarden_master_password"}, "keycloak_password_rotated": {"vaultwarden_master_password"},
"element_recovery_key": {"keycloak_password_rotated"}, "element_recovery_key": {"keycloak_password_rotated"},
"element_recovery_key_stored": {"element_recovery_key"},
"element_mobile_app": {"element_recovery_key"}, "element_mobile_app": {"element_recovery_key"},
"mail_client_setup": {"vaultwarden_master_password"}, "mail_client_setup": {"vaultwarden_master_password"},
"nextcloud_web_access": {"vaultwarden_master_password"}, "nextcloud_web_access": {"vaultwarden_master_password"},
@ -212,15 +209,13 @@ ONBOARDING_STEP_PREREQUISITES: dict[str, set[str]] = {
"nextcloud_desktop_app": {"nextcloud_web_access"}, "nextcloud_desktop_app": {"nextcloud_web_access"},
"nextcloud_mobile_app": {"nextcloud_web_access"}, "nextcloud_mobile_app": {"nextcloud_web_access"},
"budget_encryption_ack": {"nextcloud_mail_integration"}, "budget_encryption_ack": {"nextcloud_mail_integration"},
"firefly_password_rotated": {"element_recovery_key_stored"}, "firefly_password_rotated": {"element_recovery_key"},
"wger_password_rotated": {"firefly_password_rotated"}, "wger_password_rotated": {"firefly_password_rotated"},
"jellyfin_web_access": {"vaultwarden_master_password"}, "jellyfin_web_access": {"vaultwarden_master_password"},
"jellyfin_mobile_app": {"jellyfin_web_access"}, "jellyfin_mobile_app": {"jellyfin_web_access"},
"jellyfin_tv_setup": {"jellyfin_web_access"}, "jellyfin_tv_setup": {"jellyfin_web_access"},
} }
_ELEMENT_RECOVERY_ARTIFACT = "element_recovery_key_sha256"
_SHA256_HEX_RE = re.compile(r"^[0-9a-f]{64}$")
_VAULTWARDEN_READY_STATUSES = {"already_present", "active", "ready"} _VAULTWARDEN_READY_STATUSES = {"already_present", "active", "ready"}
@ -959,14 +954,9 @@ def register(app) -> None:
mark_done = completed mark_done = completed
if mark_done: if mark_done:
if step == "element_recovery_key":
return (
jsonify({"error": "step requires verification"}),
400,
)
prerequisites = ONBOARDING_STEP_PREREQUISITES.get(step, set()) prerequisites = ONBOARDING_STEP_PREREQUISITES.get(step, set())
if prerequisites: if prerequisites:
current_completed = _completed_onboarding_steps(conn, code, username) current_completed = _completed_onboarding_steps(conn, code, row.get("username") or "")
missing = sorted(prerequisites - current_completed) missing = sorted(prerequisites - current_completed)
if missing: if missing:
return jsonify({"error": "step is blocked", "blocked_by": missing}), 409 return jsonify({"error": "step is blocked", "blocked_by": missing}), 409
@ -989,15 +979,11 @@ def register(app) -> None:
"DELETE FROM access_request_onboarding_steps WHERE request_code = %s AND step = %s", "DELETE FROM access_request_onboarding_steps WHERE request_code = %s AND step = %s",
(code, step), (code, step),
) )
if step == "element_recovery_key":
conn.execute(
"DELETE FROM access_request_onboarding_artifacts WHERE request_code = %s AND artifact = %s",
(code, _ELEMENT_RECOVERY_ARTIFACT),
)
# Re-evaluate completion to update request status to ready if applicable. # Re-evaluate completion to update request status to ready if applicable.
status = _advance_status(conn, code, username, status) request_username = row.get("username") or ""
onboarding_payload = _onboarding_payload(conn, code, username) status = _advance_status(conn, code, request_username, status)
onboarding_payload = _onboarding_payload(conn, code, request_username)
except Exception: except Exception:
return jsonify({"error": "failed to update onboarding"}), 502 return jsonify({"error": "failed to update onboarding"}), 502
@ -1009,83 +995,7 @@ def register(app) -> None:
} }
) )
@app.route("/api/access/request/onboarding/element-recovery", methods=["POST"])
@require_auth
def request_access_onboarding_element_recovery() -> Any:
if not configured():
return jsonify({"error": "server not configured"}), 503
payload = request.get_json(silent=True) or {}
code = (payload.get("request_code") or payload.get("code") or "").strip()
sha256_hex = (payload.get("sha256") or payload.get("sha256_hex") or "").strip().lower()
if not code:
return jsonify({"error": "request_code is required"}), 400
if not sha256_hex:
return jsonify({"error": "sha256 is required"}), 400
if not _SHA256_HEX_RE.fullmatch(sha256_hex):
return jsonify({"error": "invalid sha256"}), 400
username = getattr(g, "keycloak_username", "") or ""
if not username:
return jsonify({"error": "invalid token"}), 401
try:
with connect() as conn:
row = conn.execute(
"SELECT username, status FROM access_requests WHERE request_code = %s",
(code,),
).fetchone()
if not row:
return jsonify({"error": "not found"}), 404
if (row.get("username") or "") != username:
return jsonify({"error": "forbidden"}), 403
status = _normalize_status(row.get("status") or "")
if status not in {"awaiting_onboarding", "ready"}:
return jsonify({"error": "onboarding not available"}), 409
prerequisites = ONBOARDING_STEP_PREREQUISITES.get("element_recovery_key", set())
if prerequisites:
current_completed = _completed_onboarding_steps(conn, code, username)
missing = sorted(prerequisites - current_completed)
if missing:
return jsonify({"error": "step is blocked", "blocked_by": missing}), 409
conn.execute(
"""
INSERT INTO access_request_onboarding_artifacts (request_code, artifact, value_hash)
VALUES (%s, %s, %s)
ON CONFLICT (request_code, artifact) DO UPDATE
SET value_hash = EXCLUDED.value_hash,
created_at = NOW()
""",
(code, _ELEMENT_RECOVERY_ARTIFACT, sha256_hex),
)
conn.execute(
"""
INSERT INTO access_request_onboarding_steps (request_code, step)
VALUES (%s, %s)
ON CONFLICT (request_code, step) DO NOTHING
""",
(code, "element_recovery_key"),
)
status = _advance_status(conn, code, username, status)
onboarding_payload = _onboarding_payload(conn, code, username)
except Exception:
return jsonify({"error": "failed to verify element recovery key"}), 502
return jsonify(
{
"ok": True,
"status": status,
"onboarding": onboarding_payload,
}
)
@app.route("/api/access/request/onboarding/keycloak-password-rotate", methods=["POST"]) @app.route("/api/access/request/onboarding/keycloak-password-rotate", methods=["POST"])
@require_auth
def request_access_onboarding_keycloak_password_rotate() -> Any: def request_access_onboarding_keycloak_password_rotate() -> Any:
if not configured(): if not configured():
return jsonify({"error": "server not configured"}), 503 return jsonify({"error": "server not configured"}), 503
@ -1095,9 +1005,20 @@ def register(app) -> None:
if not code: if not code:
return jsonify({"error": "request_code is required"}), 400 return jsonify({"error": "request_code is required"}), 400
username = getattr(g, "keycloak_username", "") or "" token_username = ""
if not username: bearer = request.headers.get("Authorization", "")
return jsonify({"error": "invalid token"}), 401 if bearer:
parts = bearer.split(None, 1)
if len(parts) != 2 or parts[0].lower() != "bearer":
return jsonify({"error": "invalid token"}), 401
token = parts[1].strip()
if not token:
return jsonify({"error": "invalid token"}), 401
try:
claims = oidc_client().verify(token)
except Exception:
return jsonify({"error": "invalid token"}), 401
token_username = claims.get("preferred_username") or ""
if not admin_client().ready(): if not admin_client().ready():
return jsonify({"error": "keycloak admin unavailable"}), 503 return jsonify({"error": "keycloak admin unavailable"}), 503
@ -1110,7 +1031,8 @@ def register(app) -> None:
).fetchone() ).fetchone()
if not row: if not row:
return jsonify({"error": "not found"}), 404 return jsonify({"error": "not found"}), 404
if (row.get("username") or "") != username: request_username = row.get("username") or ""
if token_username and request_username != token_username:
return jsonify({"error": "forbidden"}), 403 return jsonify({"error": "forbidden"}), 403
status = _normalize_status(row.get("status") or "") status = _normalize_status(row.get("status") or "")
@ -1119,12 +1041,12 @@ def register(app) -> None:
prerequisites = ONBOARDING_STEP_PREREQUISITES.get("keycloak_password_rotated", set()) prerequisites = ONBOARDING_STEP_PREREQUISITES.get("keycloak_password_rotated", set())
if prerequisites: if prerequisites:
current_completed = _completed_onboarding_steps(conn, code, username) current_completed = _completed_onboarding_steps(conn, code, request_username)
missing = sorted(prerequisites - current_completed) missing = sorted(prerequisites - current_completed)
if missing: if missing:
return jsonify({"error": "step is blocked", "blocked_by": missing}), 409 return jsonify({"error": "step is blocked", "blocked_by": missing}), 409
user = admin_client().find_user(username) or {} user = admin_client().find_user(request_username) or {}
user_id = user.get("id") if isinstance(user, dict) else None user_id = user.get("id") if isinstance(user, dict) else None
if not isinstance(user_id, str) or not user_id: if not isinstance(user_id, str) or not user_id:
return jsonify({"error": "keycloak user not found"}), 409 return jsonify({"error": "keycloak user not found"}), 409
@ -1147,7 +1069,7 @@ def register(app) -> None:
(code, _KEYCLOAK_PASSWORD_ROTATION_REQUESTED_ARTIFACT), (code, _KEYCLOAK_PASSWORD_ROTATION_REQUESTED_ARTIFACT),
) )
onboarding_payload = _onboarding_payload(conn, code, username) onboarding_payload = _onboarding_payload(conn, code, request_username)
except Exception: except Exception:
return jsonify({"error": "failed to request password rotation"}), 502 return jsonify({"error": "failed to request password rotation"}), 502

View File

@ -161,7 +161,7 @@
<input <input
type="checkbox" type="checkbox"
:checked="isStepDone(step.id)" :checked="isStepDone(step.id)"
:disabled="!auth.authenticated || loading || isStepBlocked(step.id)" :disabled="loading || isStepBlocked(step.id)"
@change="toggleStep(step.id, $event)" @change="toggleStep(step.id, $event)"
/> />
<span>{{ step.title }}</span> <span>{{ step.title }}</span>
@ -233,56 +233,7 @@
<p v-else class="muted">Guide coming soon.</p> <p v-else class="muted">Guide coming soon.</p>
</details> </details>
<div v-if="step.action === 'element_recovery'" class="recovery-verify"> <div class="step-actions">
<input
v-model="elementRecoveryKey"
class="input mono"
type="text"
placeholder="Paste recovery key (hashed locally)"
:disabled="!auth.authenticated || loading || isStepDone(step.id) || isStepBlocked(step.id)"
/>
<button
class="primary verify"
type="button"
@click="verifyElementRecoveryKey"
:disabled="
!auth.authenticated ||
loading ||
isStepDone(step.id) ||
isStepBlocked(step.id) ||
!elementRecoveryKey.trim()
"
>
Verify
</button>
<button
class="secondary"
type="button"
@click="confirmStep(step)"
:disabled="loading || isStepDone(step.id) || isStepBlocked(step.id) || !elementRecoveryKey.trim()"
>
{{ confirmLabel(step) }}
</button>
</div>
<div v-else class="step-actions">
<template v-if="step.action === 'keycloak_rotate'">
<button
class="secondary"
type="button"
@click="requestKeycloakPasswordRotation"
:disabled="
!auth.authenticated ||
loading ||
isStepDone('keycloak_password_rotated') ||
isStepBlocked('keycloak_password_rotated') ||
keycloakPasswordRotationRequested
"
>
Start Keycloak update
</button>
<a class="mono" href="https://live.bstein.dev" target="_blank" rel="noreferrer">Open Element</a>
</template>
<button <button
class="secondary" class="secondary"
type="button" type="button"
@ -361,7 +312,6 @@ const passwordCopied = ref(false);
const usernameCopied = ref(false); const usernameCopied = ref(false);
const tasks = ref([]); const tasks = ref([]);
const blocked = ref(false); const blocked = ref(false);
const elementRecoveryKey = ref("");
const keycloakPasswordRotationRequested = ref(false); const keycloakPasswordRotationRequested = ref(false);
const activeSectionId = ref("vaultwarden"); const activeSectionId = ref("vaultwarden");
const guideShots = ref({}); const guideShots = ref({});
@ -383,7 +333,6 @@ const STEP_PREREQS = {
vaultwarden_mobile_app: ["vaultwarden_master_password"], vaultwarden_mobile_app: ["vaultwarden_master_password"],
keycloak_password_rotated: ["vaultwarden_master_password"], keycloak_password_rotated: ["vaultwarden_master_password"],
element_recovery_key: ["keycloak_password_rotated"], element_recovery_key: ["keycloak_password_rotated"],
element_recovery_key_stored: ["element_recovery_key"],
element_mobile_app: ["element_recovery_key"], element_mobile_app: ["element_recovery_key"],
mail_client_setup: ["vaultwarden_master_password"], mail_client_setup: ["vaultwarden_master_password"],
nextcloud_web_access: ["vaultwarden_master_password"], nextcloud_web_access: ["vaultwarden_master_password"],
@ -391,7 +340,7 @@ const STEP_PREREQS = {
nextcloud_desktop_app: ["nextcloud_web_access"], nextcloud_desktop_app: ["nextcloud_web_access"],
nextcloud_mobile_app: ["nextcloud_web_access"], nextcloud_mobile_app: ["nextcloud_web_access"],
budget_encryption_ack: ["nextcloud_mail_integration"], budget_encryption_ack: ["nextcloud_mail_integration"],
firefly_password_rotated: ["element_recovery_key_stored"], firefly_password_rotated: ["element_recovery_key"],
wger_password_rotated: ["firefly_password_rotated"], wger_password_rotated: ["firefly_password_rotated"],
jellyfin_web_access: ["vaultwarden_master_password"], jellyfin_web_access: ["vaultwarden_master_password"],
jellyfin_mobile_app: ["jellyfin_web_access"], jellyfin_mobile_app: ["jellyfin_web_access"],
@ -448,12 +397,12 @@ const SECTION_DEFS = [
{ {
id: "element", id: "element",
title: "Element", title: "Element",
description: "Rotate your Keycloak password, create a recovery key, and store it safely.", description: "Secure chat, calls, and video for the lab.",
steps: [ steps: [
{ {
id: "keycloak_password_rotated", id: "keycloak_password_rotated",
title: "Rotate your Keycloak password", title: "Connect to Element web",
action: "keycloak_rotate", action: "confirm",
description: description:
"Sign in to Element with the temporary password. Keycloak will prompt you to set a new password. Store the new password in Vaultwarden.", "Sign in to Element with the temporary password. Keycloak will prompt you to set a new password. Store the new password in Vaultwarden.",
links: [ links: [
@ -464,18 +413,12 @@ const SECTION_DEFS = [
}, },
{ {
id: "element_recovery_key", id: "element_recovery_key",
title: "Create and verify your recovery key", title: "Create your recovery key",
action: "element_recovery", action: "confirm",
description: description:
"In Element settings → Encryption, create a recovery key so you can restore encrypted history if you lose a device.", "In Element settings → Encryption, create a recovery key and store it in Vaultwarden.",
guide: { service: "element", step: "step2_record_recovery_key" }, guide: { service: "element", step: "step2_record_recovery_key" },
}, },
{
id: "element_recovery_key_stored",
title: "Store the recovery key in Vaultwarden",
action: "checkbox",
description: "Save the recovery key in Vaultwarden so it never gets lost.",
},
{ {
id: "element_mobile_app", id: "element_mobile_app",
title: "Optional: install Element X on mobile", title: "Optional: install Element X on mobile",
@ -759,7 +702,8 @@ function stepCardClass(step) {
function sectionProgress(section) { function sectionProgress(section) {
const requiredSteps = section.steps.filter((step) => isStepRequired(step.id)); const requiredSteps = section.steps.filter((step) => isStepRequired(step.id));
if (!requiredSteps.length) return "optional"; if (!requiredSteps.length) return "optional";
const doneCount = requiredSteps.filter((step) => isStepDone(step.id)).length; if (isSectionLocked(section)) return `0/${requiredSteps.length} done`;
const doneCount = requiredSteps.filter((step) => isStepDone(step.id) && !isStepBlocked(step.id)).length;
return `${doneCount}/${requiredSteps.length} done`; return `${doneCount}/${requiredSteps.length} done`;
} }
@ -951,18 +895,22 @@ async function setStepCompletion(stepId, completed) {
if (isStepBlocked(stepId)) { if (isStepBlocked(stepId)) {
return; return;
} }
if (stepId === "element_recovery_key") {
return;
}
loading.value = true; loading.value = true;
error.value = ""; error.value = "";
try { try {
const requester = auth.authenticated ? authFetch : fetch; const requester = auth.authenticated ? authFetch : fetch;
const resp = await requester("/api/access/request/onboarding/attest", { let resp = await requester("/api/access/request/onboarding/attest", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ request_code: requestCode.value.trim(), step: stepId, completed }), body: JSON.stringify({ request_code: requestCode.value.trim(), step: stepId, completed }),
}); });
if (resp.status === 401 && requester === authFetch) {
resp = await fetch("/api/access/request/onboarding/attest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ request_code: requestCode.value.trim(), step: stepId, completed }),
});
}
const data = await resp.json().catch(() => ({})); const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`); if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`);
status.value = data.status || status.value; status.value = data.status || status.value;
@ -978,18 +926,15 @@ async function confirmStep(step) {
if (!step || isStepBlocked(step.id) || isStepDone(step.id)) return; if (!step || isStepBlocked(step.id) || isStepDone(step.id)) return;
confirmingStepId.value = step.id; confirmingStepId.value = step.id;
try { try {
if (step.id === "keycloak_password_rotated") {
await requestKeycloakPasswordRotation();
await check();
return;
}
if (step.action === "auto") { if (step.action === "auto") {
await check(); await check();
return; return;
} }
if (step.action === "keycloak_rotate") {
await check();
return;
}
if (step.action === "element_recovery") {
await verifyElementRecoveryKey();
return;
}
if (step.action === "confirm") { if (step.action === "confirm") {
await check(); await check();
if (!isStepDone(step.id)) { if (!isStepDone(step.id)) {
@ -1003,42 +948,9 @@ async function confirmStep(step) {
} }
} }
async function verifyElementRecoveryKey() {
if (!auth.authenticated) {
error.value = "Log in to verify your recovery key.";
return;
}
if (isStepBlocked("element_recovery_key")) {
error.value = "Complete earlier onboarding steps first.";
return;
}
const raw = elementRecoveryKey.value.trim();
if (!raw) return;
error.value = "";
loading.value = true;
try {
const sha256 = await sha256Hex(raw);
const resp = await authFetch("/api/access/request/onboarding/element-recovery", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ request_code: requestCode.value.trim(), sha256 }),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`);
status.value = data.status || status.value;
onboarding.value = data.onboarding || onboarding.value;
elementRecoveryKey.value = "";
} catch (err) {
error.value = err?.message || "Failed to verify recovery key";
} finally {
loading.value = false;
}
}
async function requestKeycloakPasswordRotation() { async function requestKeycloakPasswordRotation() {
if (!auth.authenticated) { if (!requestCode.value.trim()) {
error.value = "Log in to request password rotation."; error.value = "Request code is missing.";
return; return;
} }
if (isStepBlocked("keycloak_password_rotated")) { if (isStepBlocked("keycloak_password_rotated")) {
@ -1050,11 +962,19 @@ async function requestKeycloakPasswordRotation() {
loading.value = true; loading.value = true;
error.value = ""; error.value = "";
try { try {
const resp = await authFetch("/api/access/request/onboarding/keycloak-password-rotate", { const requester = auth.authenticated ? authFetch : fetch;
let resp = await requester("/api/access/request/onboarding/keycloak-password-rotate", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ request_code: requestCode.value.trim() }), body: JSON.stringify({ request_code: requestCode.value.trim() }),
}); });
if (resp.status === 401 && requester === authFetch) {
resp = await fetch("/api/access/request/onboarding/keycloak-password-rotate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ request_code: requestCode.value.trim() }),
});
}
const data = await resp.json().catch(() => ({})); const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`); if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`);
onboarding.value = data.onboarding || onboarding.value; onboarding.value = data.onboarding || onboarding.value;
@ -1067,14 +987,6 @@ async function requestKeycloakPasswordRotation() {
} }
} }
async function sha256Hex(value) {
const data = new TextEncoder().encode(value);
const hash = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(hash))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
function parseManifest(files) { function parseManifest(files) {
const grouped = {}; const grouped = {};
for (const path of files) { for (const path of files) {
@ -1507,7 +1419,13 @@ button.copy:disabled {
} }
.step-links a { .step-links a {
color: var(--text-link); color: var(--accent-cyan);
text-decoration: none;
font-weight: 600;
}
.step-links a:hover {
text-decoration: underline;
} }
.step-actions { .step-actions {
@ -1554,21 +1472,28 @@ button.copy:disabled {
overflow: hidden; overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.2); background: rgba(0, 0, 0, 0.2);
padding: 6px; padding: 0;
display: grid; position: relative;
gap: 6px;
cursor: zoom-in; cursor: zoom-in;
} }
.guide-shot figcaption { .guide-shot figcaption {
position: absolute;
top: 8px;
left: 8px;
right: 8px;
font-size: 12px; font-size: 12px;
color: var(--text-muted); color: var(--text-strong);
margin: 0 4px; background: rgba(0, 0, 0, 0.65);
padding: 4px 8px;
border-radius: 8px;
z-index: 2;
} }
.guide-shot img { .guide-shot img {
width: 100%; width: 100%;
border-radius: 8px; display: block;
border-radius: 10px;
} }
.guide-pagination { .guide-pagination {
@ -1654,12 +1579,12 @@ button.copy:disabled {
} }
.lightbox-card { .lightbox-card {
width: min(1100px, 92vw); width: min(1400px, 96vw);
max-height: 92vh; max-height: 94vh;
background: rgba(10, 14, 24, 0.96); background: rgba(10, 14, 24, 0.96);
border: 1px solid rgba(255, 255, 255, 0.12); border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 16px; border-radius: 16px;
padding: 14px; padding: 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
@ -1678,7 +1603,7 @@ button.copy:disabled {
.lightbox-card img { .lightbox-card img {
width: 100%; width: 100%;
max-height: 74vh; max-height: 82vh;
object-fit: contain; object-fit: contain;
border-radius: 12px; border-radius: 12px;
background: rgba(0, 0, 0, 0.35); background: rgba(0, 0, 0, 0.35);

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB