update UI

This commit is contained in:
2026-08-11 09:19:13 -07:00
parent 97a3f1be48
commit 4eda93cf24
31 changed files with 952 additions and 95 deletions
+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 72" aria-hidden="true">
<!-- Repository-original transparent lotus silhouette used as a CSS mask. -->
<g fill="#000">
<path d="M48 39C36 28 35 15 48 3c13 12 12 25 0 36Z" />
<path d="M43 43C27 38 19 26 24 10c16 5 23 17 19 33Z" opacity=".9" />
<path d="M53 43c16-5 24-17 19-33-16 5-23 17-19 33Z" opacity=".9" />
<path d="M39 48C21 49 9 40 7 24c17-1 28 8 32 24Z" opacity=".75" />
<path d="M57 48c18 1 30-8 32-24-17-1-28 8-32 24Z" opacity=".75" />
<path d="M48 62C29 62 16 56 10 45c13 4 25 4 38-1 13 5 25 5 38 1-6 11-19 17-38 17Z" />
<path d="M17 67c20-5 42-5 62 0-21 3-41 3-62 0Z" opacity=".62" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 697 B

+58
View File
@@ -0,0 +1,58 @@
/**
* Shared Moonlit Water edge ornament.
*
* Keeping the artwork inline lets the persisted decoration setting control
* real SVG stroke width. A raster image or CSS-scaled mask would only distort
* the motif and could not make its calligraphic lines genuinely heavier.
*/
function EdgeArtwork({ position }: { position: 'top' | 'bottom' }) {
return (
<svg
className={`theme-edge-art theme-edge-${position}`}
viewBox="0 0 1200 56"
preserveAspectRatio="none"
aria-hidden="true"
>
<g className="theme-edge-rule">
<path d="M12 30h338c52 0 86-8 116-24-13 13-13 29 0 42-30-16-64-24-116-24H12" />
<path d="M1188 30H850c-52 0-86-8-116-24 13 13 13 29 0 42 30-16 64-24 116-24h338" />
</g>
<g className="theme-edge-water">
<path d="M38 38c35-14 70-14 105 0s70 14 105 0" />
<path d="M1162 38c-35-14-70-14-105 0s-70 14-105 0" />
</g>
<g className="theme-edge-cloud">
<path d="M192 27c10-14 31-14 40 0 8-10 25-8 29 4-22 8-53 8-76 0 1-2 4-3 7-4Z" />
<path d="M1008 27c-10-14-31-14-40 0-8-10-25-8-29 4 22 8 53 8 76 0-1-2-4-3-7-4Z" />
</g>
<g className="theme-edge-leaves">
<path d="M485 28c13-14 27-17 42-9-10 13-24 16-42 9Z" />
<path d="M715 28c-13-14-27-17-42-9 10 13 24 16 42 9Z" />
</g>
<g className="theme-edge-lotus">
<path d="M600 34c-9-10-9-22 0-32 9 10 9 22 0 32Z" />
<path d="M595 36c-15-5-22-16-19-29 15 5 22 15 19 29Z" />
<path d="M605 36c15-5 22-16 19-29-15 5-22 15-19 29Z" />
<path d="M590 40c-17 0-28-7-32-20 16 0 27 7 32 20Z" />
<path d="M610 40c17 0 28-7 32-20-16 0-27 7-32 20Z" />
<path d="M600 49c-18 0-31-5-39-15 14 4 27 3 39-3 12 6 25 7 39 3-8 10-21 15-39 15Z" />
</g>
<g className="theme-edge-dots">
<circle cx="92" cy="27" r="3.2" />
<circle cx="1108" cy="27" r="3.2" />
<path d="m536 27 4-7 4 7-4 7Z" />
<path d="m664 27-4-7-4 7 4 7Z" />
</g>
</svg>
)
}
/** Decorative chrome is ignored by assistive technology and never captures input. */
export function ThemeEdges() {
return (
<div className="theme-edge-set" aria-hidden="true">
<EdgeArtwork position="top" />
<EdgeArtwork position="bottom" />
</div>
)
}
+1 -1
View File
@@ -51,7 +51,7 @@ function AuthShell({
)
}
function TotpQr({ enrollment }: { enrollment: TotpEnrollment }) {
export function TotpQr({ enrollment }: { enrollment: TotpEnrollment }) {
const { t } = useI18n()
const source = useMemo(() => {
if (enrollment.qrDataUrl) return enrollment.qrDataUrl
+46 -3
View File
@@ -374,7 +374,17 @@ body,
}
.wall {
justify-content: flex-start;
height: 100%;
min-height: 0;
justify-content: flex-end;
overflow: hidden;
}
.wall > .cards {
/* Keep the chronological stack pinned to the bottom. Once it is taller
than the OBS viewport, its oldest rows overflow above the clipping edge
while each newly appended row remains visible at the bottom. */
flex: 0 0 auto;
}
.card {
@@ -804,6 +814,22 @@ body,
background 0.24s ease;
}
.danmaku-lotus {
position: absolute;
z-index: 1;
top: 50%;
right: clamp(8px, 1.6vw, 18px);
width: clamp(26px, 7%, 44px);
aspect-ratio: 4 / 3;
pointer-events: none;
background: linear-gradient(145deg, var(--theme-accent), var(--theme-user));
filter: drop-shadow(0 0 5px color-mix(in srgb, var(--theme-accent) 48%, transparent));
opacity: 0.62;
transform: translateY(-50%);
-webkit-mask: var(--theme-pattern-lotus) center / contain no-repeat;
mask: var(--theme-pattern-lotus) center / contain no-repeat;
}
.card.danmaku.expanded {
min-height: 92px;
padding: 14px;
@@ -826,7 +852,7 @@ body,
inset-inline: -1px;
z-index: 2;
pointer-events: none;
border-inline: 4px solid rgba(123, 238, 214, 0.72);
border-inline: var(--component-decoration-rail, 4px) solid rgba(123, 238, 214, 0.72);
border-radius: 11px;
background: linear-gradient(
90deg,
@@ -901,6 +927,11 @@ body,
white-space: normal;
}
.card.danmaku.expanded .copy,
.card.danmaku.compact .copy {
padding-inline-end: clamp(38px, 8%, 58px);
}
.short .cards .card:nth-child(n + 4) {
display: flex;
}
@@ -1071,6 +1102,13 @@ body,
width: 100%;
}
.theme-moonlit-water .danmaku-lotus {
width: clamp(30px, 8%, 52px);
background: linear-gradient(145deg, #e8d59a, #9cb7a5 72%);
filter: drop-shadow(0 0 5px rgba(231, 201, 130, 0.34));
opacity: 0.82;
}
.theme-moonlit-water .card.danmaku .copy > b {
grid-column: 2;
grid-row: 1;
@@ -1099,7 +1137,7 @@ body,
height: clamp(38px, 2.3em, 56px);
overflow: hidden;
place-items: center;
border: 1px solid rgba(231, 201, 130, 0.58);
border: var(--component-decoration-line, 1px) solid rgba(231, 201, 130, 0.58);
border-radius: 50%;
background: radial-gradient(circle at 36% 28%, rgba(224, 233, 205, 0.32), rgba(22, 72, 76, 0.5));
color: #d2b976;
@@ -1190,6 +1228,11 @@ body,
height: clamp(32px, 1.9em, 44px);
}
.theme-moonlit-water .card.danmaku.expanded .copy,
.theme-moonlit-water .card.danmaku.compact .copy {
padding-inline-end: clamp(42px, 9%, 66px);
}
.theme-moonlit-water .danmaku-emoticon {
filter: drop-shadow(0 1px 3px rgba(10, 34, 38, 0.56));
}
+241
View File
@@ -19,11 +19,14 @@ import {
normalizeGiftEffectSettings,
normalizeGiftMenuSettings,
normalizeInvitations,
normalizeEnrollment,
normalizeRecoveryCodes,
normalizeSettings,
normalizeSongRequestPage,
normalizeSongRequestSettings,
normalizeSource,
} from './api'
import { TotpQr } from './auth'
import { Overlay } from './overlay'
import { GiftEffectOverlay } from './giftEffect'
import { GiftMenuOverlay } from './giftMenu'
@@ -56,6 +59,7 @@ import type {
SongRequestItem,
SongRequestPage,
SongRequestSettings,
TotpEnrollment,
} from './types'
const previewPresets = [
@@ -115,6 +119,32 @@ function TypographySettingsFields({
)
}
/** Shared percentage control for theme borders, dividers, and edge ornaments. */
function DecorationLineWeightField({
value,
onChange,
}: {
value: number
onChange: (value: number) => void
}) {
return (
<label>
<span>
{translate('settings.decoration_line_weight')} <output>{value}%</output>
</span>
<input
type="range"
min="50"
max="300"
step="10"
value={value}
onChange={event => onChange(+event.target.value)}
/>
<small>{translate('settings.decoration_line_weight_description')}</small>
</label>
)
}
type TextColorField = {
labelKey: string
color: string | null
@@ -341,6 +371,10 @@ function SettingsEditor({
]}
/>
<div className="slider-grid">
<DecorationLineWeightField
value={settings.decorationLineWeight}
onChange={value => edit('decorationLineWeight', value)}
/>
<label>
<span>
{translate('settings.font_size')} <output>{settings.fontScale}%</output>
@@ -571,6 +605,10 @@ function SongRequestSettingsEditor({
]}
/>
<div className="slider-grid">
<DecorationLineWeightField
value={settings.decorationLineWeight}
onChange={value => edit('decorationLineWeight', value)}
/>
<label>
<span>
{translate('settings.font_size')} <output>{settings.fontScale}%</output>
@@ -1196,6 +1234,10 @@ function GiftMenuSettingsEditor({
</div>
<div className="slider-grid gift-menu-renderer-settings">
<DecorationLineWeightField
value={settings.decorationLineWeight}
onChange={value => edit('decorationLineWeight', value)}
/>
<label>
<span>
{translate(
@@ -1490,6 +1532,204 @@ function SourceEditor({
)
}
/** Session-protected TOTP replacement with one-time QR and recovery material. */
function TotpResetPanel() {
const [proof, setProof] = useState('')
const [useRecoveryCode, setUseRecoveryCode] = useState(false)
const [enrollment, setEnrollment] = useState<TotpEnrollment>()
const [newCode, setNewCode] = useState('')
const [recoveryCodes, setRecoveryCodes] = useState<string[]>()
const [flash, setFlash] = useState<Flash>()
const [busy, setBusy] = useState(false)
usePwaUpdateBlocker(
'totp-reset',
translate('security.totp_reset_blocker'),
busy || Boolean(proof || enrollment || newCode || recoveryCodes),
)
const start = async (event: FormEvent) => {
event.preventDefault()
setBusy(true)
setFlash(undefined)
try {
const payload = await api<unknown>(
'/api/v1/auth/totp/reset/start',
json('POST', { code: proof }),
)
const next = normalizeEnrollment(payload)
if (!next.enrollmentToken) throw new Error(translate('security.totp_reset_missing_id'))
setProof('')
setEnrollment(next)
} catch (reason) {
setFlash({
kind: 'error',
text: errorMessage(reason, translate('security.totp_reset_start_failed')),
})
} finally {
setBusy(false)
}
}
const confirm = async (event: FormEvent) => {
event.preventDefault()
if (!enrollment) return
setBusy(true)
setFlash(undefined)
try {
const payload = await api<unknown>(
'/api/v1/auth/totp/reset/confirm',
json('POST', {
enrollmentToken: enrollment.enrollmentToken,
code: newCode,
}),
)
setNewCode('')
setEnrollment(undefined)
setRecoveryCodes(normalizeRecoveryCodes(payload))
} catch (reason) {
setFlash({
kind: 'error',
text: errorMessage(reason, translate('security.totp_reset_confirm_failed')),
})
} finally {
setBusy(false)
}
}
const cancel = () => {
setEnrollment(undefined)
setNewCode('')
setFlash({ kind: 'success', text: translate('security.totp_reset_cancelled') })
}
const recoveryText = recoveryCodes?.join('\n') ?? ''
const downloadRecoveryCodes = () => {
const blob = new Blob(
[
`${translate('auth.recovery_file_title')}\n`,
`${translate('auth.recovery_file_warning')}\n\n`,
recoveryText,
'\n',
],
{ type: 'text/plain;charset=utf-8' },
)
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = 'live-component-recovery-codes.txt'
anchor.click()
URL.revokeObjectURL(url)
}
return (
<Panel title={translate('security.title')} description={translate('security.description')}>
<FlashMessage flash={flash} />
{recoveryCodes ? (
<div>
<div className="notice warning">{translate('security.recovery_replaced')}</div>
<div className="recovery-grid">
{recoveryCodes.map(code => (
<code key={code}>{code}</code>
))}
</div>
<div className="form-actions">
<button
type="button"
className="secondary"
onClick={() => void copyToClipboard(recoveryText)}
>
{translate('auth.copy_all')}
</button>
<button type="button" className="secondary" onClick={downloadRecoveryCodes}>
{translate('auth.download_text')}
</button>
<button
type="button"
onClick={() => {
setRecoveryCodes(undefined)
setFlash({ kind: 'success', text: translate('security.totp_reset_complete') })
}}
>
{translate('auth.recovery_saved')}
</button>
</div>
</div>
) : enrollment ? (
<div>
<div className="notice warning">{translate('security.old_totp_still_active')}</div>
<TotpQr enrollment={enrollment} />
<form className="stack-form compact-form" onSubmit={confirm}>
<label>
{translate('security.new_totp_code')}
<input
required
autoFocus
className="otp-input"
inputMode="numeric"
autoComplete="one-time-code"
pattern="[0-9]{6}"
maxLength={6}
placeholder="000000"
value={newCode}
onChange={event => setNewCode(event.target.value.replace(/\D/g, '').slice(0, 6))}
/>
</label>
<div className="form-actions">
<button disabled={busy || newCode.length !== 6}>
{busy ? translate('auth.confirming') : translate('security.confirm_totp_reset')}
</button>
<button type="button" className="secondary" disabled={busy} onClick={cancel}>
{translate('security.cancel_totp_reset')}
</button>
</div>
</form>
</div>
) : (
<form className="stack-form compact-form" onSubmit={start}>
<div className="notice warning">{translate('security.totp_reset_warning')}</div>
<label>
{useRecoveryCode
? translate('auth.recovery_code')
: translate('security.current_totp_code')}
<input
required
className={useRecoveryCode ? 'recovery-input' : 'otp-input'}
inputMode={useRecoveryCode ? 'text' : 'numeric'}
autoComplete={useRecoveryCode ? 'off' : 'one-time-code'}
pattern={useRecoveryCode ? undefined : '[0-9]{6}'}
maxLength={useRecoveryCode ? 64 : 6}
placeholder={useRecoveryCode ? translate('auth.recovery_placeholder') : '000000'}
value={proof}
onChange={event =>
setProof(
useRecoveryCode
? event.target.value.trimStart().slice(0, 64)
: event.target.value.replace(/\D/g, '').slice(0, 6),
)
}
/>
</label>
<button
type="button"
className="inline-link"
onClick={() => {
setUseRecoveryCode(current => !current)
setProof('')
}}
>
{useRecoveryCode
? translate('auth.use_totp')
: translate('security.use_recovery_for_reset')}
</button>
<button disabled={busy || !proof.trim()}>
{busy ? translate('security.preparing_totp') : translate('security.start_totp_reset')}
</button>
</form>
)}
</Panel>
)
}
type TokenState = {
publicId: string
configured: boolean
@@ -2239,6 +2479,7 @@ export function AccountLiveSourcePage({
<FlashMessage flash={flash} />
{loading && <div className="loading-panel jade-panel">{translate('account.loading')}</div>}
{source && <SourceEditor source={source} onSaved={setSource} />}
<TotpResetPanel />
</div>
</ControlLayout>
)
+7 -5
View File
@@ -24,7 +24,8 @@ body,
height: min(100%, var(--menu-panel-height));
min-height: min(100%, var(--menu-row-height));
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--menu-jade) 38%, transparent);
border: var(--component-decoration-line, 1px) solid
color-mix(in srgb, var(--menu-jade) 38%, transparent);
border-radius: clamp(10px, 1.5vmin, 22px);
outline: 1px solid rgba(255, 227, 142, 0.07);
outline-offset: -4px;
@@ -71,7 +72,8 @@ body,
gap: calc(var(--menu-row-height) * 0.13);
overflow: hidden;
padding: calc(var(--menu-row-height) * 0.1) calc(var(--menu-row-height) * 0.22);
border: 1px solid color-mix(in srgb, var(--menu-jade) 36%, transparent);
border: var(--component-decoration-line, 1px) solid
color-mix(in srgb, var(--menu-jade) 36%, transparent);
border-radius: calc(var(--menu-row-height) * 0.17);
background:
linear-gradient(90deg, rgba(4, 29, 43, 0.91), rgba(7, 63, 66, 0.72) 58%, rgba(3, 27, 41, 0.9)),
@@ -438,7 +440,7 @@ body,
gap: calc(var(--menu-row-height) * 0.12);
padding-inline: calc(var(--menu-row-height) * 0.12);
border: 0;
border-bottom: 1px solid rgba(231, 201, 130, 0.17);
border-bottom: var(--component-decoration-line, 1px) solid rgba(231, 201, 130, 0.17);
border-radius: 0;
background: transparent;
box-shadow: none;
@@ -498,7 +500,7 @@ body,
inset: 0;
padding: 0.28em 1em;
border: 0;
border-bottom: 1px solid rgba(231, 201, 130, 0.82);
border-bottom: var(--component-decoration-line, 1px) solid rgba(231, 201, 130, 0.82);
border-radius: 0;
color: #eadfc3;
background: linear-gradient(90deg, transparent, rgba(72, 105, 98, 0.32) 50%, transparent);
@@ -529,7 +531,7 @@ body,
.gift-menu-theme-moonlit-water .gift-menu-empty {
border: 0;
border-bottom: 1px solid rgba(231, 201, 130, 0.25);
border-bottom: var(--component-decoration-line, 1px) solid rgba(231, 201, 130, 0.25);
border-radius: 0;
color: #afc2ad;
background: transparent;
+4
View File
@@ -7,6 +7,7 @@ import { translate, useI18n } from './i18n'
import type { ComponentStream } from './stream'
import { defaultGiftMenuSettings } from './types'
import type { GiftMenuItem, GiftMenuSettings } from './types'
import { ThemeEdges } from './ThemeEdges'
import { typographyVariables } from './typography'
type MenuEnvelope = {
@@ -365,6 +366,8 @@ export function GiftMenuOverlay({
{
...giftMenuThemeVariables(theme),
...typographyVariables(settings.fontFamily, settings.fontBrightness),
['--component-decoration-line' as string]: `${settings.decorationLineWeight / 100}px`,
['--component-decoration-edge-stroke' as string]: `${settings.decorationLineWeight / 50}px`,
['--menu-row-height' as string]: `${settings.rowHeight}px`,
['--menu-panel-height' as string]: `${settings.rowHeight * settings.visibleRows}px`,
['--menu-title-size' as string]: `${settings.rowHeight * 0.205 * (settings.fontScale / 100)}px`,
@@ -379,6 +382,7 @@ export function GiftMenuOverlay({
}
data-connection={stream?.connection ?? 'idle'}
>
<ThemeEdges />
<section
className="gift-menu-viewport"
ref={viewport}
+24 -17
View File
@@ -16,6 +16,7 @@ import type { OverlayThemeDefinition } from './themes'
import { defaultOverlaySettings } from './types'
import type { OverlaySettings } from './types'
import { typographyVariables } from './typography'
import { ThemeEdges } from './ThemeEdges'
type Envelope = {
id: string
@@ -170,18 +171,19 @@ function useEvents(preview: boolean, stream?: ComponentStream) {
chooseDecorVariant(
`${envelope.type}:${key}`,
theme.ornaments.variantCount,
old[0]?.decorVariant,
old[old.length - 1]?.decorVariant,
)
return [
{ ...envelope, key, received: Date.now(), decorVariant },
const next = [
...old.filter(item => item.key !== key),
].slice(0, current.maxVisible)
{ ...envelope, key, received: Date.now(), decorVariant },
]
return next.slice(Math.max(0, next.length - current.maxVisible))
})
}
}, [preview, stream, stream?.messages])
useEffect(() => {
setItems(current => current.slice(0, settings.maxVisible))
setItems(current => current.slice(Math.max(0, current.length - settings.maxVisible)))
}, [settings.maxVisible])
return { settings, items, setItems }
@@ -324,6 +326,7 @@ function Card({
<em>¥ {gift.priceCny.toFixed(2)}</em>
)}
</div>
{isDanmaku && <i className="danmaku-lotus" aria-hidden="true" />}
{tier === 'featured' && <div className="particles">✦ ✧ ✦</div>}
</article>
)
@@ -353,17 +356,6 @@ export function Overlay({ preview = false, previewSettings, stream }: OverlayPro
useEffect(() => {
if (preview) {
setItems([
{
id: 'text-preview',
key: 'text-preview',
received: Date.now(),
decorVariant: 0,
type: 'live.danmaku',
payload: {
viewer: { name: translate('overlay.preview_viewer') },
text: translate('overlay.preview_message'),
},
},
{
id: 'gift-preview',
key: 'gift-preview',
@@ -382,12 +374,23 @@ export function Overlay({ preview = false, previewSettings, stream }: OverlayPro
},
},
},
{
id: 'text-preview',
key: 'text-preview',
received: Date.now(),
decorVariant: 0,
type: 'live.danmaku',
payload: {
viewer: { name: translate('overlay.preview_viewer') },
text: translate('overlay.preview_message'),
},
},
])
}
}, [items.length, language, preview, setItems])
useEffect(() => {
const newest = items[0]
const newest = items[items.length - 1]
if (!newest) {
setExpandedKey(undefined)
return
@@ -413,6 +416,9 @@ export function Overlay({ preview = false, previewSettings, stream }: OverlayPro
...typographyVariables(settings.fontFamily, settings.fontBrightness),
['--component-viewer-color' as string]: settings.viewerColor || undefined,
['--component-danmaku-color' as string]: settings.danmakuColor || undefined,
['--component-decoration-line' as string]: `${settings.decorationLineWeight / 100}px`,
['--component-decoration-edge-stroke' as string]: `${settings.decorationLineWeight / 50}px`,
['--component-decoration-rail' as string]: `${settings.decorationLineWeight / 50}px`,
['--motion' as string]: `${settings.motionIntensity / 100}`,
['--unfold-duration' as string]: `${settings.unfoldDurationMs || defaultOverlaySettings.unfoldDurationMs}ms`,
['--particle-duration' as string]: `${400000 / Math.min(300, Math.max(25, settings.particleSpeed || defaultOverlaySettings.particleSpeed))}ms`,
@@ -422,6 +428,7 @@ export function Overlay({ preview = false, previewSettings, stream }: OverlayPro
} as CSSProperties
}
>
<ThemeEdges />
<section className="wall">
<div className="cards">
{items.map(item => (
+9 -9
View File
@@ -26,7 +26,7 @@ body,
position: relative;
min-width: 0;
overflow: hidden;
border: 1px solid var(--theme-card-border);
border: var(--component-decoration-line, 1px) solid var(--theme-card-border);
border-radius: clamp(8px, 1.5vmin, 15px);
background: var(--theme-card-background);
box-shadow:
@@ -60,7 +60,7 @@ body,
place-items: center;
width: clamp(30px, 3em, 46px);
aspect-ratio: 1;
border: 1px solid rgba(133, 255, 232, 0.5);
border: var(--component-decoration-line, 1px) solid rgba(133, 255, 232, 0.5);
border-radius: 50%;
color: var(--theme-accent);
background: rgba(6, 48, 57, 0.72);
@@ -127,7 +127,7 @@ body,
align-items: center;
justify-content: space-between;
padding: 0.38em 0.72em;
border-bottom: 1px solid rgba(133, 255, 232, 0.16);
border-bottom: var(--component-decoration-line, 1px) solid rgba(133, 255, 232, 0.16);
color: var(--theme-compact-user);
letter-spacing: 0.12em;
}
@@ -161,7 +161,7 @@ body,
align-items: center;
gap: 0.55em;
padding: 0.4em 0.62em;
border: 1px solid rgba(128, 238, 218, 0.13);
border: var(--component-decoration-line, 1px) solid rgba(128, 238, 218, 0.13);
border-radius: 0.62em;
background: linear-gradient(90deg, rgba(8, 47, 59, 0.74), rgba(8, 71, 72, 0.46));
overflow: hidden;
@@ -419,14 +419,14 @@ body,
grid-template-columns: auto minmax(0, 1fr) auto;
gap: clamp(10px, 1.5vmin, 18px);
padding: clamp(6px, 0.8vmin, 10px) 0 clamp(12px, 1.6vmin, 20px);
border-bottom: 1px solid rgba(198, 174, 122, 0.26);
border-bottom: var(--component-decoration-line, 1px) solid rgba(198, 174, 122, 0.26);
animation: moonlit-song-current 0.68s ease-out both;
}
.theme-moonlit-water .song-current-mark {
position: relative;
width: clamp(46px, 3.7em, 68px);
border: 1px solid rgba(198, 174, 122, 0.62);
border: var(--component-decoration-line, 1px) solid rgba(198, 174, 122, 0.62);
border-radius: 50%;
color: #d2b468;
background: radial-gradient(circle, rgba(198, 174, 122, 0.08), transparent 68%);
@@ -443,8 +443,8 @@ body,
right: -0.18em;
width: 0.72em;
height: 0.42em;
border: 1px solid rgba(116, 166, 157, 0.52);
border-width: 1px 0 0 1px;
border: var(--component-decoration-line, 1px) solid rgba(116, 166, 157, 0.52);
border-width: var(--component-decoration-line, 1px) 0 0 var(--component-decoration-line, 1px);
border-radius: 100% 0;
transform: rotate(-24deg);
}
@@ -510,7 +510,7 @@ body,
}
.theme-moonlit-water .song-waveform i {
width: 2px;
width: var(--component-decoration-line, 2px);
height: 25%;
background: #6a9f99;
animation: moonlit-song-wave 1.8s ease-in-out infinite alternate;
+4
View File
@@ -6,6 +6,7 @@ import { translate, useI18n } from './i18n'
import type { ComponentStream } from './stream'
import { getOverlayTheme, themeCssVariables } from './themes'
import type { OverlayThemeDefinition } from './themes'
import { ThemeEdges } from './ThemeEdges'
import { defaultSongRequestSettings } from './types'
import type { SongRequestItem, SongRequestSettings } from './types'
import { typographyVariables } from './typography'
@@ -324,6 +325,8 @@ export function SongRequestOverlay({
...typographyVariables(settings.fontFamily, settings.fontBrightness),
['--component-song-requester-color' as string]: settings.requesterColor || undefined,
['--component-song-title-color' as string]: settings.songTitleColor || undefined,
['--component-decoration-line' as string]: `${settings.decorationLineWeight / 100}px`,
['--component-decoration-edge-stroke' as string]: `${settings.decorationLineWeight / 50}px`,
['--song-font-size' as string]: `${Math.min(22, Math.max(11, Math.min(bounds.width, bounds.height) * 0.024)) * (settings.fontScale / 100)}px`,
['--song-current-min' as string]: `${Math.min(72, Math.max(48, bounds.height * 0.08))}px`,
['--song-moonlit-font-size' as string]: `${Math.min(26, Math.max(14, Math.min(bounds.width * 0.045, bounds.height * 0.032))) * (settings.fontScale / 100)}px`,
@@ -331,6 +334,7 @@ export function SongRequestOverlay({
} as CSSProperties
}
>
<ThemeEdges />
<section
className="song-current"
aria-label={translate('song.overlay.current_aria')}
+2 -1
View File
@@ -39,7 +39,8 @@ body {
position: relative;
min-height: 58px;
padding: 11px 14px;
border: 1px solid var(--theme-card-border, rgba(101, 226, 211, 0.28));
border: var(--component-decoration-line, 1px) solid
var(--theme-card-border, rgba(101, 226, 211, 0.28));
border-radius: 14px;
overflow: hidden;
display: flex;
+75 -53
View File
@@ -1,70 +1,101 @@
/* Shared upper/lower ornament for the three text-led Moonlit Water widgets.
The SVG remains monochrome and transparent; this gradient owns its tint so
future themes can reuse the mask with a different palette. */
It uses inline SVG so the control setting changes actual stroke width rather
than vertically stretching a thin bitmap/mask. */
:is(
.overlay.theme-moonlit-water,
.song-overlay.theme-moonlit-water,
.gift-menu-overlay.gift-menu-theme-moonlit-water
) {
--moonlit-edge-height: clamp(12px, 2.2vmin, 24px);
--moonlit-edge-height: clamp(22px, 3.6vmin, 44px);
--moonlit-edge-offset: clamp(4px, 0.65vmin, 8px);
--moonlit-edge-space: clamp(10px, 1.8vmin, 20px);
--moonlit-edge-space: clamp(16px, 2.5vmin, 30px);
position: relative;
isolation: isolate;
}
.theme-edge-set {
display: none;
}
:is(
.overlay.theme-moonlit-water,
.song-overlay.theme-moonlit-water,
.gift-menu-overlay.gift-menu-theme-moonlit-water
)::before,
:is(
.overlay.theme-moonlit-water,
.song-overlay.theme-moonlit-water,
.gift-menu-overlay.gift-menu-theme-moonlit-water
)::after {
content: '';
.overlay.theme-moonlit-water,
.song-overlay.theme-moonlit-water,
.gift-menu-overlay.gift-menu-theme-moonlit-water
)
> .theme-edge-set {
position: absolute;
z-index: 5;
left: clamp(8px, 1.4vmin, 18px);
right: clamp(8px, 1.4vmin, 18px);
height: var(--moonlit-edge-height);
inset: 0;
display: block;
overflow: hidden;
pointer-events: none;
background: linear-gradient(
90deg,
transparent 0%,
rgba(138, 174, 158, 0.7) 13%,
rgba(211, 188, 127, 0.82) 39%,
#ead79f 50%,
rgba(211, 188, 127, 0.82) 61%,
rgba(138, 174, 158, 0.7) 87%,
transparent 100%
);
-webkit-mask: var(--theme-pattern-edge) center / 100% 100% no-repeat;
mask: var(--theme-pattern-edge) center / 100% 100% no-repeat;
opacity: 0.72;
filter: drop-shadow(0 0 3px rgba(226, 204, 146, 0.2));
user-select: none;
}
.theme-edge-art {
position: absolute;
left: clamp(8px, 1.4vmin, 18px);
width: calc(100% - clamp(16px, 2.8vmin, 36px));
height: var(--moonlit-edge-height);
overflow: visible;
filter: drop-shadow(0 0 3px rgba(205, 172, 88, 0.28));
animation: moonlit-edge-breathe 7s ease-in-out infinite;
}
:is(
.overlay.theme-moonlit-water,
.song-overlay.theme-moonlit-water,
.gift-menu-overlay.gift-menu-theme-moonlit-water
)::before {
.theme-edge-top {
top: var(--moonlit-edge-offset);
}
:is(
.overlay.theme-moonlit-water,
.song-overlay.theme-moonlit-water,
.gift-menu-overlay.gift-menu-theme-moonlit-water
)::after {
.theme-edge-bottom {
bottom: var(--moonlit-edge-offset);
transform: rotate(180deg);
animation-delay: -3.5s;
}
.theme-edge-rule,
.theme-edge-water {
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: var(--component-decoration-edge-stroke, 3.2px);
}
.theme-edge-rule path,
.theme-edge-water path {
vector-effect: non-scaling-stroke;
}
.theme-edge-rule {
stroke: #d2b35f;
opacity: 0.94;
}
.theme-edge-water {
stroke: #5f978b;
opacity: 0.9;
}
.theme-edge-cloud {
fill: #568b80;
opacity: 0.76;
}
.theme-edge-leaves {
fill: #86aa92;
opacity: 0.88;
}
.theme-edge-lotus {
fill: #d4b45d;
opacity: 0.96;
filter: drop-shadow(0 0 2px rgba(221, 190, 104, 0.36));
}
.theme-edge-dots {
fill: #e4c978;
opacity: 0.94;
}
.overlay.theme-moonlit-water {
padding-block: calc(clamp(14px, 2.3vw, 34px) + var(--moonlit-edge-space));
}
@@ -80,24 +111,15 @@
@keyframes moonlit-edge-breathe {
0%,
100% {
opacity: 0.56;
opacity: 0.78;
}
50% {
opacity: 0.78;
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
:is(
.overlay.theme-moonlit-water,
.song-overlay.theme-moonlit-water,
.gift-menu-overlay.gift-menu-theme-moonlit-water
)::before,
:is(
.overlay.theme-moonlit-water,
.song-overlay.theme-moonlit-water,
.gift-menu-overlay.gift-menu-theme-moonlit-water
)::after {
.theme-edge-art {
animation: none;
}
}
+1
View File
@@ -37,6 +37,7 @@ export function themeCssVariables(
'--theme-pattern-cluster': `url("${theme.ornaments.patterns.cluster}")`,
'--theme-pattern-vine': `url("${theme.ornaments.patterns.vine}")`,
'--theme-pattern-edge': `url("${theme.ornaments.patterns.edge}")`,
'--theme-pattern-lotus': `url("${theme.ornaments.patterns.lotus}")`,
'--theme-motion-arrive': theme.motion.arrive,
'--theme-motion-sheen': theme.motion.sheen,
'--theme-motion-featured': theme.motion.featured,
+2 -1
View File
@@ -4,6 +4,7 @@ const divider = '/assets/floral-divider.svg'
const cluster = '/assets/floral-cluster.svg'
const vine = '/assets/floral-vine.svg'
const edge = '/assets/moonlit-edge.svg'
const lotus = '/assets/lotus-mark.svg'
/** The original ancient-style glass renderer, captured as the first theme. */
export const jadeScrollTheme: OverlayThemeDefinition = {
@@ -38,7 +39,7 @@ export const jadeScrollTheme: OverlayThemeDefinition = {
'star',
'floret',
],
patterns: { divider, cluster, vine, edge },
patterns: { divider, cluster, vine, edge, lotus },
},
motion: {
arrive: 'arrive',
+2 -1
View File
@@ -4,6 +4,7 @@ const divider = '/assets/floral-divider.svg'
const cluster = '/assets/floral-cluster.svg'
const vine = '/assets/floral-vine.svg'
const edge = '/assets/moonlit-edge.svg'
const lotus = '/assets/lotus-mark.svg'
/**
* A deliberately sparse, text-led theme inspired by moonlight on still water.
@@ -31,7 +32,7 @@ export const moonlitWaterTheme: OverlayThemeDefinition = {
ornaments: {
variantCount: 4,
particles: ['star', 'floret', 'star', 'floret', 'star', 'star'],
patterns: { divider, cluster, vine, edge },
patterns: { divider, cluster, vine, edge, lotus },
},
motion: {
arrive: 'moonlit-line-arrive',
+1
View File
@@ -35,6 +35,7 @@ export type OverlayThemeDefinition = {
cluster: string
vine: string
edge: string
lotus: string
}
}
motion: {
+6
View File
@@ -15,6 +15,7 @@ export type OverlaySettings = {
viewerColor: string | null
danmakuColor: string | null
fontScale: number
decorationLineWeight: number
showDanmaku: boolean
showEnter: boolean
showGift: boolean
@@ -40,6 +41,7 @@ export const defaultOverlaySettings: OverlaySettings = {
viewerColor: null,
danmakuColor: null,
fontScale: 140,
decorationLineWeight: 160,
showDanmaku: true,
showEnter: true,
showGift: true,
@@ -66,6 +68,7 @@ export type SongRequestSettings = {
requesterColor: string | null
songTitleColor: string | null
fontScale: number
decorationLineWeight: number
scrollSpeedPixelsPerSecond: number
edgePauseSeconds: number
/** Zero keeps the corresponding business limit disabled. */
@@ -81,6 +84,7 @@ export const defaultSongRequestSettings: SongRequestSettings = {
requesterColor: null,
songTitleColor: null,
fontScale: 100,
decorationLineWeight: 160,
scrollSpeedPixelsPerSecond: 28,
edgePauseSeconds: 2,
maxQueueSize: 0,
@@ -169,6 +173,7 @@ export type GiftMenuSettings = {
pageIntervalMs: number
highlightDurationMs: number
fontScale: number
decorationLineWeight: number
motionIntensity: number
lowPerformanceMode: boolean
}
@@ -184,6 +189,7 @@ export const defaultGiftMenuSettings: GiftMenuSettings = {
pageIntervalMs: 6_000,
highlightDurationMs: 3_800,
fontScale: 100,
decorationLineWeight: 160,
motionIntensity: 78,
lowPerformanceMode: false,
}