QUIRC (QUick IRC) — mobile-first, self-hosted IRC client. Vue 3 + Vite + Pinia. Punk-zine aesthetic. Zero dependencies beyond Vue ecosystem.
Status: Deployed to production. v0.4.0 — Deploy hardening, DB persistence, multi-origin CORS, image loading fix. Version: 0.4.0 | License: MIT (quirc.chat)
Production URLs:
- Frontend: https://quirc.chat (Netlify)
- IRC Server: wss://irc.quirc.chat (DO App Platform, Ergo IRC)
- CDN: quirc.sfo3.cdn.digitaloceanspaces.com (DO Spaces)
quirc/
├── package.json # vue 3.5, vue-router 4, pinia 3, dexie 4, @aws-sdk/client-s3, vite 6
├── vite.config.js # Vue plugin, @ → src/ alias
├── index.html # Entry HTML, Space Mono font, viewport-fit=cover, zoom locked
├── netlify.toml # Build config, /api/* → functions, SPA fallback
├── .env.example # All VITE_ + DO_SPACES_ env vars documented
├── .gitignore # node_modules, dist, .env*, !.env.example
├── LICENSE # MIT
├── README.md # Setup instructions
├── prd.md # Full PRD v0.3.0 (reference only)
├── quirc_app.jsx # React mockup (design reference only, not used)
│
├── public/
│ ├── favicon.svg # Pink "Q" pixel favicon (from logo pixel art)
│ ├── manifest.json # PWA manifest
│ └── noise.svg # feTurbulence noise texture
│
├── deploy/
│ ├── Dockerfile # Ergo IRC + mc backup client, custom entrypoint for DB persistence
│ ├── entrypoint.sh # DB backup/restore to DO Spaces, periodic backup, graceful shutdown
│ ├── ircd.yaml # Ergo config: WebSocket :8080, enforce-utf8, in-memory history
│ └── app.yaml # DO App Platform spec: basic-xxs, auto-deploy, Spaces backup envs
│
├── netlify/functions/
│ ├── unfurl.js # OpenGraph metadata proxy — SSRF-protected, dynamic CORS
│ └── upload-url.js # Presigned S3 upload URL — type allowlist, size limit, dynamic CORS
│
└── src/
├── main.js # Creates app, installs Pinia + Router, imports CSS
├── App.vue # Root shell: splash → main layout + all overlays + viewport tracking
│
├── router/
│ └── index.js # / → /channel/general, /channel/:name
│
├── styles/
│ ├── variables.css # Full --q-* design system (palette, type, spacing)
│ └── base.css # Reset, border-radius:0 !important, position:fixed body, scrollbars
│
├── stores/ # Pinia composition-style (setup function syntax)
│ ├── connection.js # Nick, server, SASL, MOTD, saved profiles. localStorage.
│ ├── channels.js # Channel list, active, topics, unread, mute, saved DMs. localStorage.
│ ├── messages.js # Per-channel message maps, reply target, reactions, auto-trim. Subtype support.
│ ├── users.js # Per-channel user tracking with op/voice/status, sorted computed
│ ├── settings.js # 16 settings across 4 categories, auto-persist via watch
│ └── ui.js # All overlay/drawer open states + toggle methods + WHOIS card state
│
├── irc/ # Core IRC protocol
│ ├── client.js # WebSocket client: CAP LS 302, SASL PLAIN, BATCH, chathistory, reconnect
│ ├── parser.js # IRCv3 message parser (tags, source, command, params)
│ ├── commands.js # Slash command parser (20+ commands) + COMMAND_HELP
│ ├── format.js # mIRC color/bold/italic stripper (wired into PRIVMSG handler)
│ └── caps.js # CAP constants (mostly handled in client.js now)
│
├── composables/
│ ├── useIRC.js # Main bridge: IRC events → stores. 20+ handlers. BATCH replay. System subtypes.
│ ├── useSearch.js # Debounced message search (300ms)
│ ├── useFileUpload.js # Presigned URL upload via XHR (progress events)
│ └── useNotifications.js # Desktop notifications via Web Notifications API
│
├── db/
│ └── index.js # Dexie schema (messages, channels, settings, unfurlCache)
│
├── utils/
│ ├── logoPixels.js # QUIRC pixel bitmap data + builder for logo components
│ ├── nickColor.js # Deterministic nick → color hash (10 colors)
│ ├── time.js # formatTime(date, use24h)
│ └── linkDetect.js # URL regex extraction
│
└── components/
├── SplashScreen.vue # 3-phase animation (logo→text→fade), dynamic server name
│
├── icons/ # SVG icons, square stroke caps, punk aesthetic
│ ├── IconHamburger.vue # Three lines (menu)
│ ├── IconSearch.vue # Magnifying glass
│ ├── IconClose.vue # X mark
│ ├── IconReply.vue # Curved reply arrow
│ ├── IconPlus.vue # Plus sign
│ ├── IconPaperclip.vue # File attach
│ ├── IconSend.vue # Send arrow
│ ├── IconSettings.vue # Gear
│ ├── IconChevron.vue # Chevron arrow
│ ├── IconInfo.vue # Info circle
│ ├── IconUsers.vue # People group
│ ├── IconList.vue # List lines
│ ├── IconGithub.vue # GitHub logo
│ ├── IconSun.vue # Sun (light theme)
│ ├── IconMoon.vue # Moon (dark theme)
│ └── index.js # Barrel export
│
├── logo/
│ ├── QuircMark.vue # Static SVG pixel logo (uses logoPixels)
│ └── SplashLogo.vue # Canvas animation: pixel rain, scanline, CRT
│
├── shared/
│ ├── NoiseOverlay.vue # Fixed noise texture at 3.5% opacity
│ ├── SlashCommandPalette.vue # Scrollable command autocomplete, shows on `/`
│ └── TypingIndicator.vue # Animated dots + nick list
│
├── layout/
│ ├── TopBar.vue # Logo + channel + mode badges + search + user count
│ ├── TopicBanner.vue # Full-width topic bar below topbar, click opens ChannelInfo
│ ├── InputBar.vue # Reply bar + nick display + input + send. History + tab complete + typing + upload.
│ ├── ChannelDrawer.vue # Left slide: collapsible sections, close buttons, join (+), leave (ctx), settings
│ └── UsersDrawer.vue # Right slide: users, click → WHOIS/DM/Kick actions
│
├── messages/
│ ├── MessageList.vue # Scroll container, MOTD, empty state, smart auto-scroll, viewport-aware
│ ├── MessageItem.vue # Nick + time + body + reactions + hover actions + inline image + /me actions
│ ├── SystemMessage.vue # Icon + color per subtype (join/part/kick/mode/error/whois/etc.)
│ ├── RichText.vue # Fenced code blocks + inline code + clickable URLs
│ ├── CodeBlock.vue # Green text, dark bg, left accent border
│ ├── ReplyContext.vue # Nick-colored left border + quoted text
│ ├── LinkPreview.vue # Teal left border card (domain, title, desc)
│ ├── InlineImage.vue # Image display with loading placeholder, 340×300px max
│ └── Reactions.vue # Emoji badges with counts
│
└── overlays/
├── ConnectionModal.vue # Tabbed: Guest / Register / Sign In + server config + saved servers
├── RegisterNickModal.vue # Post-connect NickServ registration (from settings)
├── SettingsPanel.vue # All 16 settings: display, media, behavior, advanced, connection
├── JoinChannelModal.vue # Channel name + key, server LIST browser, click-to-join
├── ChannelInfoPanel.vue # Channel info: topic, modes, ban list
├── ChannelDiscoveryModal.vue # Browse server channel list
├── HelpPanel.vue # Keyboard shortcuts and help info
├── UserInfoCard.vue # WHOIS card: avatar, nick, hostmask, channels, badges, DM button
├── SearchOverlay.vue # Full-screen search with filtered results
├── EmojiPicker.vue # 10 quick-access emojis, wired to reactions
└── FileUploadToast.vue # Upload progress bar with percentage
WebSocket ↔ IRCClient (singleton)
↓ events
useIRC composable
↓ mutations
Pinia Stores ←→ localStorage
↓ reactivity
Vue Components
Singleton WebSocket IRC client accessed via getClient().
- CAP negotiation: Requests
message-tags server-time batch echo-message labeled-response sasl chathistory away-notify account-notify draft/reply draft/react typing - SASL PLAIN: Base64-encoded
user\0user\0passvia AUTHENTICATE - BATCH protocol: Collects messages tagged with
batch=<id>into batch objects, emitsbatch:endwhen complete. Used for chathistory replay. - Chat history:
chathistory(target, limit)sendsCHATHISTORY LATESTif cap is available - Reconnect: Exponential backoff
[1, 2, 4, 8, 16, 30]seconds - Commands:
join part privmsg notice action topic changeNick who whois kick ban unban mode invite list away chathistory sendRaw tagmsg privmsgWithTags - Events:
status registered serverinfo motd nick:error channel:error error sasl:success sasl:fail reconnecting batch:end+ all IRC commands (PRIVMSG JOIN PART KICK QUIT NICK TOPIC MODE NOTICE INVITE TAGMSG AWAY+ numerics)
// Send TAGMSG (requires message-tags cap, used by reactions + typing)
client.tagmsg(target, { '+draft/react': '👍', '+draft/reply': msgId })
// Send PRIVMSG with IRCv3 tags (used by reply threading)
client.privmsgWithTags(target, text, { '+draft/reply': msgId })
// Request chat history after joining a channel
client.chathistory('#general', 100)Bridges IRC events to Pinia stores. Registers 20+ event handlers on mount, cleans up on unmount.
Key behaviors:
PRIVMSG→messages.addMessage(), DM channel auto-creation, unread increment (muted channels skipped), URL detection → inline image or async link preview fetch (gated on settings), mIRC format stripping viastripFormatting(), reply reference resolutionJOIN/PART/KICK/QUIT→channels.addChannel/removeChannel,usersStore.addUser/removeUser(per-channel). QUIT broadcast scoped to channels where user was present.353 (NAMES)/352 (WHO)→ populate per-channel user list with op/voice/statusWHOIS→ buffer across 311-318 numerics, opens UserInfoCard overlay on 318 (end)433 (nick in use)→ auto-retry withnick_1,nick_2, etc. during registrationMODE→ parse +o/-o/+v/-v, update user modes per-channelNOTICE→ suppresses service notices (NickServ etc.) and server***connection noticesTAGMSG→ reactions (+draft/react) and typing indicators (+typing)batch:end→ replays chathistory PRIVMSG messages into the message store- Slash commands →
handleCommand()dispatches to IRC client methods sendInput()→ usesprivmsgWithTags()with+draft/replytag when replying- All
addSystemMessage()calls include asubtypefor typed rendering (join/part/kick/mode/error/etc.) - On connect without SASL: shows registration tip for history persistence
System messages carry a subtype field for distinct icon + color rendering:
| Subtype | Events | Icon | Color |
|---|---|---|---|
join |
JOIN | → | green |
part |
PART | ← | dim |
quit |
QUIT | ← | dim |
kick |
KICK | ✘ | pink |
mode |
MODE | ⚙ | teal |
topic |
TOPIC | ✎ | teal |
nick |
NICK | ↔ | gold |
error |
errors, channel errors, nick errors | ⚠ | pink |
whois |
WHOIS lines | ℹ | blue |
info |
NOTICE, SASL, help, default | — | dim italic |
All use Pinia composition API (setup function syntax).
| Store | Persists | Key State |
|---|---|---|
connection |
localStorage | nick, serverHost, gatewayUrl, SASL config, savedServers[], motd[], status |
channels |
localStorage (lastActive, muted, savedDMs) | channels[], activeChannel, currentChannel computed, getSavedDMs() |
messages |
memory only | messagesByChannel Map, replyTarget, auto-trim to maxMessagesPerChannel |
users |
memory only | usersByChannel Map, currentUsers/sortedUsers computed (ops→voiced→status→alpha) |
settings |
localStorage (auto-watch) | 16 refs across display/media/behavior/advanced |
ui |
none | channelDrawerOpen, usersDrawerOpen, searchOpen, connectionModalOpen, settingsOpen, joinChannelOpen, registerNickOpen, whoisCardOpen, whoisData |
The users store tracks users per-channel via usersByChannel (a Map of channel → user array). Key methods:
usersStore.addUser(channel, nick, { op, voiced, status })
usersStore.removeUser(channel, nick)
usersStore.removeUserFromAll(nick) // QUIT handling
usersStore.hasUser(channel, nick) // Check presence before broadcasting QUIT/NICK messages
usersStore.clearChannel(channel) // On self-PART/KICK
usersStore.clearAll() // On disconnectcurrentUsers and sortedUsers are computed from the active channel.
/ → redirect to /channel/general
/channel/:name → sets active channel to #name
Route ↔ channel sync is bidirectional:
- Changing the active channel updates the route
- Route params on load set the active channel
Query parameters auto-configure and connect:
https://quirc.chat/?ws=wss://myserver.com&server=myserver.com&nick=guest&channels=general,random&port=6697
Communities can share pre-configured links. Params are cleaned from the URL after applying.
- Real WebSocket IRC connection with CAP + SASL
- Auto-reconnect with exponential backoff
- Channel join/part/list with unread badges (muted channels excluded)
- Message display with timestamps + deterministic nick colors (both gated on settings)
/meaction rendering (italic* nick textformat)- System message types — join/part/quit/kick/mode/topic/nick/error/whois each with distinct icon + color
- mIRC color/bold/italic stripping on incoming messages
- Per-channel user tracking with op > voiced > status > alphabetical sorting
- 20+ slash commands (/join /part /me /topic /nick /msg /notice /kick /ban /mode /invite /whois /list /away /back /clear /connect /help /raw /quit)
- Slash command palette — shows all commands on
/, scrollable, keyboard-navigable - MOTD display from server
- Connection modal — tabbed interface with Guest/Register/Sign In flows
- NickServ registration — inline flow in ConnectionModal (connect → register → auto-configure SASL), plus standalone RegisterNickModal from settings for already-connected users
- Saved server profiles (add/load/switch)
- Settings panel (16 persistent settings — all wired to actual functionality)
- Join channel modal with server LIST browser
- Input history (up/down arrow, 100 entries)
- Tab nick completion
- Topic banner — full-width topic display below topbar, click opens ChannelInfoPanel
- Nick in input bar — teal nick display replaces
>prompt, max-width truncation - Sidebar close buttons —
×on hover per channel/DM row, part or close on click - Collapsible sidebar sections — disclosure arrows on CHANNELS and DMs headers
- User info card — WHOIS data opens a structured overlay card with avatar, nick, hostmask, channels, badges, DM button
- Channel context menu (leave, mute)
- User actions (click → WHOIS/DM/Kick)
- Connection status indicator (top bar + channel drawer footer)
- First-run flow (show ConnectionModal if not configured)
- Registration tip — non-SASL users see a tip about registering for persistent history
- DM persistence — DM channel names saved to localStorage, restored on reconnect with chathistory fetch
- URL auto-config — query params (?ws=&server=&nick=&channels=) for community deploy links
- Chat history — IRCv3 BATCH + chathistory cap loads 100 recent messages on channel join
- Smart auto-scroll — scrolls to bottom for new messages only when near bottom; maintains position when scrolled up (reading history)
- Splash screen with dynamic server name
- Mobile viewport handling — visualViewport listener tracks keyboard resize, no content jumping
- Safe-area insets for notched devices
- Animated splash (pixel logo → wordmark → fade)
- Noise texture overlay
- Scoped CSS with full
--q-*design system - Clickable URLs in messages via
RichText.vue - Link previews — URLs in messages async-fetched via unfurl API (gated on settings)
- Inline images — image URLs render inline with loading placeholder (gated on settings)
- Reactions — send via TAGMSG
+draft/react, receive + display with emoji badges - Typing indicators — send throttled (3s) TAGMSG
+typing=active, display with 7s auto-expire (both directions gated on settings) - Reply threading — send with
+draft/replytag, receive + resolve parent message context - File upload — presigned PUT to DO Spaces via XHR (progress events), CDN URL inserted into input
- Emoji picker — 10 quick-access emojis wired to reaction sending
- Desktop notifications — Web Notifications API, fires on DMs/mentions/keywords (gated on settings)
- Keyboard shortcuts — Escape closes overlays (priority order), Cmd/Ctrl+K toggles search
- Message limit — auto-trims oldest messages per channel (configurable in settings)
- SSRF protection on unfurl proxy — rejects private IPs, auth URLs, enforces size limits
- Upload hardening — content-type allowlist, filename validation, 25MB size limit
- CORS headers on both Netlify functions — dynamic origin matching against comma-separated
CORS_ORIGINenv var (defaults to quirc.chat) - Server notice filtering —
***server connection notices suppressed from chat - Event handler cleanup — all IRC client listeners properly cleaned up in onUnmounted
- Service worker / PWA offline
- Web push notifications (service worker push, not desktop notifications)
- Full chat history browsing (load older messages beyond initial 100)
- Read markers / last-read tracking
- Virtual scrolling for large message lists
- IndexedDB message persistence (schema ready in Dexie)
- mIRC color rendering (parser strips them, doesn't render)
- Multi-server simultaneous connections
- End-to-end encryption
- Custom themes
- Syntax highlighting in code blocks
- Chat history requires registered account — Ergo stores history per-account. Guest users get no server-side history. A tip is shown on connect for unregistered users.
- NAMES prefix parsing — only
@(op) and+(voice) are handled. Multi-prefix modes (~owner,&admin,%halfop) are not stripped, which could store nicks with prefix characters. - Touch hover on messages — long-press sets hover state for action buttons but there's no mechanism to clear it on touch devices (mouseleave doesn't fire).
-
Database persistence — New
deploy/entrypoint.shbacks upircd.dbto DO Spaces via MinIO client (mc). Restores on startup, backs up every 5 min, saves on SIGTERM. Solves App Platform's ephemeral filesystem wiping accounts/channels/history on redeploy. -
Dockerfile overhaul — Installs
mcbinary, ensuresergouser exists, uses custom entrypoint instead of running Ergo directly. Proper signal handling with trap/cleanup. -
app.yaml env vars — Added
DO_SPACES_KEY,DO_SPACES_SECRET,DO_SPACES_REGION,DO_SPACES_BUCKET,BACKUP_INTERVALfor the ergo service. -
ircd.yaml audit fixes — Fixed
websocket-origins→ correctwebsockets.allowed-originskey (was silently ignored). Removed deadbouncersection and invalidretention.cutoff. Bumpedbcrypt-cost4→10. Tightened registration throttling 30→5 attempts. Addedmax-channels-per-client,operator-only-creation, channel registration limits. Added localhost to WebSocket origins. Note:history.persistentis disabled — current Ergo stable requires MySQL for persistent history. In-memory history (10k msgs/channel, 168h expiry) works fine and the DB backup preserves accounts/channels.
-
Image loading fix — Removed
crossorigin="anonymous"from InlineImage<img>tag. Was forcing CORS preflight on CDN image GETs, causing "LOADING" stuck state when CDN doesn't return CORS headers. -
Connection defaults from env — ConnectionModal now pre-fills server fields from
import.meta.env.VITE_*with hardcoded fallbacks. Users can type a nick and connect immediately without touching server settings. -
Password form warning — Wrapped ConnectionModal body in
<form @submit.prevent>to suppress Chrome "password field not in form" DOM warning. -
Logo fix — Removed Node.js error output appended to
public/logo.svg. -
Favicon from logo — Replaced generic favicon with pixel-art Q extracted from the logo (same #FF2E63 on #0a0a0a).
- Dynamic origin matching — Both
upload-url.jsandunfurl.jsnow support comma-separatedCORS_ORIGINenv var. Dynamically matches requestOriginheader against the allowed list. Enables forks (e.g. heatsynclabs.chat) to set their own origin without code changes.
-
System Message Types — all system messages now carry a
subtype(join/part/quit/kick/mode/topic/nick/error/whois/info) with distinct icons and colors. Backward compatible — default is'info'. -
Topic Banner — new
TopicBanner.vuebetween TopBar and MessageList. Shows full channel topic with teal info icon. Click opens ChannelInfoPanel. Topic removed from topbar. -
Nick in Input Bar — replaces
>prompt with current nick in teal, separated by vertical border. Shows nick collision results (e.g.Guest38_1). -
Sidebar Close Buttons —
×on each channel/DM row in ChannelDrawer. Hidden by default, visible on hover (always visible on touch). Part channels, remove DMs. -
Collapsible Sidebar Sections — disclosure triangles on CHANNELS and DIRECT MESSAGES headers. Ephemeral state.
-
User Info Card — WHOIS data now opens
UserInfoCard.vueoverlay instead of dumping text lines. Shows avatar initial, nick, hostmask, real name, account, server, channels, TLS/OPER badges, idle time, DM button.
- DM Persistence — DM channel names saved to localStorage and restored on reconnect with chathistory fetch.
- Registration Tip — non-SASL users see info message about registering for persistent history.
- Server Notice Filtering —
***connection notices (hostname lookup etc.) suppressed from chat. - Slash Command Palette — shows all commands on bare
/(was requiring first letter), all commands listed (was capped at 8), scrollable with keyboard tracking. - Smart Auto-Scroll — MessageList now checks if user is near bottom before auto-scrolling. Maintains position when scrolled up reading history.
- Deferred messages (nick collision) now preserve
'error'subtype - All
addSystemMessagecalls explicitly pass subtype parameter - ChannelDrawer: replaced
v-show+v-ifandv-show+v-foranti-patterns with proper<template v-if>wrappers - TopBar: merged duplicate
.topbar__centerCSS rule - MessageList: removed dead
parseTimeToDatefunction and unused.msg-list__empty-hintCSS
# Client-side (VITE_ prefix, baked into build)
VITE_DEFAULT_SERVER=irc.quirc.chat
VITE_DEFAULT_PORT=6697
VITE_GATEWAY_URL=wss://irc.quirc.chat
VITE_AUTO_JOIN=#general,#random
VITE_UPLOAD_API=/api/upload-url
VITE_UNFURL_API=/api/unfurl
# Server-side (Netlify Functions)
DO_SPACES_KEY=
DO_SPACES_SECRET=
DO_SPACES_REGION=sfo3
DO_SPACES_BUCKET=quirc
DO_SPACES_CDN_DOMAIN=quirc.sfo3.cdn.digitaloceanspaces.com
CORS_ORIGIN=https://quirc.chat # Comma-separated for multiple origins (e.g. https://quirc.chat,https://fork.example.com)Ergo service env vars (set in DO App Platform dashboard, NOT in .env):
DO_SPACES_KEY= # Same Spaces key — used by entrypoint.sh for DB backup
DO_SPACES_SECRET= # Same Spaces secret
DO_SPACES_REGION=sfo3
DO_SPACES_BUCKET=quirc
BACKUP_INTERVAL=300 # Seconds between periodic DB backups (default 300)- Site: quirc (quirc.netlify.app)
- Custom domain: quirc.chat
- Auto-deploy: linked to
virgilvox/quircmain branch - Functions:
/api/unfurland/api/upload-url(serverless, CORS-protected) - Env vars: all variables set via Netlify dashboard/CLI
npm run build # → dist/
netlify deploy --prod # or git push (auto-deploy)- Ergo IRC server in Docker container (
ghcr.io/ergochat/ergo:stable+ MinIOmcclient) - WebSocket only on port 8080, App Platform terminates TLS
- Config:
deploy/ircd.yaml— multiclient, in-memory chat history (10k msgs, 168h expiry), account registration - Database persistence:
deploy/entrypoint.shbacks upircd.dbto DO Spaces on startup/shutdown/every 5 min. Survives App Platform redeployments. - Auto-deploy: from
virgilvox/quircmain branch viadeploy/app.yamlspec - Health check: TCP on port 8080 (not HTTP — Ergo returns 400 for non-WebSocket requests)
- Env vars (set in dashboard):
DO_SPACES_KEY,DO_SPACES_SECRET,DO_SPACES_REGION,DO_SPACES_BUCKET,BACKUP_INTERVAL
# Deploy or update
doctl apps create --spec deploy/app.yaml
# Check status
doctl apps list- Bucket: quirc (sfo3 region)
- CDN: quirc.sfo3.cdn.digitaloceanspaces.com
- CORS: configured for quirc.chat and quirc.netlify.app origins
- Presigned PUT URLs via
upload-url.jsfunction (content-type allowlist, 25MB limit) - Path:
uploads/YYYY-MM/uuid.ext
quirc.chat A → 75.2.60.5 (Netlify load balancer)
irc.quirc.chat CNAME → quirc-irc-r256h.ondigitalocean.app (App Platform)
| Variable | Value | Use |
|---|---|---|
--q-accent-teal |
#08D9D6 |
Active states, links, focus borders, register actions, mode/topic messages |
--q-accent-orange |
#e85d3b |
Primary actions, CONNECT button, unread badges |
--q-accent-pink |
#FF2E63 |
Errors, danger actions, kick/error messages, close buttons |
--q-accent-acid |
#EAFF00 |
Scanline effects, highlights |
--q-accent-gold |
#f0c040 |
Warnings, away status, nick change messages |
--q-accent-green |
#6bcb77 |
Online status, success, join messages |
--q-accent-blue |
#4d96ff |
WHOIS messages |
- Font: Space Mono everywhere. No exceptions.
- Border-radius: 0 everywhere.
border-radius: 0 !importantin base.css. - Borders: Solid 1-2px. Dashed for info containers (MOTD).
- Labels: UPPERCASE, letter-spacing 2-4px, 9-10px size.
- Touch targets: 44px minimum height.
- No emoji in UI chrome. Emoji only in: message content, reactions, emoji picker.
- No CSS framework. Scoped styles + custom properties only.
- Singleton IRC client — single connection per app instance, accessed via
getClient() - Event-driven bridge —
useIRC()composable registers handlers, routes to stores - localStorage for config — connection, settings, channel state, saved DMs survive refresh
- Memory-only messages — IndexedDB schema ready but not active (performance tradeoff)
- No component library — all UI hand-built for precise control over punk aesthetic
- Shared logo data —
logoPixels.jsfeeds both static SVG and canvas animation - Env var defaults — deployers configure via
.env, users override in ConnectionModal - Saved server profiles — multi-deployment support without multi-connection complexity
- App Platform over Droplet — Docker-based, auto-deploy, managed TLS, no server maintenance
- XHR for uploads — XMLHttpRequest instead of fetch for upload progress events
- Reactivity-safe async — link preview fetches look up messages through Pinia store proxy, not raw object references
- Per-channel user tracking —
usersByChannelmap prevents user lists leaking across channels; QUIT messages scoped to channels where user was present - Tabbed connection UX — Guest/Register/Sign In tabs educate IRC newcomers about nickname ownership without requiring registration upfront
- Inline NickServ registration — ConnectionModal stays open during register flow (connect → NickServ REGISTER → auto-SASL setup) for seamless onboarding
- Mobile viewport tracking —
visualViewportAPI resize listener sets--app-heightCSS variable to prevent keyboard-induced layout jumping - WHOIS as card, not text — structured data rendered in UserInfoCard overlay instead of text dump into chat
- System message subtypes — typed system messages enable per-event styling without parsing text content
- Echo-message cap: When server supports
echo-message, client must NOT add outgoing messages locally — they come back from the server. The guard in useIRC.js PRIVMSG handler checks!client._capAcked.includes('echo-message'). - Link preview reactivity: Async unfurl results must be written to the message via the reactive Pinia store proxy (not the original object reference) or Vue won't detect the change.
- TAGMSG cap guard:
client.tagmsg()silently no-ops ifmessage-tagscap wasn't acknowledged. Check cap status when debugging missing reactions/typing. - Ergo health checks: Must use TCP, not HTTP. Ergo returns 400 for plain HTTP requests to its WebSocket port.
- DO Spaces CORS: Must be configured in the DO console (API keys may lack bucket management permissions). Required for presigned PUT uploads from the browser.
- Registration flow timing: The ConnectionModal register tab watches
connection.statusto transition from "connecting" to "registering" phase. If the connection fails silently, a 10s timeout catches the stall. - BATCH message collection: Messages with a
batchtag are silently collected during the batch and NOT emitted individually. They only surface viabatch:end. If chathistory messages aren't appearing, check that the batch handler is wired. - Mobile keyboard:
html, bodyareposition: fixedto prevent iOS rubber-banding. The--app-heightvariable is set by thevisualViewportresize listener. Without it, the fallback is100dvhwhich doesn't account for keyboard. - Chat history requires registration: Ergo stores history per-account. Guest nicks get no server-side history. DM channel names are persisted client-side, but actual message content requires a registered account.
- getSavedDMs() reads initial state: The
channels.getSavedDMs()method reads from thesavedobject captured at store creation time. This is correct for its use case (called once on connect) but would return stale data if called mid-session.
npm install # Install dependencies
npm run dev # Vite dev server (localhost:5173)
npm run build # Production build → dist/
npm run preview # Preview production build locally