Emoji autofill

This commit is contained in:
2026-10-21 14:28:58 -04:00
parent 8708c9b086
commit 2a2d976013
4 changed files with 116 additions and 2 deletions
+2
View File
@@ -33,6 +33,8 @@ Every successful button press also posts an automatic chat announcement with the
## Seasons and champions
Chat supports emoji autocomplete: type `:` followed by a name (for example `:sm`, `:heart`, or `:fire:`), then choose a suggestion with the arrow keys and Enter/Tab, or click/tap it. Escape dismisses suggestions. Selection inserts the actual Unicode emoji at the cursor without sending the message; press Enter again or Send to post. Unknown shortcodes remain unchanged. Suggestions are a built-in curated list, with no external service, and respect the message length limit.
Seasons begin at **00:00 UTC on the UTC date of each astronomical equinox or solstice**, not at the event's exact hour and not on fixed March/June/September/December dates. They are displayed as **Season I, Season II, Season III**, etc., including in the subtitle under **THE BUTTON**. Numbering begins at the earliest saved season (including existing archives on upgrade); its origin is persisted so numbering remains stable across restarts. Each astronomical boundary advances the number, including seasons missed during downtime. Date-based internal IDs and start/end dates remain unchanged. The built-in Meeus equinox/solstice calculation (with periodic and Delta-T corrections) supports 2000–2100 without network access or dependencies. The 2026 boundaries are March 20, June 21, September 23, and December 21; the December 2027 boundary is December 22. Calendar tests check these against USNO data. The far-future calendar is an astronomical estimate; extend/revalidate it before 2101.
At each boundary the server archives the old season and starts a fresh countdown anchored to that midnight. Seasonal points, press history, personal bests, achievements, and milestone progress reset. Accounts, password hashes, login sessions, mailing-list subscriptions, and chat are preserved. Previous press histories remain stored in the season archive; public archives expose only score summaries, not private per-player histories. Old milestone announcements remain visible in chat.
+107
View File
@@ -637,9 +637,116 @@ async function loadChat() {
}
}
const CHAT_EMOJIS = [
["smile", "😄"], ["smiley", "😃"], ["grin", "😁"], ["laughing", "😆"],
["joy", "😂"], ["rofl", "🤣"], ["wink", "😉"], ["blush", "😊"],
["heart_eyes", "😍"], ["thinking", "🤔"], ["sunglasses", "😎"], ["sweat_smile", "😅"],
["nervous", "😬"], ["eyes", "👀"], ["cry", "😢"], ["sob", "😭"],
["neutral", "😐"], ["surprised", "😮"], ["scream", "😱"], ["sleeping", "😴"],
["heart", "❤️"], ["green_heart", "💚"], ["blue_heart", "💙"], ["yellow_heart", "💛"],
["purple_heart", "💜"], ["black_heart", "🖤"], ["white_heart", "🤍"],
["thumbsup", "👍"], ["+1", "👍"], ["thumbsdown", "👎"], ["-1", "👎"],
["clap", "👏"], ["wave", "👋"], ["pray", "🙏"], ["muscle", "💪"],
["fire", "🔥"], ["tada", "🎉"], ["party", "🥳"], ["sparkles", "✨"],
["star", "⭐"], ["trophy", "🏆"], ["medal", "🏅"], ["rocket", "🚀"],
["hourglass", "⌛"], ["clock", "🕒"], ["alarm", "⏰"], ["stopwatch", "⏱️"],
["button", "🔘"], ["red_circle", "🔴"], ["blue_circle", "🔵"], ["green_circle", "🟢"],
["yellow_circle", "🟡"], ["white_circle", "⚪"], ["black_circle", "⚫"],
["100", "💯"], ["gg", "🤝"], ["skull", "💀"], ["coffee", "☕"],
];
let emojiMatches = [];
let emojiSelection = 0;
let emojiRange = null;
function closeEmojiSuggestions() {
emojiMatches = [];
emojiRange = null;
$("emoji-suggestions").hidden = true;
$("chat-input").setAttribute("aria-expanded", "false");
$("chat-input").removeAttribute("aria-activedescendant");
}
function renderEmojiSuggestions() {
const list = $("emoji-suggestions");
list.replaceChildren(...emojiMatches.map(([name, emoji], index) => {
const option = el("li", undefined, "emoji-option");
option.id = `emoji-option-${index}`;
option.dataset.index = String(index);
option.setAttribute("role", "option");
option.setAttribute("aria-selected", String(index === emojiSelection));
option.append(el("span", emoji, "emoji-glyph"), el("span", `:${name}:`));
return option;
}));
list.hidden = false;
$("chat-input").setAttribute("aria-expanded", "true");
$("chat-input").setAttribute("aria-activedescendant", `emoji-option-${emojiSelection}`);
}
function suggestEmoji() {
const input = $("chat-input");
if (input.selectionStart !== input.selectionEnd) return closeEmojiSuggestions();
const before = input.value.slice(0, input.selectionStart);
const match = before.match(/(?:^|\s):([a-z0-9_+\-]*):?$/i);
if (!match) return closeEmojiSuggestions();
const query = match[1].toLowerCase();
// A completed shortcode must match exactly; never turn an unknown code into another emoji.
const complete = before.endsWith(":") && match[0].trim() !== ":";
emojiMatches = CHAT_EMOJIS.filter(([name]) => complete ? name === query : name.startsWith(query)).slice(0, 6);
if (!emojiMatches.length) return closeEmojiSuggestions();
emojiSelection = 0;
emojiRange = { start: before.lastIndexOf(":", complete ? before.length - 2 : before.length - 1), end: input.selectionStart };
renderEmojiSuggestions();
}
function insertEmoji(index) {
if (!emojiRange || !emojiMatches[index]) return;
const input = $("chat-input");
const emoji = emojiMatches[index][1];
const { start, end } = emojiRange;
if (input.value.length - (end - start) + emoji.length > input.maxLength) {
$("chat-error").textContent = "This emoji would exceed the message length limit.";
closeEmojiSuggestions();
return;
}
input.setRangeText(emoji, start, end, "end");
$("chat-error").textContent = "";
closeEmojiSuggestions();
input.focus();
}
$("chat-input").addEventListener("input", (event) => {
if (!event.isComposing) suggestEmoji();
});
$("chat-input").addEventListener("compositionend", suggestEmoji);
$("chat-input").addEventListener("click", suggestEmoji);
$("chat-input").addEventListener("blur", closeEmojiSuggestions);
$("chat-input").addEventListener("keydown", (event) => {
if (event.isComposing || !emojiMatches.length) return;
if (["ArrowDown", "ArrowUp"].includes(event.key)) {
event.preventDefault();
emojiSelection = (emojiSelection + (event.key === "ArrowDown" ? 1 : -1) + emojiMatches.length) % emojiMatches.length;
renderEmojiSuggestions();
} else if (["Enter", "Tab"].includes(event.key)) {
event.preventDefault();
insertEmoji(emojiSelection);
} else if (event.key === "Escape") {
event.preventDefault();
closeEmojiSuggestions();
} else if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) {
closeEmojiSuggestions();
}
});
$("emoji-suggestions").addEventListener("pointerdown", (event) => {
const option = event.target.closest("[data-index]");
if (!option) return;
event.preventDefault(); // keep the caret and avoid blur before insertion
insertEmoji(Number(option.dataset.index));
});
$("chat-form").addEventListener("submit", async (event) => {
event.preventDefault();
const input = $("chat-input");
closeEmojiSuggestions();
$("chat-send").disabled = true;
$("chat-error").textContent = "";
try {
+3 -2
View File
@@ -219,11 +219,12 @@
<form id="chat-form" hidden>
<label for="chat-input">Add to the conversation</label>
<div class="chat-input-row">
<input id="chat-input" maxlength="200" placeholder="What's on your mind?" required autocomplete="off" aria-describedby="chat-help">
<input id="chat-input" maxlength="200" placeholder="What's on your mind? Try :smile:" required autocomplete="off" aria-describedby="chat-help" role="combobox" aria-autocomplete="list" aria-controls="emoji-suggestions" aria-expanded="false">
<button id="chat-send" type="submit">Send <span aria-hidden="true">&uarr;</span></button>
</div>
<ul id="emoji-suggestions" class="emoji-suggestions" role="listbox" aria-label="Emoji suggestions" hidden></ul>
</form>
<p id="chat-help">Be kind. 200 characters max &middot; 2 seconds between messages</p>
<p id="chat-help">Type : to find an emoji. Use &uarr;/&darr; to choose, Enter or Tab to insert, Esc to dismiss. 200 characters max &middot; 2 seconds between messages.</p>
<p id="chat-error" class="error" role="status"></p>
</div>
</section>
+4
View File
@@ -383,6 +383,10 @@ stripe-buy-button { display: block; max-width: 100%; margin: 1.5rem 0; }
#chat-messages p { margin: 0; padding: 0.7rem 0.85rem; border: 1px solid #45475a60; border-radius: 0 12px 12px 12px; background: var(--base); font-size: 0.9rem; line-height: 1.55; overflow-wrap: anywhere; }
.chat-composer { border-top: 1px solid var(--surface); padding: 1.25rem 1.5rem; background: #11111b35; }
#chat-form { display: block; }
.emoji-suggestions { list-style: none; margin: 0.5rem 0 0; padding: 0.3rem; border: 1px solid var(--surface); border-radius: 12px; background: var(--base); max-height: 280px; overflow-y: auto; }
.emoji-option { display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem 0.65rem; min-height: 44px; border-radius: 8px; color: var(--subtext); font-size: 0.8rem; cursor: pointer; overflow-wrap: anywhere; }
.emoji-option[aria-selected="true"], .emoji-option:hover { background: var(--surface); color: var(--lavender); }
.emoji-glyph { font-size: 1.25rem; flex-shrink: 0; }
#chat-form label { display: block; margin-bottom: 0.6rem; font-size: 0.75rem; font-weight: 600; color: var(--subtext); }
.chat-input-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem; background: var(--base); border: 1px solid var(--surface); border-radius: 12px; }
.chat-input-row:focus-within { border-color: var(--mauve); }