feat: release meme protocol v1.1.0
Add one-click meme copying, complete viewer actions, fixed-footer pagination, protocol lore, and the Homo Reseticus download chooser. Install dedicated social and favicon assets, expand local demo data, and synchronize the changelog, documentation, package, and OpenAPI release metadata.
@@ -5,6 +5,7 @@ const VIEW_DWELL_MS = 1000;
|
||||
const VIEW_THRESHOLD = 0.9;
|
||||
const VIEW_DEDUPE_MS = 24 * 60 * 60 * 1000;
|
||||
const VIEW_DEDUPE_PREFIX = 'meme-viewed:';
|
||||
const NEXT_PAGE_LOADER_MIN_MS = 300;
|
||||
const SAFE_TYPES = new Set(['image/png', 'image/jpeg']);
|
||||
|
||||
const grid = document.querySelector('#meme-grid');
|
||||
@@ -15,17 +16,28 @@ const uploadForm = document.querySelector('#upload-form');
|
||||
const fileInput = document.querySelector('#meme-input');
|
||||
const fileLabel = document.querySelector('#file-label');
|
||||
const formStatus = document.querySelector('#form-status');
|
||||
const submitUpload = document.querySelector('#submit-upload');
|
||||
const addMemeButton = document.querySelector('#add-meme-button');
|
||||
const lightbox = document.querySelector('#lightbox');
|
||||
const lightboxImage = document.querySelector('#lightbox-image');
|
||||
const lightboxCopy = document.querySelector('#lightbox-copy');
|
||||
const lightboxDownload = document.querySelector('#lightbox-download');
|
||||
const lightboxShare = document.querySelector('#lightbox-share');
|
||||
const lightboxId = document.querySelector('#lightbox-id');
|
||||
const lightboxViewCount = document.querySelector('#lightbox-view-count');
|
||||
const lightboxDownloadCount = document.querySelector('#lightbox-download-count');
|
||||
const petModal = document.querySelector('#pet-modal');
|
||||
const loreModal = document.querySelector('#lore-modal');
|
||||
const loreText = document.querySelector('#lore-text');
|
||||
const loreCopy = document.querySelector('#lore-copy');
|
||||
const loreShare = document.querySelector('#lore-share');
|
||||
const shareModal = document.querySelector('#share-modal');
|
||||
const shareTitle = document.querySelector('#share-title');
|
||||
const shareLinkLabel = document.querySelector('#share-link-label');
|
||||
const shareLink = document.querySelector('#share-link');
|
||||
const shareStatus = document.querySelector('#share-status');
|
||||
const copyShareLinkButton = document.querySelector('#copy-share-link');
|
||||
const scrollIndicator = document.querySelector('#scroll-indicator');
|
||||
const actionStatus = document.querySelector('#action-status');
|
||||
|
||||
let currentPage = 0;
|
||||
let totalPages = 1;
|
||||
@@ -33,6 +45,8 @@ let isLoading = false;
|
||||
let latestValidationRun = 0;
|
||||
let activeShareTrigger = null;
|
||||
let shareStatusTimer = null;
|
||||
let actionStatusTimer = null;
|
||||
let loreContent = '';
|
||||
const viewedThisSession = new Set();
|
||||
const pendingViewTimers = new Map();
|
||||
const copiedFeedbackTimers = new WeakMap();
|
||||
@@ -45,10 +59,22 @@ document.querySelector('#close-upload').addEventListener('click', closeUpload);
|
||||
document.querySelector('#cancel-upload').addEventListener('click', closeUpload);
|
||||
document.querySelector('#close-lightbox').addEventListener('click', () => lightbox.close());
|
||||
document.querySelector('#close-share').addEventListener('click', () => shareModal.close());
|
||||
document.querySelector('#open-pet').addEventListener('click', () => petModal.showModal());
|
||||
document.querySelector('#close-pet').addEventListener('click', () => petModal.close());
|
||||
document.querySelector('#open-lore').addEventListener('click', openLoreDialog);
|
||||
document.querySelector('#close-lore').addEventListener('click', () => loreModal.close());
|
||||
copyShareLinkButton.addEventListener('click', copyActiveShareLink);
|
||||
lightboxShare.addEventListener('click', async () => {
|
||||
await openShareDialog(lightboxShare.dataset.shareId, lightboxShare);
|
||||
await openMemeShareDialog(lightboxShare.dataset.shareId, lightboxShare);
|
||||
});
|
||||
lightboxCopy.addEventListener('click', () => copyImage(lightboxCopy.dataset.imageUrl, lightboxCopy));
|
||||
loreCopy.addEventListener('click', copyLore);
|
||||
loreShare.addEventListener('click', () => openShareDialog({
|
||||
path: '/lore',
|
||||
title: 'SHARE_LORE',
|
||||
label: 'LORE_LINK',
|
||||
triggerButton: loreShare
|
||||
}));
|
||||
|
||||
fileInput.addEventListener('change', async () => {
|
||||
const validationRun = ++latestValidationRun;
|
||||
@@ -70,7 +96,7 @@ uploadForm.addEventListener('submit', async (event) => {
|
||||
return;
|
||||
}
|
||||
|
||||
submitUpload.disabled = true;
|
||||
addMemeButton.disabled = true;
|
||||
formStatus.textContent = 'UPLOADING...';
|
||||
|
||||
try {
|
||||
@@ -90,14 +116,20 @@ uploadForm.addEventListener('submit', async (event) => {
|
||||
} catch (error) {
|
||||
formStatus.textContent = error.message.toUpperCase();
|
||||
} finally {
|
||||
submitUpload.disabled = false;
|
||||
addMemeButton.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
grid.addEventListener('click', async (event) => {
|
||||
const copyButton = event.target.closest('[data-copy-image-url]');
|
||||
if (copyButton) {
|
||||
await copyImage(copyButton.dataset.copyImageUrl, copyButton);
|
||||
return;
|
||||
}
|
||||
|
||||
const shareButton = event.target.closest('[data-share-id]');
|
||||
if (shareButton) {
|
||||
await openShareDialog(shareButton.dataset.shareId, shareButton);
|
||||
await openMemeShareDialog(shareButton.dataset.shareId, shareButton);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -108,7 +140,9 @@ grid.addEventListener('click', async (event) => {
|
||||
id: viewButton.dataset.viewId,
|
||||
url: viewButton.dataset.viewUrl,
|
||||
downloadUrl: viewButton.dataset.downloadUrl,
|
||||
alt: card.querySelector('img').alt
|
||||
alt: card.querySelector('img').alt,
|
||||
viewCount: Number.parseInt(viewButton.dataset.viewCount || '0', 10),
|
||||
downloadCount: Number.parseInt(viewButton.dataset.downloadCount || '0', 10)
|
||||
});
|
||||
});
|
||||
|
||||
@@ -128,7 +162,7 @@ window.addEventListener('scroll', updateScrollIndicator, { passive: true });
|
||||
window.addEventListener('resize', updateScrollIndicator);
|
||||
refreshStatus();
|
||||
window.setInterval(refreshStatus, 5000);
|
||||
await openSharedMemeFromUrl();
|
||||
await openSharedContentFromUrl();
|
||||
await loadMemes(1, { reset: true });
|
||||
observer.observe(feedLoader);
|
||||
|
||||
@@ -140,6 +174,7 @@ async function loadNextPage() {
|
||||
async function loadMemes(page, options = {}) {
|
||||
if (isLoading) return;
|
||||
isLoading = true;
|
||||
const loaderStartedAt = performance.now();
|
||||
setLoader('FETCHING_NEXT_BLOCK...', true);
|
||||
|
||||
try {
|
||||
@@ -150,6 +185,10 @@ async function loadMemes(page, options = {}) {
|
||||
currentPage = payload.page;
|
||||
totalPages = payload.totalPages;
|
||||
renderMemes(payload.memes, { append: !options.reset });
|
||||
if (page > 1) {
|
||||
const remainingLoaderTime = NEXT_PAGE_LOADER_MIN_MS - (performance.now() - loaderStartedAt);
|
||||
if (remainingLoaderTime > 0) await delay(remainingLoaderTime);
|
||||
}
|
||||
setLoader(currentPage < totalPages ? 'SCROLL_FOR_NEXT_BLOCK' : 'STREAM_SYNCHRONIZED', false);
|
||||
updateScrollIndicator();
|
||||
} catch {
|
||||
@@ -161,6 +200,10 @@ async function loadMemes(page, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function delay(milliseconds) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
function renderMemes(memes, options = {}) {
|
||||
if (!options.append && memes.length === 0) {
|
||||
unobserveViewedImages();
|
||||
@@ -185,13 +228,14 @@ function renderMemes(memes, options = {}) {
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<div class="stats">
|
||||
<span class="card-stat" data-counter-id="${meme.id}" data-counter-kind="view"><span class="material-symbols-outlined" aria-hidden="true">visibility</span>${formatCount(meme.viewCount)}</span>
|
||||
<span class="card-stat" data-counter-id="${meme.id}" data-counter-kind="download"><span class="material-symbols-outlined" aria-hidden="true">download</span>${formatCount(meme.downloadCount)}</span>
|
||||
<span class="card-stat" data-counter-id="${meme.id}" data-counter-kind="view"><span class="material-symbols-outlined" aria-hidden="true">visibility</span><span data-count>${formatCount(meme.viewCount)}</span></span>
|
||||
<span class="card-stat" data-counter-id="${meme.id}" data-counter-kind="download"><span class="material-symbols-outlined" aria-hidden="true">download</span><span data-count>${formatCount(meme.downloadCount)}</span></span>
|
||||
</div>
|
||||
<div class="action-group">
|
||||
<button class="text-action icon-only-action" type="button" data-copy-image-url="${meme.url}" aria-label="Copy meme image" title="Copy meme image"><span class="material-symbols-outlined" aria-hidden="true">content_copy</span></button>
|
||||
<a class="text-action icon-only-action" href="${meme.downloadUrl}" aria-label="Download meme" title="Download meme"><span class="material-symbols-outlined" aria-hidden="true">download</span></a>
|
||||
<button class="text-action icon-only-action" type="button" data-share-id="${meme.id}" aria-label="Share meme" title="Share meme"><span class="material-symbols-outlined" aria-hidden="true">share</span></button>
|
||||
<button class="text-action icon-only-action" type="button" data-view-id="${meme.id}" data-view-url="${meme.url}" data-download-url="${meme.downloadUrl}" aria-label="View full meme" title="View full meme"><span class="material-symbols-outlined" aria-hidden="true">visibility</span></button>
|
||||
<button class="text-action icon-only-action" type="button" data-view-id="${meme.id}" data-view-url="${meme.url}" data-download-url="${meme.downloadUrl}" data-view-count="${meme.viewCount}" data-download-count="${meme.downloadCount}" aria-label="View full meme" title="View full meme"><span class="material-symbols-outlined" aria-hidden="true">visibility</span></button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -208,7 +252,12 @@ function renderMemes(memes, options = {}) {
|
||||
observeViewedImages(cards);
|
||||
}
|
||||
|
||||
async function openSharedMemeFromUrl() {
|
||||
async function openSharedContentFromUrl() {
|
||||
if (window.location.pathname === '/lore' || window.location.pathname === '/lore/') {
|
||||
await openLoreDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
const match = window.location.pathname.match(/^\/meme\/([a-f0-9]{64})\/?$/);
|
||||
if (!match) return;
|
||||
|
||||
@@ -225,20 +274,39 @@ async function openSharedMemeFromUrl() {
|
||||
}
|
||||
}
|
||||
|
||||
async function openLightbox({ id, url, downloadUrl, alt }) {
|
||||
async function openLightbox({ id, url, downloadUrl, alt, viewCount = 0, downloadCount = 0 }) {
|
||||
lightboxImage.src = url;
|
||||
lightboxImage.alt = alt;
|
||||
lightboxCopy.dataset.imageUrl = url;
|
||||
lightboxDownload.href = downloadUrl;
|
||||
lightboxShare.dataset.shareId = id;
|
||||
lightboxId.textContent = `ID: ${shortId(id)}`;
|
||||
lightboxViewCount.dataset.counterId = id;
|
||||
lightboxViewCount.dataset.counterKind = 'view';
|
||||
lightboxViewCount.querySelector('[data-count]').textContent = formatCount(viewCount);
|
||||
lightboxDownloadCount.dataset.counterId = id;
|
||||
lightboxDownloadCount.dataset.counterKind = 'download';
|
||||
lightboxDownloadCount.querySelector('[data-count]').textContent = formatCount(downloadCount);
|
||||
lightboxId.textContent = `ID: 0x${id.toUpperCase()}`;
|
||||
setActionStatus('', lightboxCopy);
|
||||
if (!lightbox.open) lightbox.showModal();
|
||||
await recordViewOnce(id);
|
||||
}
|
||||
|
||||
async function openShareDialog(id, triggerButton = null) {
|
||||
async function openMemeShareDialog(id, triggerButton = null) {
|
||||
if (!isMemeId(id)) return;
|
||||
return openShareDialog({
|
||||
path: `/meme/${id}`,
|
||||
title: 'SHARE_MEME',
|
||||
label: 'MEME_LINK',
|
||||
triggerButton
|
||||
});
|
||||
}
|
||||
|
||||
async function openShareDialog({ path, title, label, triggerButton = null }) {
|
||||
activeShareTrigger = triggerButton;
|
||||
shareLink.value = new URL(`/meme/${id}`, window.location.origin).href;
|
||||
shareTitle.textContent = title;
|
||||
shareLinkLabel.textContent = label;
|
||||
shareLink.value = new URL(path, window.location.origin).href;
|
||||
window.clearTimeout(shareStatusTimer);
|
||||
shareStatusTimer = null;
|
||||
shareStatus.textContent = '';
|
||||
@@ -252,8 +320,8 @@ async function copyActiveShareLink() {
|
||||
try {
|
||||
await copyText(shareLink.value);
|
||||
shareStatus.textContent = 'LINK_COPIED_TO_CLIPBOARD';
|
||||
showCopiedFeedback(copyShareLinkButton);
|
||||
if (activeShareTrigger) showCopiedFeedback(activeShareTrigger);
|
||||
showCopiedFeedback(copyShareLinkButton, 'Share link copied');
|
||||
if (activeShareTrigger) showCopiedFeedback(activeShareTrigger, 'Share link copied');
|
||||
window.clearTimeout(shareStatusTimer);
|
||||
shareStatusTimer = window.setTimeout(() => {
|
||||
shareStatus.textContent = '';
|
||||
@@ -268,7 +336,104 @@ async function copyActiveShareLink() {
|
||||
}
|
||||
}
|
||||
|
||||
function showCopiedFeedback(button) {
|
||||
async function openLoreDialog() {
|
||||
setActionStatus('', loreCopy);
|
||||
if (!loreModal.open) loreModal.showModal();
|
||||
if (loreContent) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/assets/meme-protocol-lore.txt');
|
||||
if (!response.ok) throw new Error('Lore unavailable.');
|
||||
loreContent = await response.text();
|
||||
loreText.textContent = loreContent;
|
||||
} catch {
|
||||
loreText.textContent = 'LORE_TRANSMISSION_UNAVAILABLE';
|
||||
setActionStatus('LORE_TRANSMISSION_UNAVAILABLE', loreCopy);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyLore() {
|
||||
if (!loreContent) await openLoreDialog();
|
||||
if (!loreContent) return;
|
||||
|
||||
loreCopy.disabled = true;
|
||||
try {
|
||||
await copyText(loreContent);
|
||||
showCopiedFeedback(loreCopy, 'Lore copied');
|
||||
setActionStatus('LORE_COPIED_TO_CLIPBOARD', loreCopy);
|
||||
} catch {
|
||||
setActionStatus('LORE_COPY_UNAVAILABLE — USE_DOWNLOAD', loreCopy);
|
||||
} finally {
|
||||
loreCopy.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyImage(url, button) {
|
||||
if (!url || !button) return;
|
||||
button.disabled = true;
|
||||
setActionStatus('', button);
|
||||
|
||||
try {
|
||||
if (!navigator.clipboard?.write || typeof ClipboardItem === 'undefined') {
|
||||
throw new Error('Image clipboard unavailable.');
|
||||
}
|
||||
const png = fetch(url)
|
||||
.then((response) => {
|
||||
if (!response.ok) throw new Error('Image fetch failed.');
|
||||
return response.blob();
|
||||
})
|
||||
.then(convertImageBlobToPng);
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': png })]);
|
||||
showCopiedFeedback(button, 'Meme image copied');
|
||||
setActionStatus('MEME_IMAGE_COPIED_TO_CLIPBOARD', button);
|
||||
} catch {
|
||||
setActionStatus('IMAGE_COPY_UNAVAILABLE — USE_DOWNLOAD', button);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function convertImageBlobToPng(blob) {
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) throw new Error('Canvas unavailable.');
|
||||
|
||||
let image;
|
||||
let cleanup = () => {};
|
||||
if ('createImageBitmap' in window) {
|
||||
image = await createImageBitmap(blob);
|
||||
cleanup = () => image.close();
|
||||
} else {
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
image = await loadImage(objectUrl);
|
||||
cleanup = () => URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
|
||||
try {
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
context.drawImage(image, 0, 0);
|
||||
return await new Promise((resolve, reject) => {
|
||||
canvas.toBlob((png) => {
|
||||
if (png) resolve(png);
|
||||
else reject(new Error('PNG conversion failed.'));
|
||||
}, 'image/png');
|
||||
});
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
function loadImage(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error('Image decode failed.'));
|
||||
image.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
function showCopiedFeedback(button, copiedLabel = 'Copied') {
|
||||
const icon = button.querySelector('.material-symbols-outlined');
|
||||
const label = button.querySelector('[data-copy-label]');
|
||||
if (!icon) return;
|
||||
@@ -280,8 +445,8 @@ function showCopiedFeedback(button) {
|
||||
|
||||
icon.textContent = 'check';
|
||||
if (label) label.textContent = 'COPIED';
|
||||
if (button.dataset.defaultAriaLabel) button.setAttribute('aria-label', 'Share link copied');
|
||||
if (button.dataset.defaultTitle) button.setAttribute('title', 'Share link copied');
|
||||
if (button.dataset.defaultAriaLabel) button.setAttribute('aria-label', copiedLabel);
|
||||
if (button.dataset.defaultTitle) button.setAttribute('title', copiedLabel);
|
||||
button.classList.add('is-copied');
|
||||
|
||||
window.clearTimeout(copiedFeedbackTimers.get(button));
|
||||
@@ -308,9 +473,29 @@ async function copyText(value) {
|
||||
return;
|
||||
}
|
||||
|
||||
shareLink.focus();
|
||||
shareLink.select();
|
||||
if (!document.execCommand('copy')) throw new Error('Clipboard unavailable.');
|
||||
const fallback = document.createElement('textarea');
|
||||
fallback.value = value;
|
||||
fallback.setAttribute('readonly', '');
|
||||
fallback.className = 'clipboard-fallback';
|
||||
document.body.append(fallback);
|
||||
fallback.select();
|
||||
const copied = document.execCommand('copy');
|
||||
fallback.remove();
|
||||
if (!copied) throw new Error('Clipboard unavailable.');
|
||||
}
|
||||
|
||||
function setActionStatus(message, triggerButton = null) {
|
||||
window.clearTimeout(actionStatusTimer);
|
||||
for (const status of document.querySelectorAll('[data-action-status], #action-status')) {
|
||||
status.textContent = '';
|
||||
}
|
||||
const target = triggerButton?.closest('dialog')?.querySelector('[data-action-status]') || actionStatus;
|
||||
target.textContent = message;
|
||||
if (!message) return;
|
||||
actionStatusTimer = window.setTimeout(() => {
|
||||
target.textContent = '';
|
||||
actionStatusTimer = null;
|
||||
}, 3200);
|
||||
}
|
||||
|
||||
async function validateClientFile(file) {
|
||||
@@ -464,13 +649,19 @@ function connectLiveCounters() {
|
||||
|
||||
function updateCounters(meme) {
|
||||
for (const element of document.querySelectorAll(`[data-counter-id="${meme.id}"]`)) {
|
||||
const count = element.querySelector('[data-count]');
|
||||
if (!count) continue;
|
||||
if (element.dataset.counterKind === 'view') {
|
||||
element.lastChild.textContent = formatCount(meme.viewCount);
|
||||
count.textContent = formatCount(meme.viewCount);
|
||||
}
|
||||
if (element.dataset.counterKind === 'download') {
|
||||
element.lastChild.textContent = formatCount(meme.downloadCount);
|
||||
count.textContent = formatCount(meme.downloadCount);
|
||||
}
|
||||
}
|
||||
for (const button of document.querySelectorAll(`[data-view-id="${meme.id}"]`)) {
|
||||
button.dataset.viewCount = String(meme.viewCount);
|
||||
button.dataset.downloadCount = String(meme.downloadCount);
|
||||
}
|
||||
}
|
||||
|
||||
function setLoader(label, spinning) {
|
||||
|
||||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 4.1 MiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 193 KiB |
@@ -0,0 +1,9 @@
|
||||
THE MEME PROTOCOL // LORE PLACEHOLDER
|
||||
|
||||
The stream remembers what the timeline forgets.
|
||||
|
||||
Every meme enters as a signal, sheds its metadata, and waits for consensus.
|
||||
The worthy survive as immutable blocks of shared context. The rest dissolve
|
||||
back into the noise.
|
||||
|
||||
This is placeholder lore. A future transmission will replace it.
|
||||
|
After Width: | Height: | Size: 1.6 MiB |
@@ -18,6 +18,7 @@
|
||||
--error: #ffb4ab;
|
||||
--gutter: 16px;
|
||||
--desktop-margin: 32px;
|
||||
--footer-height: 44px;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -29,6 +30,10 @@ body {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-padding-bottom: calc(var(--footer-height) + 24px);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--void);
|
||||
@@ -148,7 +153,7 @@ a {
|
||||
.shell {
|
||||
width: min(100%, 1440px);
|
||||
margin: 0 auto;
|
||||
padding: 96px var(--desktop-margin) 48px;
|
||||
padding: 96px var(--desktop-margin) calc(var(--footer-height) + 16px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.status-line {
|
||||
@@ -162,6 +167,33 @@ a {
|
||||
border-bottom: 1px solid var(--outline-variant);
|
||||
}
|
||||
|
||||
.protocol-menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.protocol-menu a,
|
||||
.protocol-menu button {
|
||||
appearance: none;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.protocol-menu a:hover,
|
||||
.protocol-menu a:focus-visible,
|
||||
.protocol-menu button:hover,
|
||||
.protocol-menu button:focus-visible {
|
||||
color: var(--primary-dim);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.live {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -379,14 +411,19 @@ a {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.text-action:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.feed-loader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-height: 104px;
|
||||
margin-top: 24px;
|
||||
gap: 12px;
|
||||
min-height: 64px;
|
||||
margin-top: 16px;
|
||||
color: var(--outline);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
@@ -395,24 +432,31 @@ a {
|
||||
}
|
||||
|
||||
.loader-ring {
|
||||
display: none;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 2px solid transparent;
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 9999px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.feed-loader.is-spinning .loader-ring {
|
||||
display: block;
|
||||
animation: spin 850ms linear infinite;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.repo-footer {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
inset: auto 0 0;
|
||||
display: flex;
|
||||
min-height: calc(var(--footer-height) + env(safe-area-inset-bottom));
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: -8px;
|
||||
padding-bottom: 8px;
|
||||
padding: 0 16px env(safe-area-inset-bottom);
|
||||
border-top: 1px solid var(--outline-variant);
|
||||
background: var(--void);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.repo-footer a {
|
||||
@@ -535,6 +579,10 @@ dialog::backdrop {
|
||||
width: min(640px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.pet-panel {
|
||||
width: min(580px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.modal-header,
|
||||
.lightbox-toolbar {
|
||||
display: flex;
|
||||
@@ -546,6 +594,15 @@ dialog::backdrop {
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.lightbox-toolbar > span {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.lightbox-toolbar .icon-action {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-family: Geist, Inter, system-ui, sans-serif;
|
||||
@@ -642,6 +699,88 @@ dialog::backdrop {
|
||||
color: var(--primary-dim);
|
||||
}
|
||||
|
||||
.pet-downloads {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.pet-prompt {
|
||||
margin: 0 0 4px;
|
||||
color: var(--outline);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.pet-footnote {
|
||||
margin: 4px 0 0;
|
||||
color: var(--outline);
|
||||
font-size: 9px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.pet-footnote a {
|
||||
color: var(--muted);
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.pet-footnote a:hover,
|
||||
.pet-footnote a:focus-visible {
|
||||
color: var(--primary-dim);
|
||||
}
|
||||
|
||||
.pet-download {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 72px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--outline-variant);
|
||||
background: var(--surface-lowest);
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.pet-download:hover,
|
||||
.pet-download:focus-visible {
|
||||
border-color: var(--primary-dim);
|
||||
background: var(--surface-high);
|
||||
color: var(--primary-dim);
|
||||
}
|
||||
|
||||
.pet-download > span:first-child {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pet-download strong,
|
||||
.pet-download small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pet-download strong {
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.pet-download small {
|
||||
color: var(--outline);
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.pet-download > .material-symbols-outlined {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@@ -697,11 +836,16 @@ dialog::backdrop {
|
||||
box-shadow: 0 0 24px rgb(0 255 65 / 14%);
|
||||
}
|
||||
|
||||
.lightbox[open] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.lightbox img {
|
||||
display: block;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
max-height: calc(100vh - 144px);
|
||||
max-height: calc(100vh - 176px);
|
||||
margin: 0 auto;
|
||||
object-fit: contain;
|
||||
background: #000;
|
||||
@@ -709,9 +853,68 @@ dialog::backdrop {
|
||||
|
||||
.lightbox-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin: 12px 12px 4px;
|
||||
}
|
||||
|
||||
.lorebox {
|
||||
width: min(760px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.lore-text {
|
||||
min-height: 260px;
|
||||
max-height: calc(100vh - 176px);
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
padding: 24px;
|
||||
background: #080808;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.lore-actions {
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin: 12px;
|
||||
}
|
||||
|
||||
.viewer-status {
|
||||
min-height: 16px;
|
||||
margin: 0 12px 8px;
|
||||
color: var(--primary-dim);
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.action-toast {
|
||||
position: fixed;
|
||||
z-index: 19;
|
||||
right: 16px;
|
||||
bottom: calc(var(--footer-height) + 12px + env(safe-area-inset-bottom));
|
||||
max-width: calc(100vw - 32px);
|
||||
margin: 0;
|
||||
padding: 6px 8px;
|
||||
background: var(--surface);
|
||||
color: var(--primary-dim);
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.action-toast:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.clipboard-fallback {
|
||||
position: fixed;
|
||||
inset: auto auto 0 -9999px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
|
||||