Give each screen its own YAML override that survives updates (0.2.61)
Override YAML on a screen edits <name>.local.yaml, a package loaded after the shared board package. New profiles get an empty one; an existing profile is attached on the first save without reformatting the file. The editor validates YAML, refuses the managed sections and substitutions, and Save & check runs ESPHome's full validation of the complete profile. The example uses !extend: ESPHome appends package lists instead of merging them by id, so a bare id added a second display without a platform and failed the build. An override is 12 KB at most and a too-large body answers as JSON instead of a bare 413. The sidecar is written before the profile includes it, and a symlinked sidecar is refused. Firmware stays 0.2.52. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -1163,6 +1163,7 @@ packages:
|
||||
ref: {REFS[board]}
|
||||
files: [packages/{board}.yaml]
|
||||
refresh: 0s
|
||||
local_overrides: !include {name}.local.yaml
|
||||
|
||||
api:
|
||||
encryption:
|
||||
|
||||
@@ -38,6 +38,12 @@ def profile_meta(text):
|
||||
'screen': ours, 'api_key': key if isinstance(key, str) else None}
|
||||
|
||||
class Firmware:
|
||||
OVERRIDE_SUFFIX = '.local.yaml'
|
||||
OVERRIDE_LIMIT = 12 * 1024 # bytes; the request body limit is 16 KB and JSON escaping grows the text
|
||||
PROTECTED_OVERRIDE_KEYS = frozenset({'esphome', 'api', 'ota', 'wifi', 'packages',
|
||||
'external_components', 'captive_portal'})
|
||||
PROTECTED_SUBSTITUTIONS = frozenset({'DEVICE_NAME', 'DEVICE_FRIENDLY_NAME', 'SCREEN_FIRMWARE_VERSION'})
|
||||
|
||||
def __init__(self, root, data):
|
||||
self.root, self.data = Path(root).resolve(), Path(data)
|
||||
self.job = None
|
||||
@@ -50,7 +56,8 @@ class Firmware:
|
||||
def profiles(self):
|
||||
if not self.root.exists(): return []
|
||||
return [{'file': p.name} for p in sorted(self.root.glob('*.yaml'))
|
||||
if p.name != 'secrets.yaml' and p.is_file() and not p.is_symlink()]
|
||||
if p.name != 'secrets.yaml' and not p.name.endswith(self.OVERRIDE_SUFFIX)
|
||||
and p.is_file() and not p.is_symlink()]
|
||||
|
||||
def profile_names(self):
|
||||
"""{file: {'node': esphome name, 'friendly': friendly name}} for every readable profile.
|
||||
@@ -144,22 +151,115 @@ class Firmware:
|
||||
path.write_text(text)
|
||||
|
||||
def create(self, data):
|
||||
"""Write the device profile (unique API/OTA keys) and any missing wifi secrets."""
|
||||
"""Write the device profile, its empty local override, and any missing wifi secrets."""
|
||||
content = installation_yaml(data)
|
||||
self.root.mkdir(parents=True,exist_ok=True)
|
||||
profile = self.root / (data['name']+'.yaml')
|
||||
if profile.exists() or profile.is_symlink():
|
||||
raise ValueError('This name already exists. Use the existing profile for updates.')
|
||||
override = self.root / (data['name'] + self.OVERRIDE_SUFFIX)
|
||||
if override.exists() or override.is_symlink():
|
||||
raise ValueError('This name already has a local override. Restore or remove it before creating a new screen with this name.')
|
||||
self.store_wifi(data)
|
||||
try:
|
||||
with profile.open('x') as f:
|
||||
os.chmod(profile,0o600);f.write(content)
|
||||
except FileExistsError:
|
||||
raise ValueError('This name already exists. Use the existing profile for updates.')
|
||||
try:
|
||||
with override.open('x') as f:
|
||||
os.chmod(override, 0o600); f.write('{}\n')
|
||||
except FileExistsError:
|
||||
raise ValueError('This name already has a local override. Restore or remove it before creating a new screen with this name.')
|
||||
key = yaml.load(content, Loader=LenientLoader)['api']['encryption']['key']
|
||||
# The key is what Home Assistant asks for when pairing; the page shows it once.
|
||||
return {'file': profile.name, 'node': data['name'], 'api_key': key}
|
||||
|
||||
def _override_path(self, name):
|
||||
profile = self.profile(name)
|
||||
path = self.root / (profile.stem + self.OVERRIDE_SUFFIX)
|
||||
if path.is_symlink():
|
||||
raise ValueError('The local override is a symbolic link; replace it with a regular file first.')
|
||||
return profile, path
|
||||
|
||||
def _atomic_write(self, path, text):
|
||||
temporary = path.with_name(path.name + '.tmp')
|
||||
with temporary.open('w', encoding='utf8') as handle:
|
||||
os.chmod(temporary, 0o600)
|
||||
handle.write(text)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
temporary.replace(path)
|
||||
|
||||
def _ensure_override_include(self, profile):
|
||||
"""Attach an existing profile to its sidecar without reformatting user YAML."""
|
||||
filename = profile.stem + self.OVERRIDE_SUFFIX
|
||||
text = profile.read_text()
|
||||
if re.search(rf'(?m)^\s*local_overrides:\s*!include\s+{re.escape(filename)}\s*$', text):
|
||||
return False
|
||||
if re.search(r'(?m)^\s*local_overrides\s*:', text):
|
||||
raise ValueError('This profile already has a different local_overrides include.')
|
||||
try:
|
||||
parsed = yaml.load(text, Loader=LenientLoader)
|
||||
except yaml.YAMLError as error:
|
||||
raise ValueError("The screen profile isn't valid YAML; fix it before adding an override.") from error
|
||||
packages = parsed.get('packages') if isinstance(parsed, dict) else None
|
||||
if not isinstance(packages, dict):
|
||||
raise ValueError("This profile has no packages section that ESP Screens can extend.")
|
||||
match = re.search(r'(?m)^packages:\s*\n', text)
|
||||
if not match:
|
||||
raise ValueError("This profile has no packages section that ESP Screens can extend.")
|
||||
next_top = re.search(r'(?m)^[^\s#][^\n]*\n', text[match.end():])
|
||||
if not next_top and not text.endswith('\n'):
|
||||
text += '\n'
|
||||
end = match.end() + next_top.start() if next_top else len(text)
|
||||
addition = f' local_overrides: !include {filename}\n'
|
||||
self._atomic_write(profile, text[:end] + addition + text[end:])
|
||||
self._names.pop(profile.name, None)
|
||||
return True
|
||||
|
||||
def _validate_override(self, content):
|
||||
if not isinstance(content, str):
|
||||
raise ValueError('Enter YAML text first.')
|
||||
if len(content.encode('utf8')) > self.OVERRIDE_LIMIT:
|
||||
raise ValueError('The override is too large. Keep it below 12 KB.')
|
||||
if not content.strip():
|
||||
content = '{}\n'
|
||||
try:
|
||||
parsed = yaml.load(content, Loader=LenientLoader)
|
||||
except yaml.YAMLError as error:
|
||||
problem = getattr(error, 'problem', None) or 'invalid YAML'
|
||||
mark = getattr(error, 'problem_mark', None)
|
||||
where = f' on line {mark.line + 1}' if mark else ''
|
||||
raise ValueError(f'YAML error{where}: {problem}.') from error
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError('The override must contain a YAML object, for example display: or substitutions:.')
|
||||
protected = sorted(set(parsed) & self.PROTECTED_OVERRIDE_KEYS)
|
||||
if protected:
|
||||
raise ValueError('These sections stay managed by ESP Screens: ' + ', '.join(protected) + '.')
|
||||
substitutions = parsed.get('substitutions')
|
||||
if isinstance(substitutions, dict):
|
||||
protected = sorted(set(substitutions) & self.PROTECTED_SUBSTITUTIONS)
|
||||
if protected:
|
||||
raise ValueError('These substitutions stay managed by ESP Screens: ' + ', '.join(protected) + '.')
|
||||
return content if content.endswith('\n') else content + '\n'
|
||||
|
||||
def override(self, name):
|
||||
profile, path = self._override_path(name)
|
||||
attached = bool(re.search(rf'(?m)^\s*local_overrides:\s*!include\s+{re.escape(path.name)}\s*$',
|
||||
profile.read_text()))
|
||||
content = path.read_text() if path.exists() else '{}\n'
|
||||
return {'file': profile.name, 'override_file': path.name, 'attached': attached,
|
||||
'exists': path.exists(), 'content': content}
|
||||
|
||||
def save_override(self, name, content):
|
||||
profile, path = self._override_path(name)
|
||||
content = self._validate_override(content)
|
||||
# The sidecar first: a profile must never include a file that isn't there.
|
||||
self._atomic_write(path, content)
|
||||
self._ensure_override_include(profile)
|
||||
return self.override(profile.name)
|
||||
|
||||
def install(self, data):
|
||||
"""Profile plus, when a USB port is chosen, the build and flash in one go.
|
||||
|
||||
@@ -213,6 +313,9 @@ class Firmware:
|
||||
node=yaml.compose(secret_file.read_text())
|
||||
if isinstance(node,yaml.MappingNode):
|
||||
for _,v in node.value:collect(v)
|
||||
override_file = self.root / (profile.stem + self.OVERRIDE_SUFFIX)
|
||||
if override_file.exists() and not override_file.is_symlink():
|
||||
collect(yaml.compose(override_file.read_text()))
|
||||
self.logs.clear()
|
||||
self.job={'file':profile.name,'action':action,'target':target,'state':'running','started':time.time()}
|
||||
self.task=asyncio.create_task(self.run(profile,action,target))
|
||||
|
||||
@@ -1243,6 +1243,8 @@ def create_app(manager, development=False):
|
||||
response = await handler(request)
|
||||
except ValueError as error:
|
||||
return web.json_response({'error': str(error)}, status=400)
|
||||
except web.HTTPRequestEntityTooLarge:
|
||||
return web.json_response({'error': 'That is too much text for one request. Keep it below 12 KB.'}, status=413)
|
||||
except (TypeError, KeyError):
|
||||
return web.json_response({'error': 'Invalid input. Check the name, board, and chosen tiles.'}, status=400)
|
||||
if response.prepared:
|
||||
@@ -1363,6 +1365,13 @@ def create_app(manager, development=False):
|
||||
headers={'Content-Disposition': f'attachment; filename="{claude_skill.NAME}.zip"'})
|
||||
async def firmware_status(request): return web.json_response(manager.firmware.status())
|
||||
async def firmware_start(request): return web.json_response(manager.firmware.start(await request.json()))
|
||||
async def firmware_override(request):
|
||||
return web.json_response(manager.firmware.override(request.match_info['file']))
|
||||
async def firmware_override_save(request):
|
||||
data = await request.json()
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError('Invalid override data.')
|
||||
return web.json_response(manager.firmware.save_override(request.match_info['file'], data.get('content')))
|
||||
async def firmware_create(request):
|
||||
# Profile, missing wifi secrets and (with a USB port) the build and flash in one request.
|
||||
return web.json_response(manager.firmware.install(await request.json()))
|
||||
@@ -1378,6 +1387,8 @@ def create_app(manager, development=False):
|
||||
app.router.add_put('/api/updates', update_settings)
|
||||
app.router.add_get('/api/firmware', firmware_status)
|
||||
app.router.add_post('/api/firmware/jobs', firmware_start)
|
||||
app.router.add_get('/api/firmware/profiles/{file}/override', firmware_override)
|
||||
app.router.add_put('/api/firmware/profiles/{file}/override', firmware_override_save)
|
||||
app.router.add_post('/api/firmware/profiles', firmware_create)
|
||||
app.router.add_get('/', index)
|
||||
app.router.add_get('/api/inventory', inventory)
|
||||
|
||||
@@ -73,6 +73,7 @@ function select(id) {
|
||||
dirty = false;
|
||||
$("#title").value = layout.title;
|
||||
$("#screen-name").textContent = screen.name;
|
||||
$("#open-override").disabled = !screen.update?.profile;
|
||||
$("#editor").hidden = false;
|
||||
$("#empty").hidden = true;
|
||||
$("#dirty").textContent = "All saved";
|
||||
@@ -515,6 +516,17 @@ function renderScreens() {
|
||||
b.append(node("strong", screen.name), meta);
|
||||
b.onclick = () => select(screen.id);
|
||||
item.append(b);
|
||||
const override = node("button", "Override YAML", "mini quiet");
|
||||
override.type = "button";
|
||||
override.disabled = !screen.update?.profile;
|
||||
override.title = screen.update?.profile ? "Hardware-specific YAML for this screen" : "No ESPHome profile found";
|
||||
override.onclick = (event) => {
|
||||
event.stopPropagation();
|
||||
openOverride(screen.update?.profile, screen.name);
|
||||
};
|
||||
const overrideRow = node("div", undefined, "screen-override");
|
||||
overrideRow.append(override);
|
||||
item.append(overrideRow);
|
||||
const update = renderUpdate(screen);
|
||||
if (update) item.append(update);
|
||||
$("#screens").append(item);
|
||||
@@ -528,6 +540,150 @@ function renderScreens() {
|
||||
}
|
||||
renderUpdates();
|
||||
}
|
||||
|
||||
// ----- Per-screen local YAML overrides -----
|
||||
const OVERRIDE_EXAMPLE = `# Hardware-specific changes for this screen.
|
||||
# This file is kept when the shared firmware package updates.
|
||||
# Do not add esphome:, api:, ota:, wifi: or packages: here.
|
||||
|
||||
# Example: a CYD with the ST7789V display controller.
|
||||
# "!extend" changes the display the shared package already defines;
|
||||
# a bare id would add a second, incomplete display and fail the build.
|
||||
display:
|
||||
- id: !extend my_display
|
||||
model: ST7789V
|
||||
`;
|
||||
const overrideEditor = $("#override-editor"), overrideGutter = $("#override-gutter");
|
||||
let overrideProfile = null;
|
||||
function updateOverrideEditor() {
|
||||
if (!overrideEditor || !overrideGutter) return;
|
||||
const lines = overrideEditor.value.split("\n").length;
|
||||
overrideGutter.textContent = Array.from({ length: lines }, (_, i) => i + 1).join("\n");
|
||||
$("#override-count").textContent = `${overrideEditor.value.length} characters · ${lines} line${lines === 1 ? "" : "s"}`;
|
||||
}
|
||||
function setOverrideStatus(message, kind = "") {
|
||||
const status = $("#override-status");
|
||||
status.textContent = message;
|
||||
status.className = `override-status ${kind}`;
|
||||
}
|
||||
function setOverrideContent(content) {
|
||||
overrideEditor.value = content || "";
|
||||
updateOverrideEditor();
|
||||
overrideEditor.scrollTop = 0;
|
||||
overrideGutter.scrollTop = 0;
|
||||
}
|
||||
async function openOverride(profile, friendly = "") {
|
||||
if (!profile) {
|
||||
toast("No ESPHome profile was found for this screen.");
|
||||
return;
|
||||
}
|
||||
overrideProfile = profile;
|
||||
$("#override-title").textContent = `Override YAML${friendly ? ` · ${friendly}` : ""}`;
|
||||
setOverrideStatus("Loading…");
|
||||
$("#override-dialog").showModal();
|
||||
try {
|
||||
const data = await (await api(`firmware/profiles/${encodeURIComponent(profile)}/override`)).json();
|
||||
$("#override-file").textContent = data.override_file;
|
||||
$("#override-state").textContent = data.attached ? "Active" : "Ready to attach";
|
||||
$("#override-state").className = `badge${data.attached ? " online" : ""}`;
|
||||
setOverrideContent(data.exists && data.content !== "{}\n" ? data.content : "");
|
||||
setOverrideStatus(data.attached ? "Changes here apply on the next build." : "Save once to attach this file to the profile.");
|
||||
} catch (error) {
|
||||
setOverrideStatus(error.message, "error");
|
||||
}
|
||||
}
|
||||
function overridePayload() {
|
||||
return { content: overrideEditor.value };
|
||||
}
|
||||
async function saveOverride(runCheck = false) {
|
||||
if (!overrideProfile) return;
|
||||
const save = $("#override-save"), check = $("#override-check");
|
||||
save.disabled = true; check.disabled = true;
|
||||
setOverrideStatus("Checking and saving…");
|
||||
try {
|
||||
const response = await api(`firmware/profiles/${encodeURIComponent(overrideProfile)}/override`, {
|
||||
method: "PUT", body: JSON.stringify(overridePayload()),
|
||||
});
|
||||
const data = await response.json();
|
||||
$("#override-file").textContent = data.override_file;
|
||||
$("#override-state").textContent = "Active";
|
||||
$("#override-state").className = "badge online";
|
||||
if (!runCheck) {
|
||||
setOverrideStatus("Saved. The override is kept during firmware updates.", "ok");
|
||||
toast("Override YAML saved.");
|
||||
return;
|
||||
}
|
||||
setOverrideStatus("Saved. ESPHome is checking the complete profile…");
|
||||
const job = await (await api("firmware/jobs", {
|
||||
method: "POST", body: JSON.stringify({ file: overrideProfile, action: "validate" }),
|
||||
})).json();
|
||||
pollOverrideCheck(job);
|
||||
} catch (error) {
|
||||
setOverrideStatus(error.message, "error");
|
||||
} finally {
|
||||
save.disabled = false; check.disabled = false;
|
||||
}
|
||||
}
|
||||
function pollOverrideCheck(job) {
|
||||
const started = Date.now(), profile = overrideProfile;
|
||||
const poll = async () => {
|
||||
if (overrideProfile !== profile) return; // the dialog closed or shows another screen
|
||||
try {
|
||||
const data = await (await api("firmware")).json();
|
||||
const current = data.job;
|
||||
if (current && current.file === profile && current.state === "running") {
|
||||
setOverrideStatus(`ESPHome is checking the profile… ${current.stage || ""}`.trim());
|
||||
} else if (current && current.file === profile && current.state === "success") {
|
||||
setOverrideStatus("The complete profile is valid. Safe to build and install.", "ok");
|
||||
return;
|
||||
} else if (current && current.file === profile && current.state === "failed") {
|
||||
const error = (data.logs || []).filter((line) => /error|failed/i.test(line)).pop();
|
||||
setOverrideStatus(error || "ESPHome rejected the complete profile. See Firmware & USB for the full log.", "error");
|
||||
return;
|
||||
}
|
||||
if (Date.now() - started < 7200000) setTimeout(poll, 1200);
|
||||
} catch (error) {
|
||||
setOverrideStatus(error.message, "error");
|
||||
}
|
||||
};
|
||||
poll();
|
||||
}
|
||||
$("#open-override").onclick = () => {
|
||||
const screen = inventory.screens.find((entry) => entry.id === selected);
|
||||
openOverride(screen?.update?.profile, screen?.name);
|
||||
};
|
||||
$("#close-override").onclick = () => $("#override-dialog").close();
|
||||
$("#override-dialog").addEventListener("close", () => { overrideProfile = null; });
|
||||
$("#override-save").onclick = () => saveOverride(false);
|
||||
$("#override-check").onclick = () => saveOverride(true);
|
||||
$("#override-example").onclick = () => {
|
||||
if (!overrideEditor.value.trim() || confirm("Replace the current text with the example?")) {
|
||||
setOverrideContent(OVERRIDE_EXAMPLE);
|
||||
setOverrideStatus("Example loaded. Save it when you are ready.");
|
||||
overrideEditor.focus();
|
||||
}
|
||||
};
|
||||
$("#override-empty").onclick = () => {
|
||||
if (!overrideEditor.value.trim() || confirm("Clear the local override?")) {
|
||||
setOverrideContent("");
|
||||
setOverrideStatus("The override will be empty after you save.");
|
||||
overrideEditor.focus();
|
||||
}
|
||||
};
|
||||
overrideEditor.addEventListener("input", updateOverrideEditor);
|
||||
overrideEditor.addEventListener("scroll", () => { overrideGutter.scrollTop = overrideEditor.scrollTop; });
|
||||
overrideEditor.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
const start = overrideEditor.selectionStart, end = overrideEditor.selectionEnd;
|
||||
overrideEditor.setRangeText(" ", start, end, "end");
|
||||
updateOverrideEditor();
|
||||
} else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") {
|
||||
event.preventDefault();
|
||||
saveOverride(false);
|
||||
}
|
||||
});
|
||||
updateOverrideEditor();
|
||||
function renderUpdates() {
|
||||
const u = inventory.updates;
|
||||
$("#updates").hidden = !u;
|
||||
|
||||
@@ -57,7 +57,10 @@
|
||||
<p class="eyebrow" id="screen-name"></p>
|
||||
<h2>Make it your screen</h2>
|
||||
</div>
|
||||
<span id="delivery" class="badge"></span>
|
||||
<div class="editor-head-actions">
|
||||
<button id="open-override" class="quiet mini" type="button">Override YAML</button>
|
||||
<span id="delivery" class="badge"></span>
|
||||
</div>
|
||||
</div>
|
||||
<section class="topbar-editor" id="topbar" aria-labelledby="topbar-title">
|
||||
<div class="section-title">
|
||||
@@ -370,6 +373,45 @@
|
||||
<p id="firmware-status" role="status"></p>
|
||||
<pre id="firmware-log"></pre>
|
||||
</dialog>
|
||||
<dialog id="override-dialog" class="override-dialog" aria-labelledby="override-title">
|
||||
<div class="dialog-head">
|
||||
<div>
|
||||
<p class="eyebrow">ADVANCED · LOCAL OVERRIDE</p>
|
||||
<h2 id="override-title">Override YAML</h2>
|
||||
</div>
|
||||
<button id="close-override" class="quiet" type="button" aria-label="Close">✕</button>
|
||||
</div>
|
||||
<p class="override-intro">
|
||||
This small file is yours. It is loaded after the shared screen package and stays in place when
|
||||
ESP Screen Manager updates. Change hardware-specific details here, such as a display model.
|
||||
</p>
|
||||
<div class="override-notice">
|
||||
<span class="override-icon" aria-hidden="true">⌘</span>
|
||||
<div>
|
||||
<strong>What is safe to change?</strong>
|
||||
<span>Display, touchscreen, substitutions, pins and other device details. Name, Wi-Fi, API, OTA and package links remain managed.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="override-file-row">
|
||||
<span id="override-file" class="override-file"></span>
|
||||
<span id="override-state" class="badge"></span>
|
||||
</div>
|
||||
<div class="yaml-editor" id="yaml-editor-wrap">
|
||||
<div id="override-gutter" class="yaml-gutter" aria-hidden="true">1</div>
|
||||
<textarea id="override-editor" spellcheck="false" autocapitalize="off" autocomplete="off" autocorrect="off"
|
||||
aria-label="Local override YAML" placeholder="# Example\ndisplay:\n - id: !extend my_display\n model: ST7789V"></textarea>
|
||||
</div>
|
||||
<div class="override-tools">
|
||||
<button id="override-example" class="quiet mini" type="button">Use example</button>
|
||||
<button id="override-empty" class="quiet mini" type="button">Clear</button>
|
||||
<span id="override-count" class="hint"></span>
|
||||
</div>
|
||||
<p id="override-status" class="override-status" role="status"></p>
|
||||
<div class="yaml-actions override-actions">
|
||||
<button id="override-check" class="quiet" type="button">Save & check</button>
|
||||
<button id="override-save" type="button">Save override</button>
|
||||
</div>
|
||||
</dialog>
|
||||
<dialog id="tile-sheet" class="tile-sheet" aria-label="Configure tile"></dialog>
|
||||
<dialog id="topbar-sheet" class="tile-sheet" aria-label="Top bar"></dialog>
|
||||
<dialog id="alerts-dialog" class="alerts" aria-label="Alerts">
|
||||
|
||||
@@ -192,6 +192,12 @@ aside h2 {
|
||||
.screen-main .dot.online {
|
||||
color: #2e9d62;
|
||||
}
|
||||
.screen-override {
|
||||
padding: 0 14px 12px;
|
||||
}
|
||||
.screen-override button {
|
||||
width: 100%;
|
||||
}
|
||||
.screen-update {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -413,6 +419,13 @@ body.dragging {
|
||||
align-items: start;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.editor-head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.editor-head .badge {
|
||||
max-width: 240px;
|
||||
}
|
||||
@@ -678,6 +691,10 @@ dialog label {
|
||||
.editor-head {
|
||||
display: block;
|
||||
}
|
||||
.editor-head-actions {
|
||||
justify-content: start;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.editor-head .badge {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
@@ -942,6 +959,120 @@ pre {
|
||||
max-width: 780px;
|
||||
width: 90vw;
|
||||
}
|
||||
.override-dialog {
|
||||
max-width: 820px;
|
||||
width: 92vw;
|
||||
padding: 26px;
|
||||
}
|
||||
.override-intro {
|
||||
max-width: 700px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.override-notice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 11px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #d8e8ff;
|
||||
border-radius: 13px;
|
||||
background: #f4f8ff;
|
||||
color: #34506e;
|
||||
font-size: 13px;
|
||||
}
|
||||
.override-notice strong,
|
||||
.override-notice span {
|
||||
display: block;
|
||||
}
|
||||
.override-notice strong {
|
||||
color: #233c5a;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.override-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
flex: 0 0 25px;
|
||||
border-radius: 8px;
|
||||
background: #dceaff;
|
||||
color: #286cc2;
|
||||
font-weight: 750;
|
||||
}
|
||||
.override-file-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 18px 0 8px;
|
||||
}
|
||||
.override-file {
|
||||
font: 600 13px ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
color: var(--ink);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.yaml-editor {
|
||||
display: grid;
|
||||
grid-template-columns: 43px minmax(0, 1fr);
|
||||
min-height: 340px;
|
||||
border: 1px solid #bac9dc;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
background: #142131;
|
||||
box-shadow: 0 8px 24px #1d2d4014;
|
||||
}
|
||||
.yaml-gutter {
|
||||
padding: 15px 10px 15px 0;
|
||||
overflow: hidden;
|
||||
background: #1b2b3e;
|
||||
color: #71849b;
|
||||
font: 12px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
text-align: right;
|
||||
user-select: none;
|
||||
white-space: pre;
|
||||
}
|
||||
#override-editor {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 340px;
|
||||
resize: vertical;
|
||||
border: 0;
|
||||
border-left: 1px solid #2e4158;
|
||||
border-radius: 0;
|
||||
outline: 0;
|
||||
padding: 15px;
|
||||
background: #142131;
|
||||
color: #eaf2fc;
|
||||
caret-color: #8ec5ff;
|
||||
font: 13px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
tab-size: 2;
|
||||
white-space: pre;
|
||||
}
|
||||
#override-editor::selection {
|
||||
background: #3978bd80;
|
||||
}
|
||||
.override-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 9px;
|
||||
}
|
||||
.override-tools .hint {
|
||||
margin-left: auto;
|
||||
}
|
||||
.override-status {
|
||||
min-height: 20px;
|
||||
margin: 12px 0 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.override-status.ok {
|
||||
color: #21704b;
|
||||
}
|
||||
.override-status.error {
|
||||
color: #a53f3f;
|
||||
}
|
||||
.override-actions {
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.tile-actions {
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user