Add resumable uploads, proxy discovery, PWA install and 2 GiB personal vaults

This commit is contained in:
2026-10-12 15:54:26 +09:00
parent 439c2ae61b
commit 464715aa69
43 changed files with 1647 additions and 137 deletions

View File

@@ -6,6 +6,10 @@
<meta name="theme-color" content="#000000" />
<title>hop. — 기기 사이를 가볍게.</title>
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="hop." />
</head>
<body>
<div id="app"></div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

View File

@@ -0,0 +1,17 @@
{
"id": "/",
"name": "hop. — 기기 사이를 가볍게.",
"short_name": "hop.",
"description": "가입 없이 주변 기기에 파일과 링크를 보내세요.",
"lang": "ko",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#000000",
"theme_color": "#000000",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icon-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}

18
apps/web/public/sw.js Normal file
View File

@@ -0,0 +1,18 @@
// Network-only: no account records, files, tokens or API responses enter offline caches.
self.addEventListener('install', (event) => event.waitUntil(self.skipWaiting()));
self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()));
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET' || event.request.mode !== 'navigate') return;
event.respondWith(
fetch(event.request).catch(
() =>
new Response(
'<!doctype html><html lang="ko"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>hop.</title><body style="background:#000;color:#f5f5f5;font:16px system-ui;text-align:center;padding:25vh 24px"><h1>hop.</h1><p>인터넷 연결이 필요해요.</p><p>연결한 뒤 다시 열어주세요.</p><a href="/" style="color:inherit">다시 시도</a></body></html>',
{
status: 503,
headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' },
},
),
),
);
});

View File

@@ -84,11 +84,13 @@ async function act(fn) {
}
}
function size(value) {
return value >= 1048576
? (value / 1048576).toFixed(1) + ' MiB'
: value >= 1024
? (value / 1024).toFixed(1) + ' KiB'
: value + ' B';
return value >= 1073741824
? (value / 1073741824).toFixed(1).replace(/\.0$/, '') + ' GiB'
: value >= 1048576
? (value / 1048576).toFixed(1) + ' MiB'
: value >= 1024
? (value / 1024).toFixed(1) + ' KiB'
: value + ' B';
}
function date(value) {
return new Intl.DateTimeFormat('ko-KR', {
@@ -229,39 +231,65 @@ async function authSubmit() {
}
async function save(values) {
if (busy.value || saveTransfer.value) return;
if (busy.value) return;
if (
saveTransfer.value &&
!Object.entries(uploadProgress.value).some(
([key, value]) => key.startsWith(saveTransfer.value.id + ':') && value.status === 'failed',
)
)
return;
busy.value = true;
let id;
let id = saveTransfer.value?.id;
try {
const result = await api(
'/vault',
'POST',
{
kind: values.files ? 'file' : 'url',
...(values.files
? {
files: values.files.map((file) => ({
name: file.name,
size: file.size,
type: file.type || 'application/octet-stream',
})),
}
: { urls: values.urls }),
note: note.value,
},
crypto.randomUUID(),
);
const result = id
? { requestId: id }
: await api(
'/vault',
'POST',
{
kind: values.files ? 'file' : 'url',
...(values.files
? {
files: values.files.map((file) => ({
name: file.name,
size: file.size,
type: file.type || 'application/octet-stream',
})),
}
: { urls: values.urls }),
note: note.value,
},
crypto.randomUUID(),
);
id = result.requestId;
if (values.files) {
saveTransfer.value = { id, kind: 'file', status: 'accepted' };
if (
saveTransfer.value?.files &&
(values.files.length !== saveTransfer.value.files.length ||
values.files.some(
(file, index) =>
file.name !== saveTransfer.value.files[index].name ||
file.size !== saveTransfer.value.files[index].size,
))
)
throw Error('처음 선택한 파일과 이름·크기가 같은 파일을 선택해 주세요.');
saveTransfer.value = {
id,
kind: 'file',
status: 'accepted',
files: values.files.map((file) => ({ name: file.name, size: file.size })),
};
busy.value = false;
for (const [index, file] of values.files.entries()) {
if (saveTransfer.value?.id !== id) return;
if (uploadProgress.value[`${id}:${index}`]?.status === 'stored') continue;
await uploadFile(id, index, file, account.value.id);
}
if (saveTransfer.value?.id !== id) return;
}
await refreshLists();
saveTransfer.value = null;
compose.value = false;
note.value = '';
message('임시 보관함에 저장했습니다.');
@@ -269,7 +297,6 @@ async function save(values) {
if (!id || saveTransfer.value?.id === id || !values.files) showError(error);
} finally {
busy.value = false;
if (saveTransfer.value?.id === id) saveTransfer.value = null;
}
}
async function cancelSave() {
@@ -638,7 +665,7 @@ async function resendVerification() {
</div>
<button v-if="nextCursor" class="button load-more" @click="loadMoreAccount">더 보기</button>
<p class="scope-note">
저장 용량 {{ size(storage.usedBytes) }} / {{ size(storage.limitBytes) }}
내 보관함 {{ size(storage.usedBytes) }} / {{ size(storage.limitBytes) }} · 최대 7일
</p>
</section>
<div v-if="compose" class="modal-backdrop" @click.self="!saveTransfer && (compose = false)">

View File

@@ -33,12 +33,14 @@ import {
import QRCode from 'qrcode';
import AccountPanel from './AccountPanel.vue';
import SendComposer from './SendComposer.vue';
import InstallButton from './InstallButton.vue';
import { createDeliveryReceiver } from './deliveryReceiver';
import { initialTheme, applyTheme as setTheme } from './theme';
import {
device,
account,
connected,
discoveryEnabled,
peers,
requests,
items,
@@ -54,6 +56,7 @@ import {
markRead,
uploadProgress,
sendFiles,
retryFiles,
cancelTransfer,
download,
} from './client';
@@ -358,12 +361,16 @@ onMounted(async () => {
}
});
const onFocus = () => void refresh().catch(showError);
const onReceiptSkipped = () =>
notify('보관함 용량이 부족해 받은 항목에 저장하지 않았어요. 파일 다운로드는 진행됩니다.');
window.addEventListener('hop-receipt-skipped', onReceiptSkipped);
const onVisibility = () => {
if (document.visibilityState === 'visible') onFocus();
};
window.addEventListener('focus', onFocus);
document.addEventListener('visibilitychange', onVisibility);
onUnmounted(() => {
window.removeEventListener('hop-receipt-skipped', onReceiptSkipped);
window.removeEventListener('focus', onFocus);
document.removeEventListener('visibilitychange', onVisibility);
clearInterval(timer);
@@ -421,7 +428,9 @@ async function submitFiles(files) {
if (!selected.value) throw Error('받을 기기를 선택해 주세요.');
forwardedItem.value = null;
itemToSend.value = null;
await sendFiles(selected.value.id, files);
if (currentTransfer.value?.kind === 'file' && currentTransfer.value.status === 'accepted')
await retryFiles(currentTransfer.value.id, files);
else await sendFiles(selected.value.id, files);
});
}
async function createSpace() {
@@ -509,6 +518,7 @@ async function spaceAction(space, action, memberId) {
<span class="header-tagline">기기 사이를 가볍게.</span>
</div>
<div class="header-actions">
<InstallButton />
<button
class="icon-button theme-toggle"
:aria-label="theme === 'light' ? '다크 테마로 전환' : '라이트 테마로 전환'"
@@ -628,7 +638,27 @@ async function spaceAction(space, action, memberId) {
</Teleport>
<template v-if="tab === 'devices'">
<section class="devices-stage">
<div class="stage-heading"><h1>어디로 보낼까요?</h1></div>
<div class="stage-heading">
<h1>어디로 보낼까요?</h1>
<p class="discovery-status" role="status">
<LoaderCircle
v-if="connected && device?.discoverable && (spaces.length || discoveryEnabled)"
class="spin"
:size="13"
/>
{{
!connected
? '연결을 확인하고 있어요.'
: spaces.length
? '방 안의 기기를 확인하고 있어요.'
: !device?.discoverable
? '발견 허용이 꺼져 있어요.'
: discoveryEnabled
? '주변 기기를 계속 찾고 있어요.'
: '주변 검색 설정이 필요해요. QR로 연결할 수 있어요.'
}}
</p>
</div>
<section class="nearby-section">
<div class="section-heading">
<div>
@@ -878,6 +908,9 @@ async function spaceAction(space, action, memberId) {
네트워크가 달라도 연결할 수 있어요.<br />연결한 뒤에도 전송마다 수락이 필요합니다.
</p>
<div v-if="invite && invite.space.expiresAt > now" class="invite-display">
<p class="discovery-status">
<LoaderCircle class="spin" :size="14" />기기 참여를 기다리고 있어요.
</p>
<img :src="qr" alt="임시 연결 공간 초대 QR 코드" width="208" height="208" />
<p v-if="localOnlyQr" class="local-qr-note">
현재 QR은 이 PC의 개발 주소입니다. 휴대폰 접속은 아직 설정하지 않았어요. 같은 PC의

View File

@@ -0,0 +1,94 @@
<script setup>
import { ref, shallowRef, computed, onMounted, onUnmounted, nextTick } from 'vue';
import { Download, X } from '@lucide/vue';
const standalone = window.matchMedia('(display-mode: standalone)');
const installed = ref(standalone.matches || navigator.standalone === true);
const prompt = shallowRef(null),
help = ref(false),
button = ref(null),
closeButton = ref(null);
const ios =
/iPhone|iPad|iPod/.test(navigator.userAgent) ||
(/Macintosh/.test(navigator.userAgent) && navigator.maxTouchPoints > 1);
const mobile = ios || /Android/.test(navigator.userAgent);
const visible = computed(() => !installed.value && (mobile || prompt.value));
function available(event) {
event.preventDefault();
prompt.value = event;
}
function didInstall() {
installed.value = true;
prompt.value = null;
help.value = false;
}
function displayChanged() {
installed.value = standalone.matches || navigator.standalone === true;
}
async function install() {
if (prompt.value) {
const event = prompt.value;
prompt.value = null;
try {
await event.prompt();
if ((await event.userChoice).outcome === 'accepted') didInstall();
} catch {
help.value = true;
}
} else help.value = true;
if (help.value) {
await nextTick();
closeButton.value?.focus();
}
}
function close() {
help.value = false;
button.value?.focus();
}
onMounted(() => {
window.addEventListener('beforeinstallprompt', available);
window.addEventListener('appinstalled', didInstall);
standalone.addEventListener('change', displayChanged);
});
onUnmounted(() => {
window.removeEventListener('beforeinstallprompt', available);
window.removeEventListener('appinstalled', didInstall);
standalone.removeEventListener('change', displayChanged);
});
</script>
<template>
<button
v-if="visible"
ref="button"
class="pwa-install-button"
aria-label="hop. 앱 설치"
@click="install"
>
<Download :size="16" /><span>설치</span>
</button>
<Teleport to="body">
<div v-if="help" class="modal-backdrop" @click.self="close" @keydown.esc.stop="close">
<section class="modal install-help" role="dialog" aria-modal="true" aria-label="앱 설치 안내">
<header>
<h2>홈 화면에 hop.</h2>
<button
ref="closeButton"
class="icon-button"
aria-label="설치 안내 닫기"
@click="close"
@keydown.tab.prevent
>
<X :size="20" />
</button>
</header>
<p v-if="ios">
Safari에서 공유 버튼을 누른 뒤<br /><strong>홈 화면에 추가</strong>를 선택하세요.
</p>
<p v-else>
브라우저 메뉴에서<br /><strong>앱 설치</strong> 또는 <strong>홈 화면에 추가</strong>를
선택하세요.
</p>
<small>설치 후 홈 화면에서 바로 열 수 있어요.</small>
</section>
</div>
</Teleport>
</template>

View File

@@ -45,7 +45,16 @@ const validUrl = computed(
() =>
parsed.value.urls.length > 0 && parsed.value.urls.length <= 20 && !parsed.value.invalid.length,
);
const mutable = computed(() => !props.busy && !props.disabled && !props.transfer);
const retryable = computed(
() =>
props.transfer?.kind === 'file' &&
props.transfer.status === 'accepted' &&
!props.transferSource &&
(!props.progress?.length || props.progress.some((item) => item.status === 'failed')),
);
const mutable = computed(
() => !props.busy && !props.disabled && (!props.transfer || retryable.value),
);
const ready = computed(() =>
source.value === 'url'
? validUrl.value && !reading.value
@@ -56,6 +65,7 @@ const ready = computed(() =>
async function loadClipboard(force = false) {
if (
!props.targetKey ||
props.transfer ||
!props.readClipboardOnSelection ||
props.contentMode === 'file' ||
!mutable.value ||
@@ -129,6 +139,9 @@ watch(
if (transfer && props.transferSource) {
source.value = 'vault';
selectedVault.value = props.transferSource;
} else if (transfer?.kind === 'file') {
source.value = 'file';
urlInput.value = '';
}
},
);
@@ -233,7 +246,7 @@ function pasted(event) {
choose(event.clipboardData.files);
return;
}
if (props.contentMode === 'file') return;
if (props.contentMode === 'file' || props.transfer) return;
const result = parseUrls(event.clipboardData?.getData('text/plain') ?? '');
if (result.urls.length && !result.invalid.length) {
event.preventDefault();
@@ -245,7 +258,7 @@ function pasted(event) {
}
}
function submit() {
if (props.transfer) {
if (props.transfer && !retryable.value) {
emit('cancel');
return;
}
@@ -387,7 +400,13 @@ function size(value) {
>
</template>
<p v-if="transfer" class="composer-transfer-state" role="status">
{{ transfer.status === 'pending' ? '상대 기기의 수락을 기다립니다.' : '전송 중입니다.' }}
{{
retryable
? '같은 파일로 이어 보낼 수 있어요.'
: transfer.status === 'pending'
? '상대 기기의 수락을 기다립니다.'
: '전송 중입니다.'
}}
</p>
<div v-for="(upload, index) in progress" :key="index" class="composer-progress">
<span
@@ -399,13 +418,16 @@ function size(value) {
<button
class="button primary content-submit"
:class="{ 'link-send': source === 'url', 'send-button': source === 'file' }"
:disabled="busy || disabled || (!transfer && !ready)"
:disabled="busy || disabled || ((!transfer || retryable) && !ready)"
@click="submit"
>
{{ transfer ? '전송 취소' : saveMode ? '저장하기' : '전송하기'
}}<X v-if="transfer" :size="16" /><ArrowUpRight v-else :size="16" />
{{ retryable ? '다시 전송' : transfer ? '전송 취소' : saveMode ? '저장하기' : '전송하기'
}}<X v-if="transfer && !retryable" :size="16" /><ArrowUpRight v-else :size="16" />
</button>
<div v-if="!contentMode" class="content-switches">
<div v-if="retryable" class="content-switches">
<button class="text-button room-leave" @click="emit('cancel')">전송 취소</button>
</div>
<div v-else-if="!contentMode && !transfer" class="content-switches">
<button v-if="source !== 'file'" class="text-button" :disabled="!mutable" @click="pickFiles">
<FileIcon :size="14" />대신 파일 선택하기</button
><button v-if="source !== 'url'" class="text-button" :disabled="!mutable" @click="toLink">

View File

@@ -28,9 +28,10 @@ let socket;
let reauthorizing;
const drafts = new Map();
const completing = new Set();
export async function api(path, method = 'GET', body, key) {
export async function api(path, method = 'GET', body, key, signal) {
const response = await fetch(`/api${path}`, {
method,
signal,
credentials: 'same-origin',
headers: {
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
@@ -40,7 +41,11 @@ export async function api(path, method = 'GET', body, key) {
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.message ?? '요청을 처리하지 못했습니다.');
if (!response.ok)
throw Object.assign(new Error(payload.message ?? '요청을 처리하지 못했습니다.'), {
status: response.status,
code: payload.code,
});
return payload;
}
export async function bootstrap() {
@@ -68,6 +73,10 @@ function connect() {
connected.value = false;
});
socket.on('inbox:updated', () => window.dispatchEvent(new Event('hop-account-updated')));
socket.on('receipt:skipped', (event) => {
if (event.accountId === account.value?.id)
window.dispatchEvent(new Event('hop-receipt-skipped'));
});
socket.on('account:revoked', () => {
clearAccountUploads(account.value?.id);
account.value = null;
@@ -232,73 +241,156 @@ export async function sendFiles(targetDeviceId, files) {
await refresh();
return requestId;
}
export function uploadFile(id, index, file, accountId) {
const progressKey = `${id}:${index}`;
const chunkBytes = 8 * 1024 * 1024;
async function fileFingerprint(file, signal) {
const digests = [];
for (let offset = 0; offset < file.size; offset += chunkBytes) {
signal.throwIfAborted();
const bytes = await file.slice(offset, offset + chunkBytes).arrayBuffer();
digests.push(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)));
}
signal.throwIfAborted();
const combined = new Uint8Array(digests.length * 32);
digests.forEach((digest, index) => combined.set(digest, index * 32));
return [...new Uint8Array(await crypto.subtle.digest('SHA-256', combined))]
.map((value) => value.toString(16).padStart(2, '0'))
.join('');
}
export async function uploadFile(id, index, file, accountId) {
const progressKey = `${id}:${index}`,
controller = new AbortController();
uploadProgress.value[progressKey] = {
name: file.name,
progress: 0,
status: 'uploading',
accountId,
};
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
uploadRequests.set(progressKey, xhr);
xhr.open('POST', `/api/uploads?requestId=${id}&fileIndex=${index}`);
xhr.setRequestHeader('X-CSRF-Token', csrfToken.value);
xhr.setRequestHeader('Idempotency-Key', crypto.randomUUID());
xhr.upload.onprogress = (e) => {
if (e.lengthComputable)
uploadProgress.value[progressKey].progress = Math.min(
100,
Math.round((e.loaded / e.total) * 100),
);
};
const finish = () => uploadRequests.delete(progressKey);
xhr.onload = () => {
finish();
if (accountId && account.value?.id !== accountId) {
delete uploadProgress.value[progressKey];
reject(Error('계정 세션이 종료되었습니다.'));
return;
}
if (xhr.status >= 200 && xhr.status < 300) {
uploadProgress.value[progressKey] = { name: file.name, progress: 100, status: 'stored' };
resolve();
} else {
let message = '업로드하지 못했습니다.';
uploadRequests.set(progressKey, controller);
const check = () => {
controller.signal.throwIfAborted();
if (cancelledTransfers.has(id)) throw Error('전송이 취소되었습니다.');
if (accountId && account.value?.id !== accountId) throw Error('계정 세션이 종료되었습니다.');
};
const sendChunk = (uploadId, offset) =>
new Promise((resolve, reject) => {
check();
const xhr = new XMLHttpRequest();
const abort = () => xhr.abort();
const finish = () => controller.signal.removeEventListener('abort', abort);
controller.signal.addEventListener('abort', abort, { once: true });
xhr.open('PUT', `/api/uploads/resumable/${uploadId}`);
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
xhr.setRequestHeader('X-CSRF-Token', csrfToken.value);
xhr.setRequestHeader('Upload-Offset', String(offset));
xhr.upload.onprogress = (event) => {
if (uploadProgress.value[progressKey])
uploadProgress.value[progressKey].progress = Math.min(
99,
Math.round(((offset + event.loaded) / Math.max(1, file.size)) * 100),
);
};
xhr.onload = () => {
finish();
let payload;
try {
message = JSON.parse(xhr.responseText).message;
} catch {}
uploadProgress.value[progressKey] = {
name: file.name,
progress: 0,
status: 'failed',
accountId,
message,
};
reject(Error(message));
payload = JSON.parse(xhr.responseText);
} catch {
payload = {};
}
if (xhr.status >= 200 && xhr.status < 300) resolve(payload);
else
reject(
Object.assign(Error(payload.message || '파일 조각을 보내지 못했습니다.'), {
status: xhr.status,
code: payload.code,
}),
);
};
xhr.onerror = xhr.onabort = () => {
finish();
reject(Object.assign(Error('업로드 연결이 중단되었습니다.'), { status: 0 }));
};
xhr.send(file.slice(offset, offset + chunkBytes));
});
const retryable = (error) =>
error.status === 0 ||
error.status >= 500 ||
[408, 429].includes(error.status) ||
['UPLOAD_OFFSET', 'UPLOAD_ACTIVE'].includes(error.code);
try {
const fingerprint = await fileFingerprint(file, controller.signal);
check();
let state = await api(
'/uploads/resumable',
'POST',
{ requestId: id, fileIndex: index, fingerprint },
undefined,
controller.signal,
);
let failures = 0;
while (state.offset < file.size) {
check();
try {
state = await sendChunk(state.uploadId, state.offset);
failures = 0;
} catch (error) {
check();
if (!retryable(error) || ++failures > 3) throw error;
await new Promise((resolve) => setTimeout(resolve, failures * 400));
check();
state = await api(
`/uploads/resumable/${state.uploadId}`,
'GET',
undefined,
undefined,
controller.signal,
);
}
}
check();
if (state.status !== 'stored') {
try {
await api(
`/uploads/resumable/${state.uploadId}/complete`,
'POST',
{},
undefined,
controller.signal,
);
} catch (error) {
check();
const current = await api(
`/uploads/resumable/${state.uploadId}`,
'GET',
undefined,
undefined,
controller.signal,
);
if (current.status !== 'stored') throw error;
}
}
check();
uploadProgress.value[progressKey] = {
name: file.name,
progress: 100,
status: 'stored',
accountId,
};
xhr.onerror = xhr.onabort = () => {
finish();
if (accountId && account.value?.id !== accountId) {
delete uploadProgress.value[progressKey];
reject(Error('계정 세션이 종료되었습니다.'));
return;
}
} catch (error) {
if (cancelledTransfers.has(id) || (accountId && account.value?.id !== accountId))
delete uploadProgress.value[progressKey];
else
uploadProgress.value[progressKey] = {
name: file.name,
progress: 0,
status: 'failed',
accountId,
message: '업로드가 중단되었습니다.',
message: error.message || '업로드가 중단되었습니다.',
};
reject(Error('업로드가 중단되었습니다.'));
};
const form = new FormData();
form.append('file', file);
xhr.send(form);
});
throw error;
} finally {
uploadRequests.delete(progressKey);
}
}
async function deliverFiles(id) {
if (fileSending.has(id)) return;
@@ -317,6 +409,12 @@ async function deliverFiles(id) {
}
if (requests.value.find((r) => r.id === id)?.status !== 'accepted') break;
}
if (
Object.entries(uploadProgress.value).some(
([key, value]) => key.startsWith(`${id}:`) && value.status === 'failed',
)
)
return;
await refresh();
if (requests.value.find((r) => r.id === id)?.transferStatus === 'stored') fileDrafts.delete(id);
} finally {

View File

@@ -4,3 +4,8 @@ import App from './App.vue';
import './style.css';
applyTheme(initialTheme());
createApp(App).mount('#app');
if (import.meta.env.PROD && window.isSecureContext && 'serviceWorker' in navigator) {
window.addEventListener('load', () => {
void navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' }).catch(() => {});
});
}

View File

@@ -2932,6 +2932,46 @@ a.button {
align-items: flex-end;
gap: 18px;
}
.pwa-install-button {
display: flex;
align-items: center;
gap: 5px;
border: 0;
background: none;
color: var(--muted);
font-size: 11px;
padding: 8px 0;
min-height: 36px;
white-space: nowrap;
}
.install-help p {
margin: 28px 0 18px;
line-height: 1.9;
font-size: 14px;
}
.install-help small {
color: var(--muted);
font-size: 12px;
}
.discovery-status {
display: flex;
justify-content: center;
align-items: center;
gap: 7px;
color: var(--muted);
font-size: 11px;
margin-top: 12px;
}
@media (max-width: 360px) {
.header-tagline {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.discovery-status .spin {
animation: none;
}
}
.header-tagline {
color: var(--muted);
font-size: 13px;