ba199f24bd
- Defer quiet-hours dispatches into new deferred_dispatch table; drain job + periodic catch-up scan re-fire at window end with coalescing on (link, event_type, collection_id). - Add ON DELETE SET NULL migration on event_log_id and partial unique index on (link_id, collection_id, event_type) WHERE status='pending'. - Add release-check provider abstraction (Gitea/GitHub) with SSRF-safe URL validation, settings UI cassette, and scheduled polling. - Replace importlib-only version lookup with version.py helper that prefers the higher of installed metadata vs source pyproject so stale editable dev installs stop misreporting. - Aurora frontend polish: MetaStrip component, ReleaseCassette, EventDetailModal expansion, and i18n additions.
699 lines
21 KiB
Svelte
699 lines
21 KiB
Svelte
<script lang="ts">
|
|
import { t } from '$lib/i18n';
|
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
|
import Hint from '$lib/components/Hint.svelte';
|
|
import { api } from '$lib/api';
|
|
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
|
import { releaseStatusCache } from '$lib/stores/caches.svelte';
|
|
import type { ReleaseProviderKind, ReleaseStatus, ReleaseTestResult } from '$lib/types';
|
|
|
|
interface Props {
|
|
// All five fields are persisted as strings via the /settings PUT —
|
|
// the parent owns the boundary type. Bool flags use "0" / "1".
|
|
providerKind: string;
|
|
providerUrl: string;
|
|
providerRepo: string;
|
|
includePrereleases: string;
|
|
checkIntervalHours: string;
|
|
}
|
|
|
|
let {
|
|
providerKind = $bindable(),
|
|
providerUrl = $bindable(),
|
|
providerRepo = $bindable(),
|
|
includePrereleases = $bindable(),
|
|
checkIntervalHours = $bindable(),
|
|
}: Props = $props();
|
|
|
|
let checking = $state(false);
|
|
let testing = $state(false);
|
|
let testResult = $state<ReleaseTestResult | null>(null);
|
|
|
|
const status = $derived(releaseStatusCache.value);
|
|
const prereleaseChecked = $derived(includePrereleases === '1');
|
|
const isDisabled = $derived(providerKind === 'disabled');
|
|
|
|
// Stale Test-result on input change is misleading — wipe whenever any of
|
|
// the probed parameters change so the strip reflects "current" state.
|
|
$effect(() => {
|
|
// Touch each parameter to register dependency.
|
|
void providerKind; void providerUrl; void providerRepo; void prereleaseChecked;
|
|
testResult = null;
|
|
});
|
|
|
|
type Tone = 'mint' | 'citrus' | 'coral' | 'sky';
|
|
|
|
const stateTone: Tone = $derived.by(() => {
|
|
if (!status) return 'sky';
|
|
if (status.error && status.error !== 'disabled' && status.error !== 'provider_changed') return 'coral';
|
|
if (status.update_available) return 'citrus';
|
|
if (status.provider === 'disabled') return 'sky';
|
|
return 'mint';
|
|
});
|
|
|
|
const stateLabel = $derived.by(() => {
|
|
if (!status) return t('settings.release.statusUnknown');
|
|
if (status.provider === 'disabled') return t('settings.release.statusDisabled');
|
|
if (status.error && status.error !== 'provider_changed') return t('settings.release.statusError');
|
|
if (status.update_available) return t('settings.release.statusUpdate');
|
|
if (status.latest) return t('settings.release.statusUpToDate');
|
|
return t('settings.release.statusUnknown');
|
|
});
|
|
|
|
// Map backend error taxonomy → localized text. Falls back to the raw code
|
|
// only when the key is missing (so a new server code surfaces something).
|
|
function localizedError(code: string | null): string {
|
|
if (!code) return '';
|
|
const key = `settings.release.error.${code}`;
|
|
const localized = t(key);
|
|
// `t` falls back to the key itself when missing — detect by exact match.
|
|
return localized === key ? code : localized;
|
|
}
|
|
|
|
function relTime(iso: string | null): string {
|
|
if (!iso) return t('settings.release.never');
|
|
const then = Date.parse(iso);
|
|
if (!Number.isFinite(then)) return t('settings.release.never');
|
|
const diff = Date.now() - then;
|
|
const min = Math.round(diff / 60_000);
|
|
if (min < 1) return t('settings.release.justNow');
|
|
if (min < 60) return t('settings.release.minutesAgo').replace('{n}', String(min));
|
|
const h = Math.round(min / 60);
|
|
if (h < 24) return t('settings.release.hoursAgo').replace('{n}', String(h));
|
|
const d = Math.round(h / 24);
|
|
return t('settings.release.daysAgo').replace('{n}', String(d));
|
|
}
|
|
|
|
function setProvider(kind: ReleaseProviderKind): void {
|
|
providerKind = kind;
|
|
}
|
|
|
|
function onIntervalInput(e: Event): void {
|
|
// The native input emits string values; we keep the contract by
|
|
// re-coercing to string before assigning to the bindable prop.
|
|
const raw = (e.currentTarget as HTMLInputElement).value;
|
|
checkIntervalHours = raw === '' ? '' : String(Math.max(1, Math.min(168, Number(raw))));
|
|
}
|
|
|
|
async function checkNow(): Promise<void> {
|
|
checking = true;
|
|
try {
|
|
const next = await api<ReleaseStatus>('/settings/release/check', { method: 'POST' });
|
|
releaseStatusCache.set(next);
|
|
snackSuccess(t('settings.release.checkDone'));
|
|
} catch (err: unknown) {
|
|
snackError(err instanceof Error ? err.message : t('settings.release.checkFailed'));
|
|
} finally {
|
|
checking = false;
|
|
}
|
|
}
|
|
|
|
async function testProvider(): Promise<void> {
|
|
testing = true;
|
|
testResult = null;
|
|
try {
|
|
testResult = await api<ReleaseTestResult>('/settings/release/test', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
provider_kind: providerKind,
|
|
provider_url: providerUrl,
|
|
provider_repo: providerRepo,
|
|
include_prereleases: prereleaseChecked,
|
|
}),
|
|
});
|
|
if (testResult.ok) snackSuccess(t('settings.release.testOk'));
|
|
else snackError(t('settings.release.testFailed'));
|
|
} catch (err: unknown) {
|
|
snackError(err instanceof Error ? err.message : t('settings.release.testFailed'));
|
|
} finally {
|
|
testing = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<section class="rel glass" id="release">
|
|
<header class="rel-head">
|
|
<div class="rel-eyebrow">
|
|
<MdiIcon name="mdiUpdate" size={12} />
|
|
<span>{t('settings.release.eyebrow')}</span>
|
|
</div>
|
|
<h3 class="rel-title">{t('settings.release.headline')}</h3>
|
|
</header>
|
|
|
|
<div class="rel-body">
|
|
<!-- 01 Provider — native radios for free keyboard a11y. -->
|
|
<div class="row">
|
|
<div class="row-label">
|
|
<span class="row-num">01</span>
|
|
<span class="row-name">
|
|
{t('settings.release.provider')}
|
|
<Hint text={t('settings.release.providerHint')} />
|
|
</span>
|
|
</div>
|
|
<div class="row-control">
|
|
<div class="seg" role="radiogroup" aria-label={t('settings.release.provider')}>
|
|
<label class="seg-item" class:seg-active={providerKind === 'gitea'}>
|
|
<input
|
|
type="radio"
|
|
name="release-provider"
|
|
value="gitea"
|
|
checked={providerKind === 'gitea'}
|
|
onchange={() => setProvider('gitea')}
|
|
class="seg-radio"
|
|
/>
|
|
<span class="seg-content"><MdiIcon name="mdiGit" size={13} /> Gitea</span>
|
|
</label>
|
|
<label class="seg-item seg-soon" title={t('settings.release.comingSoon')}>
|
|
<input
|
|
type="radio"
|
|
name="release-provider"
|
|
value="github"
|
|
disabled
|
|
class="seg-radio"
|
|
/>
|
|
<span class="seg-content"><MdiIcon name="mdiGithub" size={13} /> GitHub</span>
|
|
</label>
|
|
<label class="seg-item" class:seg-active={providerKind === 'disabled'}>
|
|
<input
|
|
type="radio"
|
|
name="release-provider"
|
|
value="disabled"
|
|
checked={providerKind === 'disabled'}
|
|
onchange={() => setProvider('disabled')}
|
|
class="seg-radio"
|
|
/>
|
|
<span class="seg-content"><MdiIcon name="mdiPowerSettings" size={13} /> {t('settings.release.disabled')}</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 02 Repository -->
|
|
<div class="row" class:row-dim={isDisabled}>
|
|
<div class="row-label">
|
|
<span class="row-num">02</span>
|
|
<span class="row-name">
|
|
{t('settings.release.repository')}
|
|
<Hint text={t('settings.release.repositoryHint')} />
|
|
</span>
|
|
</div>
|
|
<div class="row-control repo-grid">
|
|
<input
|
|
bind:value={providerUrl}
|
|
placeholder="https://git.example.com"
|
|
class="text-input"
|
|
type="url"
|
|
spellcheck="false"
|
|
disabled={isDisabled}
|
|
/>
|
|
<input
|
|
bind:value={providerRepo}
|
|
placeholder="owner/repo"
|
|
class="text-input mono"
|
|
spellcheck="false"
|
|
disabled={isDisabled}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 03 Options — slider toggle for include-prereleases. -->
|
|
<div class="row" class:row-dim={isDisabled}>
|
|
<div class="row-label">
|
|
<span class="row-num">03</span>
|
|
<span class="row-name">
|
|
{t('settings.release.options')}
|
|
<Hint text={t('settings.release.prereleasesHint')} />
|
|
</span>
|
|
</div>
|
|
<div class="row-control">
|
|
<button
|
|
type="button"
|
|
class="toggle"
|
|
class:toggle-disabled={isDisabled}
|
|
onclick={() => { if (!isDisabled) includePrereleases = prereleaseChecked ? '0' : '1'; }}
|
|
aria-pressed={prereleaseChecked}
|
|
disabled={isDisabled}
|
|
>
|
|
<span class="toggle-track" class:toggle-on={prereleaseChecked} aria-hidden="true">
|
|
<span class="toggle-thumb"></span>
|
|
</span>
|
|
<span class="toggle-label-text">{t('settings.release.includePrereleases')}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 04 Check interval -->
|
|
<div class="row" class:row-dim={isDisabled}>
|
|
<div class="row-label">
|
|
<span class="row-num">04</span>
|
|
<span class="row-name">
|
|
{t('settings.release.interval')}
|
|
<Hint text={t('settings.release.intervalHint')} />
|
|
</span>
|
|
</div>
|
|
<div class="row-control interval">
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
max={168}
|
|
value={checkIntervalHours}
|
|
oninput={onIntervalInput}
|
|
class="text-input num"
|
|
disabled={isDisabled}
|
|
/>
|
|
<span class="unit">{t('settings.release.hoursUnit')}</span>
|
|
<span class="footnote">{t('settings.release.intervalRange')}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- State strip -->
|
|
<footer class="strip" data-tone={stateTone}>
|
|
<div class="strip-left">
|
|
<span class="dot" data-tone={stateTone} aria-hidden="true"></span>
|
|
<div class="strip-text">
|
|
<div class="strip-state">{stateLabel}</div>
|
|
<div class="strip-meta">
|
|
<span class="versions">
|
|
<span class="v-current">v{status?.current ?? '—'}</span>
|
|
{#if status?.latest && status.latest !== status.current}
|
|
<span class="arrow" aria-hidden="true">→</span>
|
|
<span
|
|
class="v-latest"
|
|
class:v-latest-update={status.update_available}
|
|
>v{status.latest}{#if status.latest_prerelease} · pre{/if}</span>
|
|
{/if}
|
|
</span>
|
|
<span class="sep" aria-hidden="true">·</span>
|
|
<span class="checked">
|
|
{t('settings.release.lastChecked')}: <span class="rel-time">{relTime(status?.checked_at ?? null)}</span>
|
|
</span>
|
|
</div>
|
|
{#if status?.error && status.error !== 'disabled' && status.error !== 'provider_changed'}
|
|
<div class="strip-error">
|
|
<MdiIcon name="mdiAlertCircleOutline" size={12} /> {localizedError(status.error)}
|
|
</div>
|
|
{/if}
|
|
{#if testResult && !testResult.ok}
|
|
<div class="strip-error">
|
|
<MdiIcon name="mdiAlertCircleOutline" size={12} /> {t('settings.release.testFailed')}:
|
|
{localizedError(testResult.error)}
|
|
</div>
|
|
{/if}
|
|
{#if testResult && testResult.ok && testResult.info}
|
|
<div class="strip-test-ok">
|
|
<MdiIcon name="mdiCheckCircleOutline" size={12} /> {t('settings.release.testFound')}:
|
|
<span class="mono">v{testResult.info.version}</span>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
<div class="strip-actions">
|
|
{#if status?.update_available && status.latest_url}
|
|
<a
|
|
class="strip-btn strip-btn-cta"
|
|
href={status.latest_url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
<MdiIcon name="mdiOpenInNew" size={13} />
|
|
<span>{t('settings.release.viewRelease').replace('{v}', status.latest ?? '')}</span>
|
|
</a>
|
|
{/if}
|
|
<button
|
|
type="button"
|
|
class="strip-btn"
|
|
onclick={testProvider}
|
|
disabled={testing || isDisabled || !providerRepo}
|
|
>
|
|
<MdiIcon name={testing ? 'mdiLoading' : 'mdiCheckNetworkOutline'} size={13} />
|
|
<span>{t('settings.release.testConnection')}</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="strip-btn strip-btn-primary"
|
|
onclick={checkNow}
|
|
disabled={checking || isDisabled}
|
|
>
|
|
<MdiIcon name={checking ? 'mdiLoading' : 'mdiRefresh'} size={13} />
|
|
<span>{t('settings.release.checkNow')}</span>
|
|
</button>
|
|
</div>
|
|
</footer>
|
|
</section>
|
|
|
|
<style>
|
|
.rel {
|
|
padding: 1.5rem 1.6rem 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1.2rem;
|
|
overflow: hidden;
|
|
}
|
|
.rel-head { position: relative; z-index: 1; }
|
|
.rel-eyebrow {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.35rem;
|
|
font-family: var(--font-mono);
|
|
font-size: 0.62rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.18em;
|
|
color: var(--color-muted-foreground);
|
|
margin-bottom: 0.45rem;
|
|
}
|
|
.rel-title {
|
|
margin: 0;
|
|
font-family: var(--font-display);
|
|
font-weight: 400;
|
|
font-style: italic;
|
|
font-size: 1.25rem;
|
|
line-height: 1.3;
|
|
letter-spacing: -0.015em;
|
|
color: var(--color-foreground);
|
|
max-width: 42ch;
|
|
}
|
|
|
|
.rel-body {
|
|
position: relative;
|
|
z-index: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
.row {
|
|
display: grid;
|
|
grid-template-columns: 11rem 1fr;
|
|
gap: 1.4rem;
|
|
padding: 1rem 0;
|
|
border-top: 1px solid var(--color-border);
|
|
}
|
|
.row:first-child { border-top: 0; padding-top: 0.4rem; }
|
|
.row-dim { opacity: 0.55; }
|
|
.row-label {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.3rem;
|
|
padding-top: 0.15rem;
|
|
}
|
|
.row-num {
|
|
font-family: var(--font-mono);
|
|
font-size: 0.62rem;
|
|
letter-spacing: 0.18em;
|
|
color: var(--color-muted-foreground);
|
|
}
|
|
.row-name {
|
|
font-size: 0.78rem;
|
|
font-weight: 500;
|
|
color: var(--color-foreground);
|
|
letter-spacing: -0.005em;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
}
|
|
.row-control { min-width: 0; }
|
|
|
|
/* Segmented provider control — uses real radios so arrow-key + tab
|
|
navigation just work via the browser. */
|
|
.seg {
|
|
display: inline-flex;
|
|
gap: 0.25rem;
|
|
padding: 0.25rem;
|
|
background: var(--color-glass-strong);
|
|
border: 1px solid var(--color-rule-strong);
|
|
border-radius: 0.6rem;
|
|
}
|
|
.seg-item {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
border-radius: 0.45rem;
|
|
cursor: pointer;
|
|
position: relative;
|
|
}
|
|
.seg-radio {
|
|
position: absolute;
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
inset: 0;
|
|
}
|
|
.seg-radio:focus-visible + .seg-content {
|
|
outline: 2px solid var(--color-primary);
|
|
outline-offset: 2px;
|
|
}
|
|
.seg-content {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
padding: 0.4rem 0.7rem;
|
|
border-radius: 0.45rem;
|
|
font-size: 0.78rem;
|
|
color: var(--color-muted-foreground);
|
|
transition: background 0.18s, color 0.18s;
|
|
}
|
|
.seg-item:hover:not(.seg-soon) .seg-content {
|
|
color: var(--color-foreground);
|
|
background: var(--color-glass);
|
|
}
|
|
.seg-active .seg-content {
|
|
color: var(--color-foreground);
|
|
background: var(--color-input-bg);
|
|
box-shadow: 0 0 0 1px var(--color-primary);
|
|
}
|
|
.seg-soon { opacity: 0.45; cursor: not-allowed; }
|
|
|
|
/* Text fields */
|
|
.repo-grid {
|
|
display: grid;
|
|
grid-template-columns: minmax(14rem, 18rem) minmax(0, 1fr);
|
|
gap: 0.6rem;
|
|
max-width: 100%;
|
|
}
|
|
.text-input {
|
|
width: 100%;
|
|
padding: 0.55rem 0.75rem;
|
|
border: 1px solid var(--color-rule-strong);
|
|
border-radius: 0.6rem;
|
|
background: var(--color-input-bg);
|
|
font-family: var(--font-sans);
|
|
font-size: 0.82rem;
|
|
color: var(--color-foreground);
|
|
transition: border-color 0.18s, box-shadow 0.18s;
|
|
}
|
|
.text-input.mono { font-family: var(--font-mono); }
|
|
.text-input.num { max-width: 6rem; text-align: right; }
|
|
.text-input:focus {
|
|
outline: 0;
|
|
border-color: var(--color-primary);
|
|
box-shadow: 0 0 0 3px var(--color-glow);
|
|
}
|
|
.text-input:disabled { cursor: not-allowed; opacity: 0.55; }
|
|
|
|
/* Interval */
|
|
.interval { display: inline-flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
|
|
.unit {
|
|
font-family: var(--font-mono);
|
|
font-size: 0.72rem;
|
|
color: var(--color-muted-foreground);
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
}
|
|
.footnote {
|
|
font-size: 0.68rem;
|
|
color: var(--color-muted-foreground);
|
|
font-style: italic;
|
|
}
|
|
|
|
/* Slider toggle — mirrors the backup ScheduleCassette pattern. */
|
|
.toggle {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.7rem;
|
|
background: transparent;
|
|
border: 0;
|
|
padding: 0;
|
|
font: inherit;
|
|
color: var(--color-foreground);
|
|
cursor: pointer;
|
|
}
|
|
.toggle:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 4px; border-radius: 4px; }
|
|
.toggle-track {
|
|
position: relative;
|
|
width: 40px;
|
|
height: 22px;
|
|
border-radius: 999px;
|
|
background: var(--color-glass-strong);
|
|
border: 1px solid var(--color-rule-strong);
|
|
flex-shrink: 0;
|
|
transition: background 0.2s, border-color 0.2s;
|
|
}
|
|
.toggle-thumb {
|
|
position: absolute;
|
|
top: 2px;
|
|
left: 2px;
|
|
width: 16px; height: 16px;
|
|
border-radius: 50%;
|
|
background: var(--color-muted-foreground);
|
|
transition: transform 0.2s, background 0.2s;
|
|
}
|
|
.toggle-on {
|
|
background: linear-gradient(135deg, color-mix(in srgb, var(--color-mint) 60%, transparent), color-mix(in srgb, var(--color-primary) 60%, transparent));
|
|
border-color: color-mix(in srgb, var(--color-mint) 60%, var(--color-rule-strong));
|
|
}
|
|
.toggle-on .toggle-thumb {
|
|
background: white;
|
|
transform: translateX(18px);
|
|
}
|
|
.toggle-label-text { font-size: 0.82rem; }
|
|
.toggle-disabled { opacity: 0.55; cursor: not-allowed; }
|
|
|
|
/* State strip */
|
|
.strip {
|
|
margin: 0 -1.6rem;
|
|
padding: 1rem 1.6rem;
|
|
border-top: 1px solid var(--color-border);
|
|
background:
|
|
linear-gradient(180deg,
|
|
color-mix(in srgb, var(--color-glass-strong) 60%, transparent),
|
|
transparent
|
|
);
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
gap: 1rem;
|
|
flex-wrap: wrap;
|
|
position: relative;
|
|
}
|
|
.strip[data-tone="citrus"]::before {
|
|
content: '';
|
|
position: absolute;
|
|
left: 0;
|
|
right: 0;
|
|
top: 0;
|
|
height: 1px;
|
|
background: linear-gradient(
|
|
90deg,
|
|
transparent 10%,
|
|
color-mix(in srgb, var(--color-citrus, #d4a73a) 70%, transparent) 50%,
|
|
transparent 90%
|
|
);
|
|
animation: aurora-shimmer 4s linear infinite;
|
|
}
|
|
.strip-left { display: flex; align-items: flex-start; gap: 0.7rem; min-width: 0; flex: 1 1 auto; }
|
|
.dot {
|
|
width: 0.55rem;
|
|
height: 0.55rem;
|
|
border-radius: 999px;
|
|
margin-top: 0.45rem;
|
|
flex-shrink: 0;
|
|
}
|
|
.dot[data-tone="mint"] { background: var(--color-mint, #6fcfa6); box-shadow: 0 0 8px color-mix(in srgb, var(--color-mint, #6fcfa6) 60%, transparent); }
|
|
.dot[data-tone="citrus"] { background: var(--color-citrus, #d4a73a); box-shadow: 0 0 10px color-mix(in srgb, var(--color-citrus, #d4a73a) 70%, transparent); }
|
|
.dot[data-tone="coral"] { background: var(--color-coral, #d27a7a); box-shadow: 0 0 8px color-mix(in srgb, var(--color-coral, #d27a7a) 60%, transparent); }
|
|
.dot[data-tone="sky"] { background: var(--color-muted-foreground); }
|
|
.strip-text { display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; }
|
|
.strip-state {
|
|
font-family: var(--font-display);
|
|
font-style: italic;
|
|
font-size: 0.95rem;
|
|
letter-spacing: -0.01em;
|
|
color: var(--color-foreground);
|
|
}
|
|
.strip-meta {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
gap: 0.4rem;
|
|
font-size: 0.74rem;
|
|
color: var(--color-muted-foreground);
|
|
}
|
|
.versions { display: inline-flex; align-items: center; gap: 0.35rem; }
|
|
.v-current { font-family: var(--font-mono); color: var(--color-foreground); }
|
|
.arrow { color: var(--color-muted-foreground); }
|
|
.v-latest { font-family: var(--font-mono); color: var(--color-foreground); }
|
|
.v-latest-update { color: var(--color-citrus, #d4a73a); font-weight: 600; }
|
|
.sep { opacity: 0.5; }
|
|
.rel-time { color: var(--color-foreground); }
|
|
.strip-error {
|
|
font-size: 0.72rem;
|
|
color: var(--color-coral, #d27a7a);
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.3rem;
|
|
margin-top: 0.15rem;
|
|
}
|
|
.strip-test-ok {
|
|
font-size: 0.72rem;
|
|
color: var(--color-mint, #6fcfa6);
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.3rem;
|
|
margin-top: 0.15rem;
|
|
}
|
|
|
|
.strip-actions { display: inline-flex; gap: 0.5rem; flex-shrink: 0; flex-wrap: wrap; }
|
|
.strip-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
padding: 0.5rem 0.85rem;
|
|
border: 1px solid var(--color-rule-strong);
|
|
border-radius: 0.55rem;
|
|
background: var(--color-input-bg);
|
|
font-size: 0.76rem;
|
|
color: var(--color-foreground);
|
|
cursor: pointer;
|
|
text-decoration: none;
|
|
transition: background 0.18s, border-color 0.18s, transform 0.18s;
|
|
}
|
|
.strip-btn:hover:not(:disabled) {
|
|
background: var(--color-glass-strong);
|
|
border-color: var(--color-primary);
|
|
}
|
|
.strip-btn:active:not(:disabled) { transform: translateY(1px); }
|
|
.strip-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
.strip-btn-primary {
|
|
background: color-mix(in srgb, var(--color-primary) 12%, var(--color-input-bg));
|
|
border-color: color-mix(in srgb, var(--color-primary) 35%, var(--color-rule-strong));
|
|
}
|
|
/* The CTA — high-visibility when an update is available. */
|
|
.strip-btn-cta {
|
|
background: linear-gradient(135deg,
|
|
color-mix(in srgb, var(--color-citrus, #d4a73a) 26%, var(--color-input-bg)),
|
|
color-mix(in srgb, var(--color-citrus, #d4a73a) 14%, var(--color-input-bg))
|
|
);
|
|
border-color: color-mix(in srgb, var(--color-citrus, #d4a73a) 55%, var(--color-rule-strong));
|
|
color: var(--color-foreground);
|
|
font-weight: 500;
|
|
box-shadow: 0 0 12px color-mix(in srgb, var(--color-citrus, #d4a73a) 25%, transparent);
|
|
}
|
|
.strip-btn-cta:hover {
|
|
background: linear-gradient(135deg,
|
|
color-mix(in srgb, var(--color-citrus, #d4a73a) 40%, var(--color-input-bg)),
|
|
color-mix(in srgb, var(--color-citrus, #d4a73a) 22%, var(--color-input-bg))
|
|
);
|
|
border-color: color-mix(in srgb, var(--color-citrus, #d4a73a) 75%, var(--color-rule-strong));
|
|
}
|
|
|
|
.mono { font-family: var(--font-mono); }
|
|
|
|
@keyframes aurora-shimmer {
|
|
0% { transform: translateX(-100%); }
|
|
100% { transform: translateX(100%); }
|
|
}
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.strip[data-tone="citrus"]::before { animation: none; }
|
|
.strip-btn { transition: none; }
|
|
}
|
|
|
|
@media (max-width: 720px) {
|
|
.row {
|
|
grid-template-columns: 1fr;
|
|
gap: 0.55rem;
|
|
padding: 0.95rem 0;
|
|
}
|
|
.row-label { padding-top: 0; }
|
|
.repo-grid { grid-template-columns: 1fr; }
|
|
.strip { flex-direction: column; align-items: stretch; }
|
|
.strip-actions { justify-content: stretch; }
|
|
.strip-btn { flex: 1; justify-content: center; }
|
|
}
|
|
</style>
|