Ship v3.9.0 system reliability and UX toolkit

Add an in-app preflight diagnostics center with optional auto-fix, introduce backend retry handling for failed downloads, provide live debug log viewing in settings, and expand queue controls with retry-failed actions while keeping language switching instant and locale data organized.
This commit is contained in:
xRangerDE 2026-02-14 05:53:42 +01:00
parent 2579198e8b
commit 551690d09c
14 changed files with 329 additions and 12 deletions

View File

@ -1,12 +1,12 @@
{ {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "3.8.8", "version": "3.9.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "3.8.8", "version": "3.9.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"axios": "^1.6.0", "axios": "^1.6.0",

View File

@ -1,6 +1,6 @@
{ {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "3.8.8", "version": "3.9.0",
"description": "Twitch VOD Manager - Download Twitch VODs easily", "description": "Twitch VOD Manager - Download Twitch VODs easily",
"main": "dist/main.js", "main": "dist/main.js",
"author": "xRangerDE", "author": "xRangerDE",

View File

@ -130,6 +130,7 @@
<div class="queue-list" id="queueList"></div> <div class="queue-list" id="queueList"></div>
<div class="queue-actions"> <div class="queue-actions">
<button class="btn btn-start" id="btnStart" onclick="toggleDownload()">Start</button> <button class="btn btn-start" id="btnStart" onclick="toggleDownload()">Start</button>
<button class="btn btn-retry" id="btnRetryFailed" onclick="retryFailedDownloads()">Fehler neu</button>
<button class="btn btn-clear" id="btnClear" onclick="clearCompleted()">Leeren</button> <button class="btn btn-clear" id="btnClear" onclick="clearCompleted()">Leeren</button>
</div> </div>
</div> </div>
@ -343,9 +344,30 @@
<div class="settings-card"> <div class="settings-card">
<h3 id="updateTitle">Updates</h3> <h3 id="updateTitle">Updates</h3>
<p id="versionInfo" style="margin-bottom: 10px; color: var(--text-secondary);">Version: v3.8.8</p> <p id="versionInfo" style="margin-bottom: 10px; color: var(--text-secondary);">Version: v3.9.0</p>
<button class="btn-secondary" id="checkUpdateBtn" onclick="checkUpdate()">Nach Updates suchen</button> <button class="btn-secondary" id="checkUpdateBtn" onclick="checkUpdate()">Nach Updates suchen</button>
</div> </div>
<div class="settings-card">
<h3 id="preflightTitle">System-Check</h3>
<div class="form-row" style="margin-bottom: 10px;">
<button class="btn-secondary" id="btnPreflightRun" onclick="runPreflight(false)">Check ausfuhren</button>
<button class="btn-secondary" id="btnPreflightFix" onclick="runPreflight(true)">Auto-Fix Tools</button>
</div>
<pre id="preflightResult" class="log-panel">Noch kein Check ausgefuhrt.</pre>
</div>
<div class="settings-card">
<h3 id="debugLogTitle">Live Debug-Log</h3>
<div class="form-row" style="margin-bottom: 10px; align-items: center;">
<button class="btn-secondary" id="btnRefreshLog" onclick="refreshDebugLog()">Aktualisieren</button>
<label style="display:flex; align-items:center; gap:6px; font-size:13px; color: var(--text-secondary);">
<input type="checkbox" id="debugAutoRefresh" onchange="toggleDebugAutoRefresh(this.checked)">
<span id="autoRefreshText">Auto-Refresh</span>
</label>
</div>
<pre id="debugLogOutput" class="log-panel">Lade...</pre>
</div>
</div> </div>
</div> </div>
@ -354,7 +376,7 @@
<div class="status-dot" id="statusDot"></div> <div class="status-dot" id="statusDot"></div>
<span id="statusText">Nicht verbunden</span> <span id="statusText">Nicht verbunden</span>
</div> </div>
<span id="versionText">v3.8.8</span> <span id="versionText">v3.9.0</span>
</div> </div>
</main> </main>
</div> </div>

View File

@ -8,7 +8,7 @@ import { autoUpdater } from 'electron-updater';
// ========================================== // ==========================================
// CONFIG & CONSTANTS // CONFIG & CONSTANTS
// ========================================== // ==========================================
const APP_VERSION = '3.8.8'; const APP_VERSION = '3.9.0';
const UPDATE_CHECK_URL = 'http://24-music.de/version.json'; const UPDATE_CHECK_URL = 'http://24-music.de/version.json';
// Paths // Paths
@ -88,6 +88,22 @@ interface DownloadResult {
error?: string; error?: string;
} }
interface PreflightChecks {
internet: boolean;
streamlink: boolean;
ffmpeg: boolean;
ffprobe: boolean;
downloadPathWritable: boolean;
}
interface PreflightResult {
ok: boolean;
autoFixApplied: boolean;
checks: PreflightChecks;
messages: string[];
timestamp: string;
}
interface DownloadProgress { interface DownloadProgress {
id: string; id: string;
progress: number; progress: number;
@ -214,6 +230,94 @@ function getStreamlinkPath(): string {
return 'streamlink'; return 'streamlink';
} }
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isDownloadPathWritable(targetPath: string): boolean {
try {
fs.mkdirSync(targetPath, { recursive: true });
const probeFile = path.join(targetPath, `.write_test_${Date.now()}.tmp`);
fs.writeFileSync(probeFile, 'ok');
fs.unlinkSync(probeFile);
return true;
} catch {
return false;
}
}
async function hasInternetConnection(): Promise<boolean> {
try {
const res = await axios.get('https://id.twitch.tv/oauth2/validate', {
timeout: 5000,
validateStatus: () => true
});
return res.status > 0;
} catch {
return false;
}
}
async function runPreflight(autoFix = false): Promise<PreflightResult> {
appendDebugLog('preflight-start', { autoFix });
refreshBundledToolPaths();
const checks: PreflightChecks = {
internet: await hasInternetConnection(),
streamlink: false,
ffmpeg: false,
ffprobe: false,
downloadPathWritable: isDownloadPathWritable(config.download_path)
};
if (autoFix) {
await ensureStreamlinkInstalled();
await ensureFfmpegInstalled();
refreshBundledToolPaths();
}
const streamlinkCmd = getStreamlinkCommand();
checks.streamlink = canExecuteCommand(streamlinkCmd.command, [...streamlinkCmd.prefixArgs, '--version']);
const ffmpegPath = getFFmpegPath();
const ffprobePath = getFFprobePath();
checks.ffmpeg = canExecuteCommand(ffmpegPath, ['-version']);
checks.ffprobe = canExecuteCommand(ffprobePath, ['-version']);
const messages: string[] = [];
if (!checks.internet) messages.push('Keine Internetverbindung erkannt.');
if (!checks.streamlink) messages.push('Streamlink fehlt oder ist nicht startbar.');
if (!checks.ffmpeg) messages.push('FFmpeg fehlt oder ist nicht startbar.');
if (!checks.ffprobe) messages.push('FFprobe fehlt oder ist nicht startbar.');
if (!checks.downloadPathWritable) messages.push('Download-Ordner ist nicht beschreibbar.');
const result: PreflightResult = {
ok: messages.length === 0,
autoFixApplied: autoFix,
checks,
messages,
timestamp: new Date().toISOString()
};
appendDebugLog('preflight-finished', result);
return result;
}
function readDebugLog(lines = 200): string {
try {
if (!fs.existsSync(DEBUG_LOG_FILE)) {
return 'Debug-Log ist leer.';
}
const text = fs.readFileSync(DEBUG_LOG_FILE, 'utf-8');
const rows = text.split(/\r?\n/).filter(Boolean);
return rows.slice(-lines).join('\n') || 'Debug-Log ist leer.';
} catch (e) {
return `Debug-Log konnte nicht gelesen werden: ${String(e)}`;
}
}
function canExecute(cmd: string): boolean { function canExecute(cmd: string): boolean {
try { try {
execSync(cmd, { stdio: 'ignore', windowsHide: true }); execSync(cmd, { stdio: 'ignore', windowsHide: true });
@ -1352,13 +1456,47 @@ async function processQueue(): Promise<void> {
item.last_error = ''; item.last_error = '';
const result = await downloadVOD(item, (progress) => { let finalResult: DownloadResult = { success: false, error: 'Unbekannter Fehler beim Download' };
mainWindow?.webContents.send('download-progress', progress);
});
item.status = result.success ? 'completed' : 'error'; for (let attempt = 1; attempt <= MAX_RETRY_ATTEMPTS; attempt++) {
item.progress = result.success ? 100 : 0; appendDebugLog('queue-item-attempt', { itemId: item.id, attempt, max: MAX_RETRY_ATTEMPTS });
item.last_error = result.success ? '' : (result.error || 'Unbekannter Fehler beim Download');
const result = await downloadVOD(item, (progress) => {
mainWindow?.webContents.send('download-progress', progress);
});
if (result.success) {
finalResult = result;
break;
}
finalResult = result;
if (!isDownloading || currentDownloadCancelled) {
finalResult = { success: false, error: 'Download wurde abgebrochen.' };
break;
}
if (attempt < MAX_RETRY_ATTEMPTS) {
item.last_error = `Versuch ${attempt}/${MAX_RETRY_ATTEMPTS} fehlgeschlagen: ${result.error || 'Unbekannter Fehler'}`;
mainWindow?.webContents.send('download-progress', {
id: item.id,
progress: -1,
speed: '',
eta: '',
status: `Neuer Versuch in ${RETRY_DELAY_SECONDS}s...`,
currentPart: item.currentPart,
totalParts: item.totalParts
} as DownloadProgress);
saveQueue(downloadQueue);
mainWindow?.webContents.send('queue-updated', downloadQueue);
await sleep(RETRY_DELAY_SECONDS * 1000);
}
}
item.status = finalResult.success ? 'completed' : 'error';
item.progress = finalResult.success ? 100 : 0;
item.last_error = finalResult.success ? '' : (finalResult.error || 'Unbekannter Fehler beim Download');
appendDebugLog('queue-item-finished', { appendDebugLog('queue-item-finished', {
itemId: item.id, itemId: item.id,
status: item.status, status: item.status,
@ -1521,6 +1659,28 @@ ipcMain.handle('clear-completed', () => {
return downloadQueue; return downloadQueue;
}); });
ipcMain.handle('retry-failed-downloads', () => {
downloadQueue = downloadQueue.map((item) => {
if (item.status !== 'error') return item;
return {
...item,
status: 'pending',
progress: 0,
last_error: ''
};
});
saveQueue(downloadQueue);
mainWindow?.webContents.send('queue-updated', downloadQueue);
if (!isDownloading) {
void processQueue();
}
return downloadQueue;
});
ipcMain.handle('start-download', async () => { ipcMain.handle('start-download', async () => {
const hasPendingItems = downloadQueue.some(item => item.status !== 'completed'); const hasPendingItems = downloadQueue.some(item => item.status !== 'completed');
if (!hasPendingItems) { if (!hasPendingItems) {
@ -1636,6 +1796,14 @@ ipcMain.handle('download-clip', async (_, clipUrl: string) => {
}); });
}); });
ipcMain.handle('run-preflight', async (_, autoFix: boolean = false) => {
return await runPreflight(autoFix);
});
ipcMain.handle('get-debug-log', async (_, lines: number = 200) => {
return readDebugLog(lines);
});
ipcMain.handle('is-downloading', () => isDownloading); ipcMain.handle('is-downloading', () => isDownloading);
// Video Cutter IPC // Video Cutter IPC

View File

@ -61,6 +61,7 @@ contextBridge.exposeInMainWorld('api', {
addToQueue: (item: Omit<QueueItem, 'id' | 'status' | 'progress'>) => ipcRenderer.invoke('add-to-queue', item), addToQueue: (item: Omit<QueueItem, 'id' | 'status' | 'progress'>) => ipcRenderer.invoke('add-to-queue', item),
removeFromQueue: (id: string) => ipcRenderer.invoke('remove-from-queue', id), removeFromQueue: (id: string) => ipcRenderer.invoke('remove-from-queue', id),
clearCompleted: () => ipcRenderer.invoke('clear-completed'), clearCompleted: () => ipcRenderer.invoke('clear-completed'),
retryFailedDownloads: () => ipcRenderer.invoke('retry-failed-downloads'),
// Download // Download
startDownload: () => ipcRenderer.invoke('start-download'), startDownload: () => ipcRenderer.invoke('start-download'),
@ -91,6 +92,8 @@ contextBridge.exposeInMainWorld('api', {
downloadUpdate: () => ipcRenderer.invoke('download-update'), downloadUpdate: () => ipcRenderer.invoke('download-update'),
installUpdate: () => ipcRenderer.invoke('install-update'), installUpdate: () => ipcRenderer.invoke('install-update'),
openExternal: (url: string) => ipcRenderer.invoke('open-external', url), openExternal: (url: string) => ipcRenderer.invoke('open-external', url),
runPreflight: (autoFix: boolean) => ipcRenderer.invoke('run-preflight', autoFix),
getDebugLog: (lines: number) => ipcRenderer.invoke('get-debug-log', lines),
// Events // Events
onDownloadProgress: (callback: (progress: DownloadProgress) => void) => { onDownloadProgress: (callback: (progress: DownloadProgress) => void) => {

View File

@ -87,6 +87,22 @@ interface UpdateDownloadProgress {
total: number; total: number;
} }
interface PreflightChecks {
internet: boolean;
streamlink: boolean;
ffmpeg: boolean;
ffprobe: boolean;
downloadPathWritable: boolean;
}
interface PreflightResult {
ok: boolean;
autoFixApplied: boolean;
checks: PreflightChecks;
messages: string[];
timestamp: string;
}
interface ApiBridge { interface ApiBridge {
getConfig(): Promise<AppConfig>; getConfig(): Promise<AppConfig>;
saveConfig(config: Partial<AppConfig>): Promise<AppConfig>; saveConfig(config: Partial<AppConfig>): Promise<AppConfig>;
@ -97,6 +113,7 @@ interface ApiBridge {
addToQueue(item: Omit<QueueItem, 'id' | 'status' | 'progress'>): Promise<QueueItem[]>; addToQueue(item: Omit<QueueItem, 'id' | 'status' | 'progress'>): Promise<QueueItem[]>;
removeFromQueue(id: string): Promise<QueueItem[]>; removeFromQueue(id: string): Promise<QueueItem[]>;
clearCompleted(): Promise<QueueItem[]>; clearCompleted(): Promise<QueueItem[]>;
retryFailedDownloads(): Promise<QueueItem[]>;
startDownload(): Promise<boolean>; startDownload(): Promise<boolean>;
cancelDownload(): Promise<boolean>; cancelDownload(): Promise<boolean>;
isDownloading(): Promise<boolean>; isDownloading(): Promise<boolean>;
@ -115,6 +132,8 @@ interface ApiBridge {
downloadUpdate(): Promise<{ downloading?: boolean; error?: boolean }>; downloadUpdate(): Promise<{ downloading?: boolean; error?: boolean }>;
installUpdate(): Promise<void>; installUpdate(): Promise<void>;
openExternal(url: string): Promise<void>; openExternal(url: string): Promise<void>;
runPreflight(autoFix: boolean): Promise<PreflightResult>;
getDebugLog(lines: number): Promise<string>;
onDownloadProgress(callback: (progress: DownloadProgress) => void): void; onDownloadProgress(callback: (progress: DownloadProgress) => void): void;
onQueueUpdated(callback: (queue: QueueItem[]) => void): void; onQueueUpdated(callback: (queue: QueueItem[]) => void): void;
onDownloadStarted(callback: () => void): void; onDownloadStarted(callback: () => void): void;

View File

@ -7,6 +7,7 @@ const UI_TEXT_DE = {
navMerge: 'Videos zusammenfugen', navMerge: 'Videos zusammenfugen',
navSettings: 'Einstellungen', navSettings: 'Einstellungen',
queueTitle: 'Warteschlange', queueTitle: 'Warteschlange',
retryFailed: 'Fehler neu',
clearQueue: 'Leeren', clearQueue: 'Leeren',
refresh: 'Aktualisieren', refresh: 'Aktualisieren',
streamerPlaceholder: 'Streamer hinzufugen...', streamerPlaceholder: 'Streamer hinzufugen...',
@ -36,6 +37,13 @@ const UI_TEXT_DE = {
partMinutesLabel: 'Teil-Lange (Minuten)', partMinutesLabel: 'Teil-Lange (Minuten)',
updateTitle: 'Updates', updateTitle: 'Updates',
checkUpdates: 'Nach Updates suchen', checkUpdates: 'Nach Updates suchen',
preflightTitle: 'System-Check',
preflightRun: 'Check ausfuhren',
preflightFix: 'Auto-Fix Tools',
preflightEmpty: 'Noch kein Check ausgefuhrt.',
debugLogTitle: 'Live Debug-Log',
refreshLog: 'Aktualisieren',
autoRefresh: 'Auto-Refresh',
notConnected: 'Nicht verbunden' notConnected: 'Nicht verbunden'
}, },
status: { status: {

View File

@ -7,6 +7,7 @@ const UI_TEXT_EN = {
navMerge: 'Merge Videos', navMerge: 'Merge Videos',
navSettings: 'Settings', navSettings: 'Settings',
queueTitle: 'Queue', queueTitle: 'Queue',
retryFailed: 'Retry failed',
clearQueue: 'Clear', clearQueue: 'Clear',
refresh: 'Refresh', refresh: 'Refresh',
streamerPlaceholder: 'Add streamer...', streamerPlaceholder: 'Add streamer...',
@ -36,6 +37,13 @@ const UI_TEXT_EN = {
partMinutesLabel: 'Part Length (Minutes)', partMinutesLabel: 'Part Length (Minutes)',
updateTitle: 'Updates', updateTitle: 'Updates',
checkUpdates: 'Check for updates', checkUpdates: 'Check for updates',
preflightTitle: 'System Check',
preflightRun: 'Run check',
preflightFix: 'Auto-fix tools',
preflightEmpty: 'No checks run yet.',
debugLogTitle: 'Live Debug Log',
refreshLog: 'Refresh',
autoRefresh: 'Auto refresh',
notConnected: 'Not connected' notConnected: 'Not connected'
}, },
status: { status: {

View File

@ -19,6 +19,11 @@ async function clearCompleted(): Promise<void> {
renderQueue(); renderQueue();
} }
async function retryFailedDownloads(): Promise<void> {
queue = await window.api.retryFailedDownloads();
renderQueue();
}
function getQueueStatusLabel(item: QueueItem): string { function getQueueStatusLabel(item: QueueItem): string {
if (item.status === 'completed') return UI_TEXT.queue.statusDone; if (item.status === 'completed') return UI_TEXT.queue.statusDone;
if (item.status === 'error') return UI_TEXT.queue.statusFailed; if (item.status === 'error') return UI_TEXT.queue.statusFailed;

View File

@ -40,6 +40,56 @@ function changeLanguage(lang: string): void {
} }
} }
function renderPreflightResult(result: PreflightResult): void {
const entries = [
['Internet', result.checks.internet],
['Streamlink', result.checks.streamlink],
['FFmpeg', result.checks.ffmpeg],
['FFprobe', result.checks.ffprobe],
['Download-Pfad', result.checks.downloadPathWritable]
];
const lines = entries.map(([name, ok]) => `${ok ? 'OK' : 'FAIL'} ${name}`).join('\n');
const extra = result.messages.length ? `\n\n${result.messages.join('\n')}` : '\n\nAlles bereit.';
byId('preflightResult').textContent = `${lines}${extra}`;
}
async function runPreflight(autoFix = false): Promise<void> {
const btn = byId<HTMLButtonElement>(autoFix ? 'btnPreflightFix' : 'btnPreflightRun');
const old = btn.textContent || '';
btn.disabled = true;
btn.textContent = autoFix ? 'Fixe...' : 'Prufe...';
try {
const result = await window.api.runPreflight(autoFix);
renderPreflightResult(result);
} finally {
btn.disabled = false;
btn.textContent = old;
}
}
async function refreshDebugLog(): Promise<void> {
const text = await window.api.getDebugLog(250);
const panel = byId('debugLogOutput');
panel.textContent = text;
panel.scrollTop = panel.scrollHeight;
}
function toggleDebugAutoRefresh(enabled: boolean): void {
if (debugLogAutoRefreshTimer) {
clearInterval(debugLogAutoRefreshTimer);
debugLogAutoRefreshTimer = null;
}
if (enabled) {
debugLogAutoRefreshTimer = window.setInterval(() => {
void refreshDebugLog();
}, 1500);
}
}
async function saveSettings(): Promise<void> { async function saveSettings(): Promise<void> {
const clientId = byId<HTMLInputElement>('clientId').value.trim(); const clientId = byId<HTMLInputElement>('clientId').value.trim();
const clientSecret = byId<HTMLInputElement>('clientSecret').value.trim(); const clientSecret = byId<HTMLInputElement>('clientSecret').value.trim();

View File

@ -38,3 +38,4 @@ let clipDialogData: ClipDialogData | null = null;
let clipTotalSeconds = 0; let clipTotalSeconds = 0;
let updateReady = false; let updateReady = false;
let debugLogAutoRefreshTimer: number | null = null;

View File

@ -46,6 +46,7 @@ function applyLanguageToStaticUI(): void {
setText('navMergeText', UI_TEXT.static.navMerge); setText('navMergeText', UI_TEXT.static.navMerge);
setText('navSettingsText', UI_TEXT.static.navSettings); setText('navSettingsText', UI_TEXT.static.navSettings);
setText('queueTitleText', UI_TEXT.static.queueTitle); setText('queueTitleText', UI_TEXT.static.queueTitle);
setText('btnRetryFailed', UI_TEXT.static.retryFailed);
setText('btnClear', UI_TEXT.static.clearQueue); setText('btnClear', UI_TEXT.static.clearQueue);
setText('refreshText', UI_TEXT.static.refresh); setText('refreshText', UI_TEXT.static.refresh);
setText('clipsHeading', UI_TEXT.static.clipsHeading); setText('clipsHeading', UI_TEXT.static.clipsHeading);
@ -74,6 +75,13 @@ function applyLanguageToStaticUI(): void {
setText('partMinutesLabel', UI_TEXT.static.partMinutesLabel); setText('partMinutesLabel', UI_TEXT.static.partMinutesLabel);
setText('updateTitle', UI_TEXT.static.updateTitle); setText('updateTitle', UI_TEXT.static.updateTitle);
setText('checkUpdateBtn', UI_TEXT.static.checkUpdates); setText('checkUpdateBtn', UI_TEXT.static.checkUpdates);
setText('preflightTitle', UI_TEXT.static.preflightTitle);
setText('btnPreflightRun', UI_TEXT.static.preflightRun);
setText('btnPreflightFix', UI_TEXT.static.preflightFix);
setText('preflightResult', UI_TEXT.static.preflightEmpty);
setText('debugLogTitle', UI_TEXT.static.debugLogTitle);
setText('btnRefreshLog', UI_TEXT.static.refreshLog);
setText('autoRefreshText', UI_TEXT.static.autoRefresh);
setText('updateText', UI_TEXT.updates.bannerDefault); setText('updateText', UI_TEXT.updates.bannerDefault);
setText('updateButton', UI_TEXT.updates.downloadNow); setText('updateButton', UI_TEXT.updates.downloadNow);
setPlaceholder('newStreamer', UI_TEXT.static.streamerPlaceholder); setPlaceholder('newStreamer', UI_TEXT.static.streamerPlaceholder);

View File

@ -80,6 +80,9 @@ async function init(): Promise<void> {
void checkUpdateSilent(); void checkUpdateSilent();
}, 3000); }, 3000);
void runPreflight(false);
void refreshDebugLog();
setInterval(() => { setInterval(() => {
void syncQueueAndDownloadState(); void syncQueueAndDownloadState();
}, 2000); }, 2000);

View File

@ -318,6 +318,15 @@ body {
transition: all 0.2s; transition: all 0.2s;
} }
.btn-retry {
background: #2a3344;
color: #d9e4f7;
}
.btn-retry:hover {
background: #33405a;
}
.btn-start { .btn-start {
background: var(--success); background: var(--success);
color: white; color: white;
@ -574,6 +583,19 @@ body {
gap: 10px; gap: 10px;
} }
.log-panel {
background: #11151c;
border: 1px solid rgba(255,255,255,0.12);
border-radius: 6px;
padding: 10px;
max-height: 220px;
overflow: auto;
white-space: pre-wrap;
color: #b8c7df;
font-size: 12px;
line-height: 1.35;
}
.form-row input { .form-row input {
flex: 1; flex: 1;
} }