Seance is a static SPA: yarn build writes everything to public/, and an IRC network ships that directory as its own client. Two layers of branding exist:
| Layer | Source | Applied when | Covers |
|---|---|---|---|
| Runtime | public/config.json (fetched) |
Every page load | Everything the Vue app renders: title, connect-form defaults, help links, UI strings, feature flags, default theme |
| Build-time | client/config.json (read by webpack) |
yarn build |
index.html <title>, application-name, theme-color, the loading splash text, and the web app manifest fields |
Both read the same file: client/config.json is copied to public/config.json unchanged. A deploy that only edits public/config.json gets full runtime branding without rebuilding; the pre-JavaScript bits (browser tab title before boot, PWA manifest name, splash text) keep whatever was in client/config.json at build time. Rebuild (or overwrite those files, see below) to change them.
client/js/branding.ts owns the schema, defaults and loader. boot.ts awaits loadBranding() before anything renders, commits the result to store.state.branding, sets document.title, and folds theme / themeColor / uploads into the configuration.
{
"appName": "TestNet IRC",
"shortName": "TestNet",
"description": "Chat on TestNet from your browser",
"defaultNetwork": {
"name": "TestNet",
"host": "irc.testnet.example",
"port": 8443,
"tls": true,
"channels": ["#lobby", "#help"],
"nick": "guest????",
"lockHost": true
},
"theme": "morning",
"themeColor": "#1d3557",
"links": {
"website": "https://testnet.example/",
"help": "https://testnet.example/help",
"privacy": "https://testnet.example/privacy"
},
"features": {
"multiNetwork": false,
"saveNetworks": true,
"allowCustomServer": false
},
"strings": {
"connect.title": "Join TestNet",
"connect.submit": "Join"
},
"uploads": {
"endpoint": "https://files.testnet.example/upload",
"maxSizeBytes": 10485760
}
}Every field is optional; {"appName": "Seance"} (the shipped default) is a complete file. Unknown or malformed fields are dropped one by one and the rest still applies. A missing file, a 404 or invalid JSON falls back to the defaults with a single console.warn.
| Field | Type | Default | Notes |
|---|---|---|---|
appName |
string | "Seance" |
Document title, About heading, "Add … to Home screen", notification/protocol-handler name, <title> at build time. |
shortName |
string | appName |
Build time only: manifest short_name. |
description |
string | "IRC client" |
Build time only: manifest description. |
defaultNetwork.name |
string | host | Label shown on the connect form when the host is locked. |
defaultNetwork.host |
string | — | Required for defaultNetwork to count; otherwise the whole object is ignored. |
defaultNetwork.port |
integer | 8443 / 8067 by tls |
1–65535; strings are accepted. |
defaultNetwork.tls |
boolean | true |
|
defaultNetwork.channels |
string[] | none | A comma-separated string also works. Names without a prefix get #. |
defaultNetwork.nick |
string | empty | Every ? (or %, TheLounge style) becomes a random digit: "guest????" → guest4821. |
defaultNetwork.lockHost |
boolean | false |
Hide the host/port/TLS fields; the form always connects to defaultNetwork. |
theme |
string | "default" |
Must be a theme in the build (default, morning). Applies until the user picks a theme in Settings. |
themeColor |
#rgb(a)/#rrggbb(aa) |
#415364 |
<meta name="theme-color">; build time also fills the manifest theme_color / background_color. |
links.website / .help / .privacy |
http(s) URL |
the Seance repo, its docs/, none |
Links in the Help window. Set privacy to add a "Privacy policy" link. |
features.multiNetwork |
boolean | true |
false hides the sidebar "connect" button once one network exists. |
features.saveNetworks |
boolean | true |
false hides the saved-networks picker, "remember password" and "connect automatically" on the connect form (see follow-ups). |
features.allowCustomServer |
boolean | true |
false behaves like lockHost and also ignores hosts from saved networks and ?host= URL parameters. Requires defaultNetwork. |
features.saslDisconnectOnFail |
boolean | true |
Drop the connection when a network set to log in with SASL does not manage to; see Failed SASL logins. |
strings.<key> |
string | built-in copy | Keys: connect.title, connect.savedNetworks, connect.savedNetworksEmpty, connect.submit, help.about, help.website, help.documentation, help.privacy. |
uploads |
object | none (uploads off) | Network-provided file uploader; see Uploads. Dropped unless endpoint is an https: URL. |
uploads.endpoint |
https URL |
— | Receives a multipart POST per file. |
uploads.maxSizeBytes |
integer | 10485760 (10 MiB) | Client-side limit; larger files are refused with "File … is over the maximum allowed size". |
uploads.fieldName |
string | "file" |
Multipart form field carrying the file. |
uploads.responseUrlKey |
string | "url" |
JSON key holding the public URL in the response. |
uploads.withCredentials |
boolean | false |
Send cookies with the request (credentials: "include"). |
uploads.headers |
object of strings | none | Extra request headers, e.g. {"X-Api-Key": "…"}. Content-Type is ignored: the browser sets the multipart boundary. |
URL parameters (?host=…&port=…&nick=…&join=…&autoconnect=1, ?uri=web+irc://…) still pre-fill the form and beat defaultNetwork, except for host/port/TLS when the host is locked.
When a network is configured with "Username + password (SASL PLAIN)" and the login does not succeed, Seance reports why and drops the connection — it does not quietly register you as an unauthenticated stranger. features.saslDisconnectOnFail: false restores the old behaviour: the same report, but the connection carries on.
"Does not succeed" is deliberately wide, because every one of these leaves the user logged out when they asked to be logged in:
- no account name or password is configured (caught before
CAP LS, so nothing is sent); - the server does not offer the
saslcapability, or offers it withoutPLAIN(the message names what it does offer); - the server
NAKssasl; - the server answers
902,904,905,906or907— its text is quoted verbatim, so "invalid credentials", "service unavailable" or nefarious2'sFAIL AUTHENTICATE VERIFICATION_REQUIREDreach the user; - nothing arrives within 12 s, or the mechanism gets a challenge it cannot answer.
The lobby then shows the reason, Not connecting to <host> without the login you asked for. and what to try; the client sends QUIT and does not reconnect, so credentials can be fixed in the network's settings without a reconnect loop. Nothing about this reaches the wire beyond the QUIT: the ircd decides on its own whether an unauthenticated client may register at all.
Leave it on for a network whose users expect an account (channel access, host masks, a bouncer session keyed to the account); turn it off for a public deploy where connecting anyway is more useful than not connecting at all.
Seance has no server of its own, so the file goes straight from the browser to an uploader the network runs. Files reach it by drag & drop anywhere on the page, by pasting an image into the input, or from the paperclip button. Running that service is the network's responsibility; Seance only needs it to honour this contract:
- Request:
POSTtouploads.endpointwith amultipart/form-databody whoseuploads.fieldNamefield (defaultfile) holds the file, filename included. Anyuploads.fieldsare sent as extra form fields and anyuploads.headersas headers; cookies only whenuploads.withCredentialsistrue. - CORS: the endpoint is on another origin, so its
POSTresponse must carryAccess-Control-Allow-Originfor the app's origin (plusAccess-Control-Allow-Headersfor any custom headers,Access-Control-Allow-Credentials: truewhen cookies are used, and anOPTIONSanswer when either of those makes the request non-simple). Without that header the browser blocks the response even though the upload itself succeeded, so the user sees "Upload failed: Failed to fetch". - Response:
2xxwith either a JSON body holding the URL atuploads.responseUrlKey(defaulturl) or a plain-text body that is the URL. Relative URLs resolve against the endpoint. The key may be a dotted path into nested objects and arrays, e.g.results.0.filePath. On failure, a non-2xxstatus or an error message atuploads.responseErrorKey(defaulterror), which is shown to the user verbatim; otherwise "Upload failed: HTTP status".
The client checks uploads.maxSizeBytes before sending and refuses types outside uploads.accept (exact MIME types or type/* wildcards) without contacting the endpoint. The uploader should enforce its own limit, authentication and retention rules, since anyone with the app can call it. With uploads absent the upload button is hidden and dropped or pasted files are ignored after a single "File uploads are not configured in this client." notice. The stock config.json ships catbox-litterbox enabled, so the reference deploy at evilnet.github.io/seance can share files out of the box; a network that does not want a third-party host removes the uploads key. It lives in config.json rather than in the code defaults precisely so that deleting it works — a default in DEFAULT_BRANDING would be inherited by every deploy with no way to opt out.
uploads.optionalFields names fields that may be dropped for one retry when the uploader's error message blames them — the fallback that lets an upload through when the service cannot strip metadata off that particular file.
A minimal uploader is a few dozen lines (an nginx client_body handler script, or a small web function that writes to object storage and returns its URL); those recipes are out of scope here.
uploads.preset fills in the wire details of a known service; anything given alongside it wins, so a deploy can point the same format at its own instance.
The binding constraint on a third-party uploader is not its feature list but CORS: the browser discards the response unless it carries Access-Control-Allow-Origin, however well the upload itself went. Most such services are built for curl and ShareX, which never have to meet that rule, so a preset is only worth adding for an endpoint checked against it. docs/projects/boxlabs-paste-uploads.md § Survey records what was tested and when.
| Preset | Service | Works from a browser |
|---|---|---|
catbox-litterbox |
Litterbox, catbox's temporary sibling. Anonymous, no account or userhash; answers with the URL as plain text. Images and video, up to 1 GB. Uploads expire: time is 1h, 12h, 24h or 72h. |
Yes — sends Access-Control-Allow-Origin: * |
boxlabs-paste |
The anonymous image staging endpoint of PASTE, https://paste.boxlabs.uk/img/ — the one poxchat uploads pasted images to. No API key. Images only, 10 MiB. |
Not yet — no CORS header, see below |
{
"appName": "ExampleNet",
"uploads": {"preset": "catbox-litterbox"}
}fields merges with the preset's per key, so one can be changed on its own — a shorter retention, say, without restating reqtype:
{"uploads": {"preset": "catbox-litterbox", "fields": {"time": "1h"}, "maxSizeBytes": 33554432}}Capping maxSizeBytes below the service's own limit is usually wise: the progress bar is only a busy indicator, so a gigabyte is a long silence. Overriding endpoint is how a deploy aims a preset's wire format at its own instance.
boxlabs-paste expands to images[] as the file field, strip_exif=1 as an extra field (dropped and retried once if the server says stripping is what failed), results.0.filePath / results.0.error as the response paths, a 10 MiB limit and PNG/JPEG/GIF/WebP as the accepted types. The endpoint is /img/: it takes images, not video, so a dropped video is refused with a message naming the types it does take.
paste.boxlabs.uk/img/does not sendAccess-Control-Allow-Origintoday (checked 2026-08-28: thePOSTresponse carries no CORS header andOPTIONSanswers405). Until the operator adds it, uploads from a browser fail even though the file lands on the server. Nothing in the client can work around it — the response body is unreadable without it, and the URL is server-generated, so there is nothing to guess. An API key is not a workaround:api.phpon the same host does send CORS headers, but it only handles text pastes, and CORS is orthogonal to authentication. Self-hosted PASTE instances that add their own/img/need the same header in their nginx or Apache config.
Because that service strips EXIF itself, the "Attempt to remove metadata from images before uploading" setting (which re-encodes through a canvas, and already skips GIF and SVG) is belt-and-braces with boxlabs-paste rather than the only defence. With catbox-litterbox nothing strips metadata server-side, so the setting is the only thing removing EXIF from a pasted photo.
config.json covers the app itself. Icons and the manifest are static files; replace them with your own after building (or before, in client/, so the build copies them):
manifest.webmanifest— the build already fillsname,short_name,description,theme_color,background_colorfromclient/config.json. Overwrite it to change the icon list; keepstart_url/scope(./),launch_handler,protocol_handlersand the separateany/maskableicon entries, which the installed-app behaviour depends on (seepwa.md). Keep the filename:client/service-worker.jsprecaches it by name andindex.htmllinks it.favicon.icoandimg/favicon-alerted.ico— browser tab, normal and the "unread highlight" variant (client/js/vue.tsswaps between them).img/icon-192.png,img/icon-512.png— manifestpurpose: any, and the notification icon.img/icon-maskable-192.png,img/icon-maskable-512.png— manifestpurpose: maskable. Keep the artwork inside the central 80% and the background full-bleed, so Android/ChromeOS can round or circle-crop them.img/apple-touch-icon-120.png,-152.png,-167.png,-180.png— iOS home screen, and the two Windowsmsapplication-square*logotiles. These must be opaque; iOS ignores transparency and composites on black.img/logo-tile.png— the sidebar logo (45px tall) and the loading splash. One image serves both light and dark themes, so it needs its own background rather than relying on the page behind it.img/logo-art.png,img/logo-art-wide.png— the bare artwork, transparent. Not referenced by the shipped markup; available for docs, README headers and native-shell assets.
There is no Safari pinned-tab icon. mask-icon needs a single-colour SVG silhouette, which the raster artwork cannot supply, so the <link> was removed and Safari falls back to the favicon. Add one if your mark is vector.
Notifications set icon but no badge. A badge must be a monochrome silhouette; supply one and add badge: in client/service-worker.js and client/js/socket-events/msg.ts if you have artwork that suits it.
index.html hard-codes msapplication-TileColor (#0D0E14, matching the icon tiles); theme-color and the manifest's theme_color/background_color come from themeColor in config.json, defaulting to #415364.
config.json is resolved relative to the document (new URL("config.json", document.baseURI)), so serving from https://host/chat/ or through a <base href> works as long as the file sits next to index.html. The service worker treats it like any other same-origin asset (network first, cache fallback), so the last fetched copy is still used offline.
- localStorage keys still use the
thelounge.*prefix (thelounge.networks,thelounge.mentions,thelounge.sort.*,thelounge.state.*,thelounge.ignore.*,thelounge.muted,thelounge.networks.collapsed,thelounge.media.trusted, andsettings). They are deliberately untouched: renaming them would drop every user's saved networks and settings. A rename needs a one-off migration. features.saveNetworks: falsehides the saved-network UI, butclient/js/irc/manager.tsstill records the last-used network in localStorage. Make persistence conditional there.- The Changelog window still says "based on The Lounge x.y.z" on purpose (upstream attribution). The Help window's "Report an issue" link is hardcoded to
github.com/evilnet/seance/issues/new, so a deploy cannot point it at the network's own tracker; make it abranding.linkskey if one asks. - Native shells (E.4) can call
setBranding()fromclient/js/branding.tsinstead of fetching, if they bundle the config.