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.
This commit is contained in:
2026-08-14 18:54:37 -03:00
parent 0bf215555a
commit 48f05bcf8a
24 changed files with 702 additions and 81 deletions
+216 -25
View File
@@ -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) {
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

+9
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

+214 -11
View File
@@ -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 {