v0.319.0: the app page in panels, the login in one place, a password the box never had is said in words
gates / gates (push) Successful in 52s

- style A (operator 2026-10-16): hero, tab bar and every section are panels on --bg-1, one width, 16px apart;
  the install page's form groups use the same rule; the settings tab's 700px inner wrapper is gone
- „Belépés az alkalmazásba": name + password side by side, fetched on demand (R-254, R-709); pairing in app_login.go
- the empty-value rule: no stored value -> a sentence, never an eye; page and reveal endpoint share storedValuePresent
- „Cím és kulcsok" is rows (no Domain input, no disabled storage select, no install form); hint under the title
- tests red-proved: app_page_tuning_test.go (4); 36 app-page parity fixtures re-captured, 4 new; install fixtures unchanged

Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
This commit is contained in:
2026-10-16 12:12:06 +02:00
parent 8d44416610
commit d170a2aecf
58 changed files with 6046 additions and 1776 deletions
@@ -18,7 +18,7 @@
{{if .FlashError}}<div class="monitoring-banner monitoring-banner-red">{{.FlashError}}</div>{{end}}
{{if .DataPathCards}}
<div class="app-section" style="margin-top:1rem">
<div class="app-section">
<h3>{{T "app_info.hova_tegyem_a_fajlokat"}}</h3>
<p class="form-hint">{{T "app_info.ezeket_a_mappakat_a_fajlkezeloben"}}</p>
<div class="datapath-list">
@@ -36,7 +36,7 @@
{{end}}
{{if and .Stack.Deployed .MigrateTargets}}
<div class="app-section" style="margin-top:1rem">
<div class="app-section">
<h3>{{T "app_info.athelyezes_masik_tarhelyre"}}</h3>
<p class="form-hint">{{T "app_info.ennek_az_alkalmazasnak_az_adatait"}}</p>
<div style="display:flex;gap:.5rem;align-items:center;flex-wrap:wrap;margin-top:.5rem">
@@ -87,7 +87,7 @@ function appMigrate(btn,app,label){
{{/* The nightly copy, stated as a fact. It was a card on the settings page; the files it is about
live here. */}}
<div class="app-section deploy-cross-drive">
<div class="app-section">
<h3>{{T "deploy.biztonsagi_mentes"}}</h3>
<div class="cross-drive-nightly">
@@ -255,13 +255,13 @@ function appMigrate(btn,app,label){
</button>
</div>
<div id="progressCard" class="card" style="max-width:700px;display:none">
<div id="progressCard" class="app-section" style="display:none">
<h3>{{T "app_export.folyamat"}}</h3>
<div id="progressSteps"></div>
<div id="progressError" style="display:none;color:var(--crit);margin-top:1rem;font-weight:600"></div>
</div>
<div id="doneCard" class="card" style="max-width:700px;display:none">
<div id="doneCard" class="app-section" style="display:none">
<h3 style="color:var(--blue)">{{T "app_export.kesz"}}</h3>
<div style="margin-bottom:1rem">
<span id="doneFile" style="font-weight:600"></span>
@@ -1,9 +1,11 @@
{{/* THE APP'S CONFIGURATION FIELDS — shared by the install wizard and the settings tab.
{{/* THE APP'S CONFIGURATION FIELDS — the install wizard's form (deploy.html).
ONE copy of this markup, rendered by two surfaces, because they are the same fields: the
install page submits them, the settings tab shows what was submitted. `.AlreadyDeployed` is
the single flag every branch below reads, exactly as it did when both surfaces were one page —
that is what keeps the install form and the settings view from drifting apart.
v0.319.0: the settings tab no longer renders this — it shows the install answers as rows and
the login as one panel (app_settings.html, app_login.go). The `.AlreadyDeployed` branches below
are NOT dead: `/apps/{slug}/install` still answers for an app that is installed, and renders
this form read-only. Their guards are what keep an installed app's secrets out of that page
(TestDeployPage_DeployedApp_DoesNotCarryTheSecret, TestR709_AnInstalledAppsPasswordIsNotInThePage)
and the parity case `deploy_installed` renders them.
The secret rules are unchanged and load-bearing:
R-254 — an INSTALLED app's `type: secret` value is not in the page; the eye fetches it.
@@ -14,21 +14,33 @@
Everything here is PLAIN TEXT through html/template. No markdown, no raw HTML: a step that
contains a tag is shown as the characters the author typed. */}}
<!-- Screenshots (graceful — hidden if assets don't exist) -->
<script>
// Defined BEFORE the images: an error event can fire as soon as an <img> is parsed.
function appShotGone(img) {
img.style.display = 'none';
var box = img.parentNode, left = false;
box.querySelectorAll('img').forEach(function (i) { if (i.style.display !== 'none') { left = true; } });
if (!left) { box.closest('.app-section').style.display = 'none'; }
}
</script>
<!-- Screenshots (graceful — each hidden if its asset does not exist, and the section with the last
one, so a panel is never drawn around nothing) -->
<div class="app-section" data-screenshots-section="1">
<div class="app-screenshots" id="screenshots">
<img src="{{screenshotURL .Meta.Slug 1}}" alt="" class="app-screenshot"
onerror="this.style.display='none'">
onerror="appShotGone(this)">
<img src="{{screenshotURL .Meta.Slug 2}}" alt="" class="app-screenshot"
onerror="this.style.display='none'">
onerror="appShotGone(this)">
<img src="{{screenshotURL .Meta.Slug 3}}" alt="" class="app-screenshot"
onerror="this.style.display='none'">
onerror="appShotGone(this)">
</div>
</div>
{{if not .Deployed}}
{{/* The install button lives on this tab, because this tab IS the page for a not-installed app.
The server refuses a non-installable template regardless; hiding the button keeps the page
from offering an action that would be rejected. */}}
<div class="app-section" style="margin-top:1rem">
<div class="app-section">
{{if canInstall .Meta}}
<a href="/apps/{{.Meta.Slug}}/install" class="btn btn-primary" data-install-cta="1"
onclick="return checkBeforeDeploy(event, '{{.Stack.Name}}')">{{T "app_info.telepites"}}</a>
@@ -71,7 +83,7 @@
{{if .AppInfo.Guide.Rendered}}
<div class="app-guide" data-guide="1" data-guide-checked="{{.AppInfo.Guide.CheckedVersion}}">
{{- range .AppInfo.Guide.Sections}}
<div class="app-section" style="margin-top:1rem" data-guide-section="1">
<div class="app-section" data-guide-section="1">
<h3>{{.Title}}</h3>
{{if .Intro}}<p>{{.Intro}}</p>{{end}}
{{if .Steps}}
@@ -82,7 +94,7 @@
</div>
{{- end}}
{{- if .AppInfo.Guide.FAQ}}
<div class="app-section" style="margin-top:1rem" data-guide-faq="1">
<div class="app-section" data-guide-faq="1">
<h3>{{T "app_page.faq"}}</h3>
<dl class="app-faq">
{{- range .AppInfo.Guide.FAQ}}
@@ -94,7 +106,7 @@
{{- end}}
</div>
{{else}}
<p class="form-hint" style="margin-top:1rem" data-guide-missing="1">{{T "app_page.guide_missing"}}</p>
<div class="app-section" data-guide-missing="1"><p>{{T "app_page.guide_missing"}}</p></div>
{{end}}
<div class="app-sections">
@@ -7,7 +7,7 @@
the reason the tabs are four URLs and not four hidden panels: a poller on the settings tab
would ask the box for 200 log lines every three seconds for nothing. */}}
<div data-tab-panel="logs">
<div class="app-section" data-tab-panel="logs">
<div class="logs-container" id="logs-container">
<pre class="logs-output" id="logs-output">{{.Logs}}</pre>
</div>
@@ -17,7 +17,7 @@
„Adatok es mentes" holds everything about the FILES, including the backup card that used to
sit on this page. */}}
<div class="deploy-container">
<div class="app-settings-tab">
{{if .FlashSuccess}}<div class="flash flash-success">{{.FlashSuccess}}</div>{{end}}
{{if .FlashError}}<div class="flash flash-error">{{.FlashError}}</div>{{end}}
@@ -62,13 +62,13 @@
{{/* 3. THE REST OF WHAT THE APP WANTS FROM THE HOUSEHOLD. */}}
{{- if .InstallHoldClosed}}
<div class="app-section" style="margin-top:1rem" id="install-hold-card">
<div class="app-section" id="install-hold-card">
<h3>{{T "app_info.install_hold_title"}}</h3>
<p>{{T "app_info.install_hold_closed"}}</p>
</div>
{{- end}}
{{- if .SetupGateClosed}}
<div class="app-section" style="margin-top:1rem" id="setup-gate-card">
<div class="app-section" id="setup-gate-card">
<h3>{{T "app_info.setup_gate_title"}}</h3>
<p>{{T "app_info.setup_gate_closed"}}</p>{{- if .SignupNative}}
<p class="form-hint" data-restarts-once="true">{{T "app_info.restarts_once"}}</p>{{- end}}
@@ -84,7 +84,7 @@
{{- end}}
{{- if .CloseSignupOffered}}
<div class="app-section" style="margin-top:1rem" id="close-signup-card">
<div class="app-section" id="close-signup-card">
<h3>{{T "app_info.signup_title"}}</h3>
<p>{{T "app_info.close_signup_text"}}</p>{{- if .SignupNative}}
<p class="form-hint" data-restarts-once="true">{{T "app_info.restarts_once"}}</p>{{- end}}
@@ -93,7 +93,7 @@
</div>
{{- end}}
{{- if or .SignupClosed .SignupOpenUntil}}
<div class="app-section" style="margin-top:1rem" id="signup-card">
<div class="app-section" id="signup-card">
<h3>{{T "app_info.signup_title"}}</h3>
{{- if .SignupOpenUntil}}
<p>{{T "app_info.signup_open_until"}} {{.SignupOpenUntil}}</p>
@@ -121,6 +121,35 @@
{{/* The logins, each already its own guarded card on the details page. */}}
<div class="app-sections">
{{/* v0.319.0 — „BELEPES AZ ALKALMAZASBA": the name and the password of the app's first login,
side by side. The values are fetched on demand (R-254, R-709); the page learns only whether
the box holds one (app_login.go), and a field it holds none for is a sentence, not an eye. */}}
{{if .LoginFields}}
<div class="app-section" id="app-login-panel" data-login-panel="1">
<h3>{{T "app_page.login_title"}}</h3>
<p>{{T "app_page.login_desc"}}</p>
<div class="app-login-pair">
{{- range .LoginFields}}
<div class="app-login-field" data-login-field="{{.EnvVar}}">
<label class="app-login-label" for="field-{{.EnvVar}}">{{.Label}}</label>
{{- if .Restored}}
<p class="form-hint" data-login-restored="{{.EnvVar}}">{{T "deploy.login_from_backup"}}</p>
{{- else if not .Stored}}
<p class="form-hint" data-login-not-stored="{{.EnvVar}}">{{if .IsName}}{{T "app_page.login_no_name"}}{{else}}{{T "app_page.login_no_password"}}{{end}}</p>
{{- else if .OnDemand}}
<div class="input-with-button">
<input type="password" id="field-{{.EnvVar}}" class="form-control" value="" placeholder="••••••••••••" readonly>
<button type="button" class="btn btn-sm btn-outline" data-login-reveal="{{.EnvVar}}" onclick="revealAutoField('{{$.Stack.Name}}','{{.EnvVar}}', this, 'field-{{.EnvVar}}')">{{T "deploy.megjelenites"}}</button>
</div>
{{- else}}
<input type="text" id="field-{{.EnvVar}}" class="form-control" value="{{.Value}}" readonly>
{{- end}}
</div>
{{- end}}
</div>
</div>
{{end}}
{{if .InitialCreds}}
<div class="app-section">
<h3>{{T "app_info.kezdeti_belepesi_adatok"}}</h3>
@@ -173,8 +202,8 @@
household reached by pressing „Beallitasok" on an installed app. The hint lives with the
fields it is about, at the bottom of this tab. */}}
{{if .StorageInfo}}
<div class="deploy-storage-info">
<h4>{{T "deploy.adattarolas"}}</h4>
<div class="app-section">
<h3>{{T "deploy.adattarolas"}}</h3>
<div class="settings-grid">
<div class="settings-row">
<span class="settings-label">{{T "deploy.tarhely"}}</span>
@@ -201,8 +230,8 @@
</div>
{{end}}
{{if .StaleData}}
<div class="deploy-stale-data">
<h4>{{T "deploy.korabbi_adatok"}}</h4>
<div class="app-section deploy-stale-data">
<h3>{{T "deploy.korabbi_adatok"}}</h3>
<p class="form-hint" style="margin-bottom:1rem">
{{T "deploy.az_alkalmazas_adatainak_masolata_megtala"}}
</p>
@@ -230,7 +259,7 @@
</div>
{{end}}
{{if .HasIntegrations}}
<div class="app-optional-config">
<div class="app-section">
<h3>{{T "deploy.integraciok"}}</h3>
<p class="config-group-desc">
{{T "deploy.mas_telepitett_alkalmazasokkal_valo_ossz"}}
@@ -290,7 +319,7 @@
{{end}}
{{if .GeoGlobalEnabled}}
<div class="app-optional-config">
<div class="app-section">
<h3>{{T "deploy.foldrajzi_korlatozas"}}</h3>
<p class="config-group-desc">
{{T "deploy.az_alkalmazas_egyeni_orszagkorlatozas_ne"}}
@@ -425,7 +454,7 @@
{{end}}
{{if .HasOptionalConfig}}
<div class="app-optional-config">
<div class="app-section">
<h3>{{T "deploy.opcionalis_beallitasok"}}</h3>
{{range .OptionalConfig}}
<div class="config-group">
@@ -519,7 +548,7 @@
{{end}}
{{if .AppEmailSupported}}
<div class="app-optional-config">
<div class="app-section">
<h3>{{T "deploy.email_kuldes"}}</h3>
<p class="config-group-desc">
{{T "deploy.ez_az_alkalmazas_tud_emailt"}}
@@ -542,18 +571,38 @@
</form>
</div>
{{end}}
<div class="app-section">
{{/* „CIM ES KULCSOK" (v0.319.0) — what was set at install, as rows. The hint that says they
cannot be changed sits under the title it is about, not at the bottom of the tab. The domain,
the subdomain and the data folder are the „Cim" and „Tarhely" rows; the login is the panel
above; everything else is a row here (app_login.go appSettingRows). */}}
<div class="app-section" data-address-keys="1">
<h3>{{T "app_page.address_and_keys"}}</h3>
<p class="app-field-hint" data-readonly-hint="1">{{T "app_page.installed_readonly"}}</p>
{{if .EffectiveSubdomain}}
<div class="app-field-row">
<div class="app-field-label">{{T "app_page.address"}}</div>
<div class="app-field-value" data-app-address-row="1">{{.EffectiveSubdomain}}.{{.Domain}}</div>
</div>
{{end}}
<form id="deploy-form" class="deploy-form">
{{template "app_deploy_fields" .}}
</form>
<p class="app-field-hint" data-readonly-hint="1">{{T "app_page.installed_readonly"}}</p>
{{if .CurrentHDDPath}}
<div class="app-field-row">
<div class="app-field-label">{{T "deploy.tarhely"}}</div>
<div class="app-field-value" data-app-storage-row="1">{{if .CurrentHDDPathMissing}}{{T "deploy.nem_elerheto"}}{{else if .CurrentHDDLabel}}{{.CurrentHDDLabel}}{{else}}<span class="mono">{{.CurrentHDDPath}}</span>{{end}}</div>
</div>
{{end}}
{{- range .SettingsRows}}
<div class="app-field-row" data-setting-row="{{.EnvVar}}">
<div class="app-field-label">{{.Label}}</div>
{{- if .OnDemand}}
<div class="app-field-value input-with-button">
<input type="password" id="auto-field-{{.EnvVar}}" class="form-control" value="" placeholder="••••••••••••" readonly aria-label="{{.Label}}">
<button type="button" class="btn btn-sm btn-outline" onclick="revealAutoField('{{$.Stack.Name}}','{{.EnvVar}}', this)">{{T "deploy.megjelenites"}}</button>
</div>
{{- else}}
<div class="app-field-value">{{.Value}}</div>
{{- end}}
</div>
{{- end}}
</div>
</div>
@@ -612,46 +661,9 @@ function icCopyPw(btn) {
{{template "app_press_script" .}}
<script>
function checkStorageSpace(sel) {
var opt = sel.options[sel.selectedIndex];
var warn = document.getElementById('storage-space-warn');
if (!warn) return;
var freePct = parseFloat(opt.getAttribute('data-free-percent') || '100');
warn.style.display = freePct < 20 ? 'block' : 'none';
}
// Check on page load
document.addEventListener('DOMContentLoaded', function() {
var sel = document.querySelector('select[onchange="checkStorageSpace(this)"]');
if (sel) checkStorageSpace(sel);
});
// R-254 site two: on an already-deployed app the value is NOT in this page — it is fetched on
// demand, and the server records the act. Nothing caches it between presses.
// R-709 (v0.280.0): an installed app's `type: password` value, fetched on demand like a secret.
function revealPasswordField(stackName, envVar, btn) {
var el = document.getElementById('field-' + envVar);
if (!el) return;
if (btn.dataset.shown === '1') {
el.value = '';
el.type = 'password';
btn.dataset.shown = '';
return;
}
btn.disabled = true;
fetch('/stacks/' + encodeURIComponent(stackName) + '/auto-field/reveal', {
method: 'POST',
headers: Object.assign({'Content-Type': 'application/x-www-form-urlencoded'}, csrfHeaders()),
credentials: 'same-origin',
body: 'env_var=' + encodeURIComponent(envVar)
}).then(function (r) { return r.json(); }).then(function (j) {
btn.disabled = false;
if (!j.ok) { showAlert(j.error || '{{T "deploy.a_lekeres_nem_sikerult"}}'); return; }
el.value = j.data.value;
el.type = 'text';
el.dataset.revealed = '1';
btn.dataset.shown = '1';
}).catch(function () { btn.disabled = false; showAlert('{{T "deploy.a_lekeres_nem_sikerult"}}'); });
}
// R-254 site two: on an already-deployed app no secret is in this page — it is fetched on demand,
// and the server records the act. Nothing caches it between presses. Since v0.319.0 the login
// panel's name and password use this too (R-709: a `type: password` value, fetched like a secret).
function revealAutoField(stackName, envVar, btn, elId) {
var el = document.getElementById(elId || ('auto-field-' + envVar));
if (!el) return;
+63 -35
View File
@@ -1300,13 +1300,17 @@ a.stat-card:hover {
.login-footer a:hover { color: var(--blue); }
/* --- App Info Page --- */
/* ── THE APP PAGE (v0.316.0) — one column, hairlines, boxes only where they mean something ───────
Operator-approved mockup, 2026-10-15. The rules, and why each one:
ONE COLUMN of ONE WIDTH. Boxes of different widths read as a page assembled by accident.
SECTIONS ARE SEPARATED BY HAIRLINES, not drawn as cards. A card says "this is a thing you act
on"; most of an app page is a thing you READ, so a card there is a promise it cannot keep.
A BOX MEANS SOMETHING. Exactly two exist: blue when the app is waiting for the household, amber
when something is risky. The exception-colour principle, applied to shape as well as colour.
/* ── THE APP PAGE (v0.319.0) — one column, the header, the tab bar and every section a panel ─────
Operator-approved mockup „Felhom app oldal — egy oszlop", 2026-10-16, section style A. The rules:
ONE COLUMN of ONE WIDTH. Every panel is the column's full width; panels of different widths
read as a page assembled by accident.
PANELS ON --bg-1. v0.316.0's hairline sections sat on the page background with the header and
the tab bar, and the operator found the page flat: nothing told one section from the next.
Now the hero, the tab bar and each section are their own panel, 16px apart.
ONE SECTION STYLE ON EVERY SURFACE — the four tabs and the install page. Never mixed. Pinned by
TestAppPage_OneSectionStyleEverywhere.
COLOUR STILL MEANS SOMETHING. A panel is neutral; blue is the app waiting for the household,
amber is a risk. The callouts keep their colours inside or as a panel.
Tokens only — no raw hexes, 2px radius, no shadows. */
.app-page {
@@ -1330,18 +1334,23 @@ a.stat-card:hover {
}
.app-back:hover { color: var(--text-1); }
/* HERO — no box, no border. The app's identity is a heading, not a card. */
/* HERO — its own panel. */
.app-hero {
display: flex;
align-items: flex-start;
gap: 20px;
flex-wrap: wrap;
background: var(--bg-1);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 20px 24px;
margin-top: 16px;
}
.app-hero-logo {
width: 64px; height: 64px;
min-width: 64px;
border: 1px solid var(--line);
background: var(--bg-1);
background: var(--bg-2);
border-radius: var(--radius);
object-fit: contain;
padding: 8px;
@@ -1364,40 +1373,62 @@ a.stat-card:hover {
worked. A success that needed no decision is not news; it is a date. */
.app-meta { font-size: 12px; color: var(--text-3); }
/* TABS — links with a line under the open one. No pills, no fills. */
/* TABS — a panel; the open tab is filled and underlined. */
.app-tabs {
display: flex;
gap: 28px;
gap: 4px;
flex-wrap: wrap;
margin: 28px 0 8px;
border-bottom: 1px solid var(--line);
background: var(--bg-1);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 4px;
margin: 16px 0 20px;
}
.app-tab {
font-size: 15px; font-weight: 600;
padding: 12px 0;
padding: 10px 18px;
color: var(--text-2);
text-decoration: none;
border-radius: var(--radius);
border-bottom: 2px solid transparent;
margin-bottom: -1px;
white-space: nowrap;
}
.app-tab:hover { color: var(--text-1); }
.app-tab-active { color: var(--text-1); border-bottom-color: var(--blue-bright); }
.app-tab-active { color: var(--text-1); background: var(--bg-2); border-bottom-color: var(--blue-bright); }
/* SECTIONS — a hairline between, nothing around. */
.app-section {
padding: 28px 0;
border-bottom: 1px solid var(--line-soft);
background: none;
border-left: 0; border-right: 0; border-top: 0;
border-radius: 0;
margin: 0;
/* SECTIONS — style A: each its own panel, the column's width, 16px apart, the title 18px from the
top edge with a soft rule under it. The install page's form groups (.form-section inside the
wizard's form) and its progress panel are the same panel: one style on every surface. */
.app-section,
.app-page .deploy-form .form-section,
.app-page .deploy-progress {
padding: 18px 24px 22px;
background: var(--bg-1);
border: 1px solid var(--line);
border-radius: var(--radius);
margin: 0 0 16px;
}
.app-section:last-child { border-bottom: 0; }
.app-section > h3, .app-section > h4 {
font-size: 17px; font-weight: 700; color: var(--text-1); margin: 0 0 6px;
.app-section > h3:first-child, .app-section > h4:first-child,
.app-page .deploy-form .form-section > h4:first-child,
.app-page .deploy-progress > h3:first-child {
font-size: 17px; font-weight: 700; color: var(--text-1);
margin: 0 0 12px;
padding-bottom: 10px;
border-bottom: 1px solid var(--line-soft);
}
/* A heading further down a panel is a sub-heading, not a second title. */
.app-section > h3:not(:first-child), .app-section > h4:not(:first-child) {
font-size: 14px; font-weight: 600; color: var(--text-1); margin: 16px 0 6px;
}
.app-section > p { font-size: 14px; color: var(--text-2); line-height: 1.55; margin: 0 0 16px; }
.app-section > :last-child { margin-bottom: 0; }
/* The install wizard's form was one box around all its groups; the groups are the panels now. */
.app-page .deploy-form { background: none; border: 0; padding: 0; }
/* THE LOGIN PAIR — the name and the password side by side, stacked on a phone. */
.app-login-pair { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.app-login-field { min-width: 0; }
.app-login-label { display: block; font-size: 13px; color: var(--text-2); margin-bottom: 6px; }
/* ROWS — label and value, one grid so every value on the page starts at the same x. */
.app-field-row {
@@ -1418,17 +1449,13 @@ a.stat-card:hover {
font-size: 14px;
border: 1px solid var(--line);
}
.app-callout > h3, .app-callout > h4 { font-size: 15px; font-weight: 700; margin: 0 0 6px; color: var(--text-1); }
.app-callout > h3, .app-callout > h4 { font-size: 15px; font-weight: 700; margin: 0 0 6px; color: var(--text-1); padding-bottom: 0; border-bottom: 0; }
/* „The app is waiting for you" — a request, never a fault, so blue and never amber. */
.app-callout-ask { border-color: var(--blue); background: var(--blue-dim); }
/* „Something here is risky" — the exception colour, and only on a deviation. */
.app-callout-warn { border-color: var(--warn); background: var(--warn-dim); }
/* The settings tab reuses the install wizard's field markup, which carries that page's boxes: a
card around the whole form and one around every generated value. On a TAB those are sections, so
the boxes come off — the input itself is still a field, which is what tells the household it is a
value. Scoped to .app-section, so the install page (same column, its own form) is untouched. */
.app-section .deploy-form,
/* A box inside a panel would be a narrower inner card; the panel is the box. */
.app-section .form-group-auto,
.app-section .app-optional-config,
.app-section .deploy-storage-info,
@@ -1450,8 +1477,9 @@ a.stat-card:hover {
.app-page { padding: 0 0 24px; }
.app-field-row { grid-template-columns: 1fr; gap: 4px; }
.app-hero-actions { margin-left: 0; flex-basis: 100%; }
.app-tabs { gap: 18px; }
.app-tab { font-size: 14px; }
.app-tab { font-size: 14px; padding: 8px 12px; }
.app-hero, .app-section, .app-page .deploy-form .form-section, .app-page .deploy-progress { padding-left: 16px; padding-right: 16px; }
.app-login-pair { grid-template-columns: 1fr; }
}
.app-info-hero {