feat(patterns,api): pattern OTA, graceful shutdown, driver delivery updates
- Pattern controller/UI and presets patterns tab for OTA to Wi-Fi drivers - Device controller extensions; driver_delivery chunk handling - main: SIGINT/SIGTERM shutdown, TCP/UDP server close coordination - Submodule led-driver: Wi-Fi default transport, lazy espnow import, dynamic patterns Made-with: Cursor
This commit is contained in:
@@ -3,11 +3,301 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const patternsModal = document.getElementById('patterns-modal');
|
||||
const patternsCloseButton = document.getElementById('patterns-close-btn');
|
||||
const patternsList = document.getElementById('patterns-list');
|
||||
const patternAddButton = document.getElementById('pattern-add-btn');
|
||||
const patternEditorModal = document.getElementById('pattern-editor-modal');
|
||||
const patternEditorCloseButton = document.getElementById('pattern-editor-close-btn');
|
||||
const patternCreateBtn = document.getElementById('pattern-create-btn');
|
||||
const patternCreateName = document.getElementById('pattern-create-name');
|
||||
const patternCreateMinDelay = document.getElementById('pattern-create-min-delay');
|
||||
const patternCreateMaxDelay = document.getElementById('pattern-create-max-delay');
|
||||
const patternCreateMaxColors = document.getElementById('pattern-create-max-colors');
|
||||
const patternCreateFile = document.getElementById('pattern-create-file');
|
||||
const patternCreateCode = document.getElementById('pattern-create-code');
|
||||
const patternCreateOverwrite = document.getElementById('pattern-create-overwrite');
|
||||
const patternCreateN = [1, 2, 3, 4, 5, 6, 7, 8].map((i) =>
|
||||
document.getElementById(`pattern-create-n${i}`),
|
||||
);
|
||||
const patternCreateNSection = document.getElementById('pattern-create-n-section');
|
||||
const patternCreateNEmpty = document.getElementById('pattern-create-n-empty');
|
||||
|
||||
if (!patternsButton || !patternsModal || !patternsList) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nReadableStringFromMeta = (meta, key) => {
|
||||
if (!meta || typeof meta !== 'object') {
|
||||
return '';
|
||||
}
|
||||
const pm = meta.parameter_mappings;
|
||||
if (pm && typeof pm === 'object' && typeof pm[key] === 'string') {
|
||||
const s = pm[key].trim();
|
||||
if (s) {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
if (typeof meta[key] === 'string') {
|
||||
return meta[key].trim();
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const setPatternEditorNFields = (mode, data) => {
|
||||
const meta = data && typeof data === 'object' ? data : {};
|
||||
let visible = 0;
|
||||
const grid = patternCreateNSection && patternCreateNSection.querySelector('.n-params-grid');
|
||||
const h3 = patternCreateNSection && patternCreateNSection.querySelector('h3');
|
||||
|
||||
for (let i = 1; i <= 8; i += 1) {
|
||||
const key = `n${i}`;
|
||||
const labelEl = document.querySelector(`label[for="pattern-create-${key}"]`);
|
||||
const inputEl = document.getElementById(`pattern-create-${key}`);
|
||||
const groupEl = labelEl ? labelEl.closest('.n-param-group') : null;
|
||||
|
||||
if (mode === 'create') {
|
||||
if (labelEl) {
|
||||
labelEl.textContent = '';
|
||||
labelEl.style.display = 'none';
|
||||
}
|
||||
if (inputEl) {
|
||||
inputEl.value = '';
|
||||
inputEl.placeholder = 'Readable name (optional)';
|
||||
inputEl.removeAttribute('aria-label');
|
||||
}
|
||||
if (groupEl) {
|
||||
groupEl.style.display = '';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const readable = nReadableStringFromMeta(meta, key);
|
||||
const show = Boolean(readable);
|
||||
if (labelEl) {
|
||||
labelEl.textContent = '';
|
||||
labelEl.style.display = 'none';
|
||||
}
|
||||
if (inputEl) {
|
||||
inputEl.value = show ? readable : '';
|
||||
inputEl.placeholder = '';
|
||||
if (show) {
|
||||
inputEl.setAttribute('aria-label', readable);
|
||||
} else {
|
||||
inputEl.removeAttribute('aria-label');
|
||||
inputEl.value = '';
|
||||
}
|
||||
}
|
||||
if (groupEl) {
|
||||
groupEl.style.display = show ? '' : 'none';
|
||||
}
|
||||
if (show) {
|
||||
visible += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === 'create') {
|
||||
if (patternCreateNEmpty) {
|
||||
patternCreateNEmpty.style.display = 'none';
|
||||
}
|
||||
if (grid) {
|
||||
grid.style.display = '';
|
||||
}
|
||||
if (h3) {
|
||||
h3.style.display = '';
|
||||
}
|
||||
if (patternCreateNSection) {
|
||||
patternCreateNSection.style.display = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (patternCreateNEmpty) {
|
||||
patternCreateNEmpty.style.display = visible === 0 ? '' : 'none';
|
||||
}
|
||||
if (grid) {
|
||||
grid.style.display = visible === 0 ? 'none' : '';
|
||||
}
|
||||
if (h3) {
|
||||
h3.style.display = visible === 0 ? 'none' : '';
|
||||
}
|
||||
};
|
||||
|
||||
const readFileAsText = (file) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ''));
|
||||
reader.onerror = () => reject(reader.error || new Error('read failed'));
|
||||
reader.readAsText(file);
|
||||
});
|
||||
|
||||
const collectCreatePayload = async () => {
|
||||
const name = patternCreateName ? patternCreateName.value.trim() : '';
|
||||
if (!name) {
|
||||
throw new Error('Pattern name is required.');
|
||||
}
|
||||
let code = '';
|
||||
const fileInput = patternCreateFile && patternCreateFile.files && patternCreateFile.files[0];
|
||||
if (fileInput) {
|
||||
code = await readFileAsText(fileInput);
|
||||
} else if (patternCreateCode && patternCreateCode.value.trim()) {
|
||||
code = patternCreateCode.value;
|
||||
}
|
||||
if (!code.trim()) {
|
||||
throw new Error('Choose a .py file or paste source code.');
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name,
|
||||
code,
|
||||
min_delay: parseInt(patternCreateMinDelay && patternCreateMinDelay.value, 10) || 0,
|
||||
max_delay: parseInt(patternCreateMaxDelay && patternCreateMaxDelay.value, 10) || 0,
|
||||
max_colors: parseInt(patternCreateMaxColors && patternCreateMaxColors.value, 10) || 0,
|
||||
overwrite: !!(patternCreateOverwrite && patternCreateOverwrite.checked),
|
||||
};
|
||||
|
||||
patternCreateN.forEach((el, idx) => {
|
||||
const key = `n${idx + 1}`;
|
||||
if (el && el.value.trim()) {
|
||||
payload[key] = el.value.trim();
|
||||
}
|
||||
});
|
||||
|
||||
return payload;
|
||||
};
|
||||
|
||||
const resetCreateForm = () => {
|
||||
if (patternCreateName) patternCreateName.value = '';
|
||||
if (patternCreateFile) patternCreateFile.value = '';
|
||||
if (patternCreateCode) patternCreateCode.value = '';
|
||||
if (patternCreateMinDelay) patternCreateMinDelay.value = '10';
|
||||
if (patternCreateMaxDelay) patternCreateMaxDelay.value = '10000';
|
||||
if (patternCreateMaxColors) patternCreateMaxColors.value = '10';
|
||||
patternCreateN.forEach((el) => {
|
||||
if (el) el.value = '';
|
||||
});
|
||||
if (patternCreateOverwrite) patternCreateOverwrite.checked = true;
|
||||
setPatternEditorNFields('create', {});
|
||||
};
|
||||
|
||||
if (patternCreateBtn) {
|
||||
patternCreateBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
const payload = await collectCreatePayload();
|
||||
const response = await fetch('/patterns/driver', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error((data && data.error) || 'Create failed');
|
||||
}
|
||||
alert(data.message || 'Pattern created.');
|
||||
resetCreateForm();
|
||||
if (patternEditorModal) {
|
||||
patternEditorModal.classList.remove('active');
|
||||
}
|
||||
await loadPatterns();
|
||||
} catch (e) {
|
||||
console.error('Create pattern failed:', e);
|
||||
alert(e.message || 'Failed to create pattern.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const sendPatternToDevices = async (patternName) => {
|
||||
const response = await fetch(`/patterns/${encodeURIComponent(patternName)}/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error((data && data.error) || 'Failed to send pattern');
|
||||
}
|
||||
const sentCount = data && typeof data.sent_count === 'number' ? data.sent_count : null;
|
||||
if (sentCount === null) {
|
||||
alert(`Sent "${patternName}" to devices.`);
|
||||
} else {
|
||||
alert(`Sent "${patternName}" to ${sentCount} device(s).`);
|
||||
}
|
||||
};
|
||||
|
||||
const loadPatternMetadata = async (patternName, fallbackData) => {
|
||||
const raw = String(patternName || '').trim();
|
||||
const norm = raw.endsWith('.py') ? raw.slice(0, -3).trim() : raw;
|
||||
try {
|
||||
const response = await fetch('/patterns/definitions', {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load pattern definitions');
|
||||
}
|
||||
const definitions = await response.json();
|
||||
if (definitions && typeof definitions === 'object') {
|
||||
if (definitions[raw]) {
|
||||
return definitions[raw];
|
||||
}
|
||||
if (norm && definitions[norm]) {
|
||||
return definitions[norm];
|
||||
}
|
||||
if (norm) {
|
||||
const lower = norm.toLowerCase();
|
||||
const matched = Object.keys(definitions).find(
|
||||
(k) => String(k).toLowerCase() === lower,
|
||||
);
|
||||
if (matched) {
|
||||
return definitions[matched];
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load pattern definitions failed:', error);
|
||||
}
|
||||
return fallbackData || {};
|
||||
};
|
||||
|
||||
const loadPatternIntoEditor = async (patternName, fallbackData) => {
|
||||
const data = await loadPatternMetadata(patternName, fallbackData);
|
||||
if (patternCreateName) {
|
||||
patternCreateName.value = patternName;
|
||||
}
|
||||
if (patternCreateMinDelay) {
|
||||
patternCreateMinDelay.value =
|
||||
data && data.min_delay !== undefined ? String(data.min_delay) : '10';
|
||||
}
|
||||
if (patternCreateMaxDelay) {
|
||||
patternCreateMaxDelay.value =
|
||||
data && data.max_delay !== undefined ? String(data.max_delay) : '10000';
|
||||
}
|
||||
if (patternCreateMaxColors) {
|
||||
patternCreateMaxColors.value =
|
||||
data && data.max_colors !== undefined ? String(data.max_colors) : '10';
|
||||
}
|
||||
setPatternEditorNFields('edit', data);
|
||||
if (patternCreateOverwrite) {
|
||||
patternCreateOverwrite.checked = true;
|
||||
}
|
||||
if (patternCreateFile) {
|
||||
patternCreateFile.value = '';
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/patterns/ota/file/${encodeURIComponent(patternName)}.py`, {
|
||||
headers: { Accept: 'text/plain' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load pattern file');
|
||||
}
|
||||
const source = await response.text();
|
||||
if (patternCreateCode) {
|
||||
patternCreateCode.value = source || '';
|
||||
patternCreateCode.focus();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load pattern source failed:', error);
|
||||
alert('Could not load pattern source into editor.');
|
||||
}
|
||||
};
|
||||
|
||||
const renderPatterns = (patterns) => {
|
||||
patternsList.innerHTML = '';
|
||||
const entries = Object.entries(patterns || {});
|
||||
@@ -32,13 +322,37 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
details.style.color = '#aaa';
|
||||
details.style.fontSize = '0.85em';
|
||||
|
||||
const sendBtn = document.createElement('button');
|
||||
sendBtn.className = 'btn btn-primary btn-small';
|
||||
sendBtn.textContent = 'Send';
|
||||
sendBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
await sendPatternToDevices(patternName);
|
||||
} catch (error) {
|
||||
console.error('Send pattern failed:', error);
|
||||
alert(error.message || 'Failed to send pattern.');
|
||||
}
|
||||
});
|
||||
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.className = 'btn btn-secondary btn-small';
|
||||
editBtn.textContent = 'Edit';
|
||||
editBtn.addEventListener('click', async () => {
|
||||
if (patternEditorModal) {
|
||||
patternEditorModal.classList.add('active');
|
||||
}
|
||||
await loadPatternIntoEditor(patternName, data || {});
|
||||
});
|
||||
|
||||
row.appendChild(label);
|
||||
row.appendChild(details);
|
||||
row.appendChild(editBtn);
|
||||
row.appendChild(sendBtn);
|
||||
patternsList.appendChild(row);
|
||||
});
|
||||
};
|
||||
|
||||
const loadPatterns = async () => {
|
||||
async function loadPatterns() {
|
||||
patternsList.innerHTML = '';
|
||||
const loading = document.createElement('p');
|
||||
loading.className = 'muted-text';
|
||||
@@ -62,7 +376,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
errorMessage.textContent = 'Failed to load patterns.';
|
||||
patternsList.appendChild(errorMessage);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const openModal = () => {
|
||||
patternsModal.classList.add('active');
|
||||
@@ -74,6 +388,21 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
};
|
||||
|
||||
patternsButton.addEventListener('click', openModal);
|
||||
if (patternAddButton) {
|
||||
patternAddButton.addEventListener('click', () => {
|
||||
resetCreateForm();
|
||||
if (patternEditorModal) {
|
||||
patternEditorModal.classList.add('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
if (patternEditorCloseButton) {
|
||||
patternEditorCloseButton.addEventListener('click', () => {
|
||||
if (patternEditorModal) {
|
||||
patternEditorModal.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
if (patternsCloseButton) {
|
||||
patternsCloseButton.addEventListener('click', closeModal);
|
||||
}
|
||||
|
||||
@@ -547,32 +547,26 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
presetPatternInput.style.backgroundColor = '';
|
||||
presetPatternInput.style.cursor = '';
|
||||
}
|
||||
|
||||
// Update labels and visibility based on pattern
|
||||
updatePresetNLabels(patternName);
|
||||
|
||||
|
||||
// Get pattern config to map descriptive names back to n keys
|
||||
const patternConfig = cachedPatterns && cachedPatterns[patternName];
|
||||
const nToLabel = {};
|
||||
if (patternConfig && typeof patternConfig === 'object') {
|
||||
// Now n keys are keys, labels are values
|
||||
Object.entries(patternConfig).forEach(([nKey, label]) => {
|
||||
if (typeof nKey === 'string' && nKey.startsWith('n') && typeof label === 'string') {
|
||||
nToLabel[nKey] = label;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Set n values, checking both n keys and descriptive names
|
||||
for (let i = 1; i <= 8; i++) {
|
||||
const nKey = `n${i}`;
|
||||
const inputEl = document.getElementById(`preset-${nKey}-input`);
|
||||
if (inputEl) {
|
||||
// First check if preset has n key directly
|
||||
if (preset[nKey] !== undefined) {
|
||||
inputEl.value = preset[nKey] || 0;
|
||||
} else {
|
||||
// Check if preset has descriptive name (from pattern.json mapping)
|
||||
const label = nToLabel[nKey];
|
||||
if (label && preset[label] !== undefined) {
|
||||
inputEl.value = preset[label] || 0;
|
||||
@@ -582,6 +576,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// After values: show only mapped n params with labels from pattern.json; clear hidden inputs
|
||||
updatePresetNLabels(patternName);
|
||||
updatePresetEditorTabActionsVisibility();
|
||||
};
|
||||
|
||||
@@ -774,44 +771,65 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
};
|
||||
|
||||
const updatePresetNLabels = (patternName) => {
|
||||
const rawPatternName = String(patternName || '').trim();
|
||||
const normalizedPatternName = rawPatternName.endsWith('.py')
|
||||
? rawPatternName.slice(0, -3)
|
||||
: rawPatternName;
|
||||
let patternConfig =
|
||||
(cachedPatterns && cachedPatterns[rawPatternName]) ||
|
||||
(cachedPatterns && cachedPatterns[normalizedPatternName]) ||
|
||||
null;
|
||||
if (!patternConfig && cachedPatterns && typeof cachedPatterns === 'object') {
|
||||
const lower = normalizedPatternName.toLowerCase();
|
||||
const matchedKey = Object.keys(cachedPatterns).find(
|
||||
(k) => String(k).toLowerCase() === lower,
|
||||
);
|
||||
if (matchedKey) {
|
||||
patternConfig = cachedPatterns[matchedKey];
|
||||
}
|
||||
}
|
||||
if (patternConfig && typeof patternConfig === 'object' && patternConfig.data && typeof patternConfig.data === 'object') {
|
||||
patternConfig = patternConfig.data;
|
||||
}
|
||||
if (patternConfig && typeof patternConfig === 'object' && patternConfig.parameter_mappings && typeof patternConfig.parameter_mappings === 'object') {
|
||||
patternConfig = patternConfig.parameter_mappings;
|
||||
}
|
||||
const labels = {};
|
||||
const visibleNKeys = new Set();
|
||||
|
||||
// Initialize all labels with default n1:, n2:, etc.
|
||||
for (let i = 1; i <= 8; i++) {
|
||||
labels[`n${i}`] = `n${i}:`;
|
||||
}
|
||||
|
||||
const patternConfig = cachedPatterns && cachedPatterns[patternName];
|
||||
|
||||
if (patternConfig && typeof patternConfig === 'object') {
|
||||
// Now n values are keys and descriptive names are values
|
||||
Object.entries(patternConfig).forEach(([key, label]) => {
|
||||
if (typeof key === 'string' && key.startsWith('n') && typeof label === 'string') {
|
||||
labels[key] = `${label}:`;
|
||||
visibleNKeys.add(key); // Mark this n key as visible
|
||||
const text = label.trim();
|
||||
if (text) {
|
||||
labels[key] = `${text}:`;
|
||||
visibleNKeys.add(key);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update labels and show/hide input groups
|
||||
|
||||
for (let i = 1; i <= 8; i++) {
|
||||
const nKey = `n${i}`;
|
||||
const labelEl = document.getElementById(`preset-${nKey}-label`);
|
||||
const inputEl = document.getElementById(`preset-${nKey}-input`);
|
||||
const groupEl = labelEl ? labelEl.closest('.n-param-group') : null;
|
||||
|
||||
const show = visibleNKeys.has(nKey);
|
||||
const inputEl = document.getElementById(`preset-${nKey}-input`);
|
||||
|
||||
if (labelEl) {
|
||||
labelEl.textContent = labels[nKey];
|
||||
labelEl.textContent = show ? labels[nKey] : '';
|
||||
}
|
||||
|
||||
// Show or hide the entire group based on whether it has a mapping
|
||||
if (groupEl) {
|
||||
if (visibleNKeys.has(nKey)) {
|
||||
groupEl.style.display = ''; // Show
|
||||
} else {
|
||||
groupEl.style.display = 'none'; // Hide
|
||||
}
|
||||
groupEl.style.display = show ? '' : 'none';
|
||||
}
|
||||
if (inputEl && !show) {
|
||||
inputEl.value = '0';
|
||||
}
|
||||
}
|
||||
|
||||
const nGrid = presetEditorModal && presetEditorModal.querySelector('.n-params-grid');
|
||||
if (nGrid) {
|
||||
nGrid.style.display = visibleNKeys.size > 0 ? '' : 'none';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -845,6 +863,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
editButton.addEventListener('click', async () => {
|
||||
currentEditId = presetId;
|
||||
currentEditTabId = null;
|
||||
await loadPatterns();
|
||||
const paletteColors = await getCurrentProfilePaletteColors();
|
||||
const presetForEditor = {
|
||||
...(preset || {}),
|
||||
|
||||
@@ -458,22 +458,28 @@ body.preset-ui-run .edit-mode-only {
|
||||
.n-param-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
gap: 0.75rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.n-param-group label {
|
||||
min-width: 40px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.n-input {
|
||||
flex: 1;
|
||||
flex: 0 0 var(--n-input-width, 5ch);
|
||||
width: var(--n-input-width, 5ch);
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.5rem;
|
||||
background-color: #3a3a3a;
|
||||
color: white;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.n-input:focus {
|
||||
@@ -1251,6 +1257,48 @@ body.preset-ui-run .edit-mode-only {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.preset-editor-field label {
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.preset-editor-field input[type="number"] {
|
||||
width: var(--n-input-width, 5ch);
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Pattern editor: numeric metadata row */
|
||||
#pattern-editor-modal input[type="number"] {
|
||||
width: var(--n-input-width, 5ch);
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Pattern editor: human-readable n labels (text), full width */
|
||||
#pattern-editor-modal .n-params-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
#pattern-editor-modal .n-param-group:has(.pattern-n-readable-input) {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
#pattern-editor-modal .pattern-n-readable-input {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@supports not selector(:has(*)) {
|
||||
#pattern-editor-modal #pattern-create-n-section .n-param-group {
|
||||
justify-content: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
/* Settings modal */
|
||||
|
||||
Reference in New Issue
Block a user