fixed blinking

This commit is contained in:
2026-05-16 20:01:48 +02:00
parent ed0edc4c99
commit 23c62fb1e6
4 changed files with 80 additions and 45 deletions
+2 -12
View File
@@ -8,7 +8,7 @@ import { search, searchKeymap } from '@codemirror/search';
import { closeBrackets } from '@codemirror/autocomplete'; import { closeBrackets } from '@codemirror/autocomplete';
import { getPost, savePost, deletePost, getAssets, uploadAsset, ApiError } from '../../../lib/api'; import { getPost, savePost, deletePost, getAssets, uploadAsset, ApiError } from '../../../lib/api';
import type { Asset } from '../../../lib/types'; import type { Asset } from '../../../lib/types';
import { confirmDialog } from '../../../lib/confirm'; import { confirmDialog, notify } from '../../../lib/confirm';
const AssetManager = lazy(() => import('./AssetManager')); const AssetManager = lazy(() => import('./AssetManager'));
@@ -90,7 +90,6 @@ export default function Editor({ editSlug }: Props) {
const [tagsInput, setTagsInput] = useState(''); const [tagsInput, setTagsInput] = useState('');
const [draft, setDraft] = useState(false); const [draft, setDraft] = useState(false);
const [originalSlug, setOriginalSlug] = useState(editSlug || ''); const [originalSlug, setOriginalSlug] = useState(editSlug || '');
const [alert, setAlert] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [showPreview, setShowPreview] = useState(false); const [showPreview, setShowPreview] = useState(false);
const [mobileView, setMobileView] = useState<'edit' | 'preview'>('edit'); const [mobileView, setMobileView] = useState<'edit' | 'preview'>('edit');
@@ -111,9 +110,7 @@ export default function Editor({ editSlug }: Props) {
} }
function showAlertMsg(msg: string, type: 'success' | 'error') { function showAlertMsg(msg: string, type: 'success' | 'error') {
setAlert({ msg, type }); notify(msg, type);
window.scrollTo({ top: 0, behavior: 'smooth' });
setTimeout(() => setAlert(null), 5000);
} }
const updatePreview = useCallback(async () => { const updatePreview = useCallback(async () => {
@@ -410,13 +407,6 @@ export default function Editor({ editSlug }: Props) {
return ( return (
<> <>
{alert && (
<div className={`p-4 rounded-lg mb-6 text-sm font-semibold text-center backdrop-blur-sm shadow-lg ${
alert.type === 'success' ? 'bg-green/15 border border-green/30' : 'bg-red/15 border border-red/30'
}`} style={{ color: 'var(--text)' }}>
{alert.msg}
</div>
)}
{/* Actions bar */} {/* Actions bar */}
<div className="flex flex-wrap gap-4 mb-6"> <div className="flex flex-wrap gap-4 mb-6">
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { getConfig, updateConfig, ApiError } from '../../../lib/api'; import { getConfig, updateConfig, ApiError } from '../../../lib/api';
import { notify } from '../../../lib/confirm';
import type { SiteConfig, ContactLink } from '../../../lib/types'; import type { SiteConfig, ContactLink } from '../../../lib/types';
const CONTACT_KINDS: { value: string; label: string; placeholder: string }[] = [ const CONTACT_KINDS: { value: string; label: string; placeholder: string }[] = [
@@ -13,8 +14,6 @@ const CONTACT_KINDS: { value: string; label: string; placeholder: string }[] = [
export default function Settings() { export default function Settings() {
const [config, setConfig] = useState<Partial<SiteConfig>>({}); const [config, setConfig] = useState<Partial<SiteConfig>>({});
const [alert, setAlert] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
useEffect(() => { useEffect(() => {
getConfig() getConfig()
.then(setConfig) .then(setConfig)
@@ -22,8 +21,7 @@ export default function Settings() {
}, []); }, []);
function showAlert(msg: string, type: 'success' | 'error') { function showAlert(msg: string, type: 'success' | 'error') {
setAlert({ msg, type }); notify(msg, type);
setTimeout(() => setAlert(null), 5000);
} }
function update<K extends keyof SiteConfig>(key: K, value: SiteConfig[K]) { function update<K extends keyof SiteConfig>(key: K, value: SiteConfig[K]) {
@@ -62,19 +60,6 @@ export default function Settings() {
return ( return (
<form onSubmit={handleSubmit} className="space-y-10"> <form onSubmit={handleSubmit} className="space-y-10">
{alert && (
<div
className={`p-4 text-sm font-display italic text-center border ${
alert.type === 'success'
? 'bg-[var(--green)]/15 text-[var(--green)] border-[var(--green)]/30'
: 'bg-[var(--red)]/15 text-[var(--red)] border-[var(--red)]/30'
}`}
style={{ borderRadius: 1 }}
>
{alert.msg}
</div>
)}
<section className="space-y-6"> <section className="space-y-6">
<h2 className="font-display italic text-2xl text-[var(--text)] border-l-2 border-[var(--mauve)] pl-4">Identity</h2> <h2 className="font-display italic text-2xl text-[var(--text)] border-l-2 border-[var(--mauve)] pl-4">Identity</h2>
<div className="grid md:grid-cols-2 gap-6"> <div className="grid md:grid-cols-2 gap-6">
+11 -4
View File
@@ -117,15 +117,22 @@ export function confirmDialog(opts: ConfirmOptions): Promise<boolean> {
}); });
} }
/** Transient bottom-center toast. Replaces window.alert for failures. */ /** Transient hovering toast at the top of the viewport. Replaces
* window.alert and the old inline save banners. */
export function notify(message: string, tone: 'error' | 'success' = 'error') { export function notify(message: string, tone: 'error' | 'success' = 'error') {
document.querySelector('.toast[data-notify]')?.remove(); document.querySelector('.toast[data-notify]')?.remove();
const el = document.createElement('div'); const el = document.createElement('div');
el.className = `toast${tone === 'error' ? ' toast--error' : ''}`; el.className = `toast toast--${tone}`;
el.dataset.notify = ''; el.dataset.notify = '';
el.setAttribute('role', tone === 'error' ? 'alert' : 'status'); el.setAttribute('role', tone === 'error' ? 'alert' : 'status');
el.textContent = message; el.textContent = message;
el.addEventListener('click', () => el.remove());
const dismiss = () => {
if (el.classList.contains('toast--out')) return;
el.classList.add('toast--out');
window.setTimeout(() => el.remove(), 220);
};
el.addEventListener('click', dismiss);
document.body.appendChild(el); document.body.appendChild(el);
window.setTimeout(() => el.remove(), 4500); window.setTimeout(dismiss, 4500);
} }
+65 -12
View File
@@ -1360,7 +1360,7 @@ select.topbar-control.theme-select {
/* Toast */ /* Toast */
.toast { .toast {
position: fixed; position: fixed;
bottom: 1.5rem; top: 1.25rem;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
background: var(--mantle); background: var(--mantle);
@@ -1368,17 +1368,34 @@ select.topbar-control.theme-select {
color: var(--rosewater); color: var(--rosewater);
padding: 0.65rem 1.1rem; padding: 0.65rem 1.1rem;
border-radius: 1px; border-radius: 1px;
box-shadow: 0 12px 30px -10px rgba(0, 0, 0, 0.45); box-shadow: 0 14px 34px -12px rgba(0, 0, 0, 0.55);
font-family: var(--font-display); font-family: var(--font-display);
font-style: italic; font-style: italic;
font-size: 0.9rem; font-size: 0.9rem;
z-index: 200; z-index: 200;
animation: toast-in 0.2s ease; cursor: pointer;
animation: toast-in 0.22s cubic-bezier(0.2, 0.7, 0.2, 1);
} }
@keyframes toast-in { @keyframes toast-in {
from { opacity: 0; transform: translate(-50%, 8px); } from { opacity: 0; transform: translate(-50%, -10px); }
to { opacity: 1; transform: translate(-50%, 0); } to { opacity: 1; transform: translate(-50%, 0); }
} }
.toast--out {
animation: toast-out 0.2s ease forwards;
}
@keyframes toast-out {
from { opacity: 1; transform: translate(-50%, 0); }
to { opacity: 0; transform: translate(-50%, -10px); }
}
/* Success variant — parallels .toast--error. */
.toast--success {
border-left: 3px solid var(--green);
color: var(--rosewater);
}
.toast--success::before {
content: "✓ ";
color: var(--green);
}
/* Salon grid spans driven by --col-span custom prop (avoids Tailwind dynamic class issue). */ /* Salon grid spans driven by --col-span custom prop (avoids Tailwind dynamic class issue). */
@media (min-width: 768px) { @media (min-width: 768px) {
@@ -2023,7 +2040,7 @@ html.cybersigil body::after {
0 0 10px var(--sky), 0 0 10px var(--sky),
0 -3px 0 color-mix(in srgb, var(--mauve) 70%, transparent), 0 -3px 0 color-mix(in srgb, var(--mauve) 70%, transparent),
0 3px 0 color-mix(in srgb, var(--teal) 60%, transparent); 0 3px 0 color-mix(in srgb, var(--teal) 60%, transparent);
animation: cs-tear 8.5s steps(1, jump-none) infinite; animation: cs-tear 8.5s linear infinite;
} }
.cybersigil .cs-fx-tear::after { .cybersigil .cs-fx-tear::after {
content: ""; content: "";
@@ -2046,7 +2063,7 @@ html.cybersigil body::after {
-webkit-mask: var(--cs-corner) center / contain no-repeat; -webkit-mask: var(--cs-corner) center / contain no-repeat;
mask: var(--cs-corner) center / contain no-repeat; mask: var(--cs-corner) center / contain no-repeat;
filter: drop-shadow(0 0 5px color-mix(in srgb, var(--sky) 45%, transparent)); filter: drop-shadow(0 0 5px color-mix(in srgb, var(--sky) 45%, transparent));
animation: cs-flicker 7s steps(1, jump-none) infinite; animation: cs-flicker 7s linear infinite;
} }
.cybersigil .cs-fx-corner--tl { top: 0; left: 0; } .cybersigil .cs-fx-corner--tl { top: 0; left: 0; }
.cybersigil .cs-fx-corner--tr { top: 0; right: 0; transform: scaleX(-1); animation-delay: -1.7s; } .cybersigil .cs-fx-corner--tr { top: 0; right: 0; transform: scaleX(-1); animation-delay: -1.7s; }
@@ -2118,7 +2135,8 @@ html.cybersigil body::after {
/* Nameplate = the system handle. `> ` prompt + live block caret. */ /* Nameplate = the system handle. `> ` prompt + live block caret. */
.cybersigil .nameplate-title::before { .cybersigil .nameplate-title::before {
content: "> "; content: ">";
margin-right: 0.4em;
color: var(--sky); color: var(--sky);
-webkit-text-fill-color: var(--sky); -webkit-text-fill-color: var(--sky);
} }
@@ -2131,7 +2149,7 @@ html.cybersigil body::after {
vertical-align: -0.12em; vertical-align: -0.12em;
background: var(--mauve); background: var(--mauve);
box-shadow: 0 0 8px color-mix(in srgb, var(--mauve) 70%, transparent); box-shadow: 0 0 8px color-mix(in srgb, var(--mauve) 70%, transparent);
animation: cs-blink 1.05s steps(1, jump-none) infinite; animation: cs-blink 1.05s steps(2, jump-none) infinite;
} }
.cybersigil .nameplate-subtitle { .cybersigil .nameplate-subtitle {
font-family: var(--font-sans); font-family: var(--font-sans);
@@ -2535,7 +2553,7 @@ html.cybersigil body::after {
.cybersigil .back-link::after { .cybersigil .back-link::after {
content: "_"; content: "_";
margin-left: -0.1em; margin-left: -0.1em;
animation: cs-blink 1.05s steps(1, jump-none) infinite; animation: cs-blink 1.05s steps(2, jump-none) infinite;
} }
.cybersigil .back-link:hover, .cybersigil .back-link:hover,
.cybersigil .back-link:focus-visible { .cybersigil .back-link:focus-visible {
@@ -2569,7 +2587,7 @@ html.cybersigil body::after {
content: "_"; content: "_";
margin-left: 0.18em; margin-left: 0.18em;
opacity: 0.85; opacity: 0.85;
animation: cs-blink 1.05s steps(1, jump-none) infinite; animation: cs-blink 1.05s steps(2, jump-none) infinite;
} }
/* Icon-only / collapsed controls have no room for the `>` prompt + blink /* Icon-only / collapsed controls have no room for the `>` prompt + blink
* caret — they overflow the 2rem square on phones. Drop the pseudo when * caret — they overflow the 2rem square on phones. Drop the pseudo when
@@ -2845,7 +2863,7 @@ html.cybersigil body::after {
content: "_"; content: "_";
color: var(--mauve); color: var(--mauve);
font-family: var(--font-sans); font-family: var(--font-sans);
animation: cs-blink 1.05s steps(1, jump-none) infinite; animation: cs-blink 1.05s steps(2, jump-none) infinite;
} }
.cybersigil .search-result [class*="line-clamp"] { .cybersigil .search-result [class*="line-clamp"] {
font-family: var(--font-sans) !important; font-family: var(--font-sans) !important;
@@ -2916,7 +2934,7 @@ html.cybersigil body::after {
.cybersigil .asset-drop-title::after { .cybersigil .asset-drop-title::after {
content: "_"; content: "_";
color: var(--mauve); color: var(--mauve);
animation: cs-blink 1.05s steps(1, jump-none) infinite; animation: cs-blink 1.05s steps(2, jump-none) infinite;
} }
.cybersigil .asset-empty { .cybersigil .asset-empty {
@@ -3006,6 +3024,41 @@ html.cybersigil body::after {
text-shadow: -1px 0 0 var(--sky), 1px 0 0 var(--mauve); text-shadow: -1px 0 0 var(--sky), 1px 0 0 var(--mauve);
} }
/* Toast — a terminal status line printed at the top of the tube. */
.cybersigil .toast {
background: color-mix(in srgb, var(--crust) 92%, transparent);
border: 1px solid var(--sky);
border-radius: 0;
color: var(--sky);
font-family: var(--font-sans);
font-style: normal;
font-size: 0.74rem;
letter-spacing: 0.12em;
text-transform: uppercase;
box-shadow:
3px 3px 0 0 var(--mauve),
0 0 22px -6px color-mix(in srgb, var(--sky) 45%, transparent);
}
.cybersigil .toast--success {
border-color: var(--sky);
color: var(--sky);
}
.cybersigil .toast--success::before {
content: "> OK\00a0\00a0";
color: var(--sky);
}
.cybersigil .toast--error {
border-color: var(--red);
color: var(--red);
box-shadow:
3px 3px 0 0 var(--mauve),
0 0 22px -6px color-mix(in srgb, var(--red) 50%, transparent);
}
.cybersigil .toast--error::before {
content: "> ERR\00a0\00a0";
color: var(--red);
}
/* ─── Theme keyframes ─── */ /* ─── Theme keyframes ─── */
@keyframes cs-blink { @keyframes cs-blink {
0%, 49% { opacity: 1; } 0%, 49% { opacity: 1; }