Theming
Match the portal, embed and widget to your brand: colours, fonts, corners, layout and custom CSS.
Make the public portal, the embed (the widget's panel and your mobile WebViews) and the widget launcher look like your product: colours, fonts, corners, density, layout, the launcher button, and, on Business, your own CSS. The dashboard always stays TheyAsked.
Set it up in Settings → Appearance: controls on the left, a live preview on the right (portal, embed in a 375 px phone frame, launcher on a sample page, light or dark). Start from a preset, or paste your site's address under Match my site and review the suggestion.
Theme options
Everything is stored in the workspace's branding (accent, theme, logoUrl, faviconUrl,
portalTitle, portalIntro, and the theme itself in branding.appearance). Every field has a
default that reproduces the stock look, so a workspace that never opens the studio is unchanged.
| Group | Option | Values | Default |
|---|---|---|---|
| Brand | accent |
#RRGGBB |
vermilion |
theme (colour mode) |
auto · light · dark |
auto (follows each visitor) |
|
| Colours, per mode | colors.light, colors.dark |
accent, accentText, background, surface, text, muted, border (each #RRGGBB or null = automatic) |
all automatic |
colors.dark = null |
derive the dark palette from the light one (same hue, dark tones) | derived | |
| Status colours | status.open … status.closed |
#RRGGBB or null (they then win over each status's own colour) |
null |
| Typography | typography.body, typography.heading |
default (Geist + Newsreader), system, geist, inter, dm-sans, manrope, poppins, roboto, open-sans, lato, nunito, work-sans, ibm-plex-sans, source-sans-3, space-grotesk, noto-sans-arabic, cairo, tajawal, newsreader, playfair-display, lora, jetbrains-mono |
default |
typography.baseSize |
14 – 17 (px) | 15 | |
typography.headingWeight |
400 · 500 · 600 · 700 | 400 | |
| Shape | shape.radius |
0 · 4 · 6 · 10 · 16 | 6 |
shape.density |
compact · cozy · comfortable |
cozy |
|
shape.border |
hairline · none · shadow (rows become cards) |
hairline |
|
shape.vote |
tally · pill · arrow |
tally |
|
shape.button |
solid · outline |
solid |
|
| Layout | layout.header |
embed: the top bar · portal: the headline and intro | on |
layout.logo, layout.search, layout.sortTabs, layout.statusChips |
on / off | on | |
layout.poweredBy |
on / off (off only on a plan with removeBranding; none by default, see below) |
on | |
layout.defaultTab |
feedback · roadmap · changelog (the embed's start tab) |
feedback |
|
layout.rtl |
auto (right to left for ar, he, fa, ur…) · on · off |
auto |
|
| Launcher | launcher.position |
right · left |
right |
launcher.label |
≤ 24 characters | "Feedback" | |
launcher.icon |
chat · lightbulb · megaphone · heart |
chat |
|
launcher.background, launcher.text |
#RRGGBB or null (accent; ink or paper, whichever contrasts) |
null |
|
launcher.offsetX, launcher.offsetY |
0 – 96 px from the side / bottom | 20, 20 | |
launcher.size |
sm · md · lg |
md |
|
| Mobile | webview.hideHeader |
hide the embed header for `platform=ios | android` |
| Advanced | customCss |
≤ 20 KB, sanitized (below); Business and above | none |
| Per surface | embed, portal |
a partial theme (colours, status, typography, shape, layout) layered on the base for that surface only | none |
In the studio, Editing: Embed only / Portal only writes to those overrides; overridden fields show a dot, and "Clear overrides" removes them. Export downloads (and copies) the theme as JSON; Import takes that JSON back; Reset to default returns to the stock theme (logo and portal text stay).
API
PATCH /api/admin/workspace with { "branding": { "appearance": { … } } } (admin). The body is a
deep patch: nested keys you don't send keep their saved values ({ "shape": { "radius": 10 } }
changes only the radius). null resets a colour or clears an override; embed / portal are
replaced as a whole when sent. Out-of-range values are refused with 422; plan-gated options with
402 plan_limit (details.limit = removeBranding or customCss, details.upgradeTo).
POST /api/admin/workspace/appearance/match { "url": "yourproduct.com" } (admin, rate-limited)
returns { url, suggestion: { accent, background, surface, text, font, headingFont, logoUrl }, found: [...] }. It reads theme-color, icons, inline styles and up to two stylesheets (brand /
primary / background / foreground custom properties, shadcn-style HSL triplets, body colours,
font-family). It saves nothing. Every request, including each redirect hop and stylesheet, goes
through the SSRF guard (apps/worker/src/lib/net-guard.ts): https only, no private, loopback,
link-local or metadata addresses, at most three redirects; bodies are capped at 512 KB / 256 KB
with a 6 s timeout.
Accessibility
The compiler never renders below WCAG AA: text, muted text and accent text are nudged until they reach 4.5:1 on the background and the surface; the label on an accent fill is ink or paper, whichever reaches 4.5:1 (the fill shifts slightly when neither does); focus rings use the accent-text colour. The studio's contrast panel lists the pairs your chosen colours fail (text, muted text, button labels, accent marks at 3:1, status glyphs at 3:1) with a one-click Fix that stores the nudged colour. Status never relies on colour alone (the glyph is a progress pie).
URL overrides (embed)
Quick per-host tweaks without saving anything:
https://theyasked.me/embed/acme?accent=00A19A&theme=dark&font=inter&radius=10
| Param | Values |
|---|---|
accent |
6-digit hex, with or without # (%23 when encoded) |
theme |
light · dark · auto (auto ignores the workspace's forced mode) |
font |
a font id from the table above (sets the heading font too when it is default) |
radius |
0 · 4 · 6 · 10 · 16 |
Anything else is ignored. They never change layout, plan-gated options or custom CSS. The studio's Embed snippet builds this URL from the current settings.
Runtime theme (follow your app's dark mode)
Web widget:
TheyAsked('init', { workspace: 'acme', theme: { mode: 'auto', font: 'dm-sans' } });
// later, when your app switches:
TheyAsked('theme', { mode: 'dark' }); // or TheyAsked('theme', 'dark')
TheyAsked('theme', { accent: '#00A19A', background: '#0B1020' });
Native WebViews: evaluate window.TheyAskedEmbed.theme({ mode: 'dark' }), or post
{ "source": "theyasked-host", "type": "theme", "theme": { "mode": "dark" } }. See
MOBILE.md for Swift, Kotlin, React Native and Flutter snippets.
Accepted fields: mode (light · dark · auto), accent, font, headingFont, radius,
and the colours of the current mode (background, surface, text, muted, border). Each
field is validated on its own; an invalid one is dropped. A font chosen at runtime loads only if
the page already allows Google Fonts (the saved theme or the font URL parameter picks one);
otherwise it falls back to the system stack, so pass font in init / the URL when you use one.
How it is delivered
- One compiler.
compileTheme()inpackages/shared/src/theme/compile.tsturns a theme into CSS custom properties anddata-ta-*attributes. The web app applies them on the portal / embed root (ThemeScope, also used by the studio preview); the Worker uses the same function to inject a first-paint<style>into the embed and portal HTML, so there is no flash of the stock colours. The same module validates and sanitizes; the runtime half has no zod (7.5 KB gzip). - Fonts.
default,geist,newsreaderandsystemare self-hosted or local. The others load fromfonts.googleapis.com(CSS) andfonts.gstatic.com(files), only when the page's theme picks one. The page's Content Security Policy then adds exactly those two hosts, tostyle-srcandfont-src, and nothing else (apps/worker/src/lib/csp.ts → withGoogleFonts). The dashboard (/app, which hosts the studio) always allows them. - Colour mode. A workspace that forces light or dark gets
<html data-ta-mode>from the Worker;/theme-init.jsapplies it before first paint. RTL themes getdir="rtl"the same way; portal and embed layouts use logical properties, so they mirror. - Plan gating. Public responses (
/api/v1/w/:ws,/widget-config, the injected HTML) apply the plan: "Powered by" stays on withoutremoveBranding(so on every default plan, whatever was stored), custom CSS is dropped withoutcustomCss. Stored values are kept. - Caching. The public config is edge-cached for 30 s and the widget config for 60 s, so a saved theme can take up to a minute to reach every visitor.
CSS variables and hooks
These are stable; target them from custom CSS (class names are not).
| Variable | Meaning |
|---|---|
--canvas, --surface, --raised, --sunken |
page, rows/headers, inputs/menus, hover/tracks |
--ink, --ink-2, --ink-3, --ink-inverse |
text, secondary text, muted text, text on ink |
--rule, --rule-strong |
hairlines, control borders |
--accent, --accent-fg, --accent-text, --accent-wash, --focus |
fill, label on fill, accent as text, tint, focus ring |
--st-open … --st-closed |
status glyph colours |
--font-ui, --font-display, --font-data |
body, headings, numbers |
--ta-fs, --ta-heading-weight |
base size ÷ 15, heading weight |
--r-xs, --r-sm, --r-md, --r-lg |
corner radii |
| Hook | Element |
|---|---|
[data-ta-root] |
the themed root (also <body> on portal / embed pages), with `data-ta-surface="portal |
[data-ta-density], [data-ta-border], [data-ta-button-style], [data-ta-vote-style] |
the shape options, on the root |
[data-ta-header], [data-ta-tabs], [data-ta-hero], [data-ta-logo] |
header bar, embed tabs, portal headline block, logo |
[data-ta-toolbar], [data-ta-search], [data-ta-sort], [data-ta-status-chips] |
feed controls |
[data-ta-feed], [data-ta-post], [data-ta-vote] |
the list, one request, its vote button (`data-ta-vote="tally |
| `[data-ta-button="primary | secondary |
[data-ta-footer], [data-ta-powered-by] |
footer, the "Powered by" link |
/* Pill-shaped votes and a tighter title */
[data-ta-vote] { border-radius: 999px; }
[data-ta-post] h3 { letter-spacing: -0.01em; }
/* Variables set by the theme are inline on the root: use !important to override them */
:root { --accent-wash: color-mix(in oklab, var(--accent) 18%, transparent) !important; }
Custom CSS rules
Custom CSS (Business and above) is parsed and re-written, not filtered as text
(packages/shared/src/theme/css.ts); the Worker stores the sanitized version, and it is scoped
again when rendered:
- every selector is prefixed with
[data-ta-root](:root,htmlandbodybecome the root itself), so it can't reach anything outside the portal or embed; - comments, backslash escapes,
<and control characters are removed first; @importonly fromhttps://fonts.googleapis.com/css2?…;@font-facesources only fromhttps://fonts.gstatic.com;@media,@supports,@container,@layerand@keyframesare kept; every other at-rule is dropped;url()must behttps://(any host for images);data:,javascript:,vbscript:,expression(),behavior,-moz-binding,image-set(),image(),src()and nested rules are dropped;- at most 20 KB, cut at a whole rule.
The studio shows what will be removed as you type.
Plans
| Free | Pro | Business | Enterprise | |
|---|---|---|---|---|
| Colours, fonts, shape, layout, launcher, presets, URL and runtime overrides | ✓ | ✓ | ✓ | ✓ |
Hide "Powered by theyasked.me" (removeBranding) |
(platform may enable) | |||
Custom CSS (customCss) |
✓ | ✓ |
The limits live in PLAN_LIMITS (packages/shared/src/plans.ts, editable per plan in
/platform/plans); the API answers 402 when a request turns a gated option on.
"Powered by theyasked.me"
The badge is mandatory on every plan (owner decision, 2026-10-15: every account is a
source of new signups). removeBranding is off in every default plan, and migration 0010
switched it off in any plan a platform admin had edited. The flag stays editable in
/platform/plans (for example for an Enterprise contract); only then does the studio show the
"Powered by" switch. Otherwise there is no switch and no upsell, PATCH answers 402
(details.limit = removeBranding, upgradeTo: null), and a hide value stored earlier is
ignored by every public surface.
Where it appears (one component, apps/web/src/features/portal/PoweredBy.tsx; mark +
"theyasked.me" wordmark, muted text that turns ink with an underline on hover, the global
focus ring; only theme tokens, so it reads on light, dark and custom themes and passes the
contrast checks):
| Surface | Where | utm_medium |
|---|---|---|
| Portal | footer (phones too, above the tab bar) | portal (changelog on /:ws/changelog…) |
| Embed (direct, iOS / Android WebViews) | bottom of Feedback, Roadmap and What's new | embed |
| Web widget panel | same, inside the panel | widget |
| Emails to end users (status, comments, changelog, end-user magic links) | a subtle line under the footer, plus the plain-text part | email |
| Public changelog RSS | the channel <description> |
changelog |
Staff-only screens and staff emails never show it. The link is
https://theyasked.me/?ref=<workspaceSlug>&utm_source=powered_by&utm_medium=<medium>&utm_campaign=badge
(poweredByUrl in packages/shared/src/growth.ts; the SPA uses its own origin, the Worker
APP_URL), target="_blank" rel="noopener". In a native WebView the click is handed to the
app as theyasked:open_url so it opens in the system browser (MOBILE.md).
Hook for custom CSS: [data-ta-powered-by] (also data-medium). Hiding it with custom CSS
breaks the terms of the plan, not the product. Signups it brings show up in the platform
console (PLATFORM-ADMIN.md → "Growth"). Screenshots:
docs/v2/screenshots/powered-by/.