theyasked.me

Docs

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() in packages/shared/src/theme/compile.ts turns a theme into CSS custom properties and data-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, newsreader and system are self-hosted or local. The others load from fonts.googleapis.com (CSS) and fonts.gstatic.com (files), only when the page's theme picks one. The page's Content Security Policy then adds exactly those two hosts, to style-src and font-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.js applies it before first paint. RTL themes get dir="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 without removeBranding (so on every default plan, whatever was stored), custom CSS is dropped without customCss. 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, html and body become the root itself), so it can't reach anything outside the portal or embed;
  • comments, backslash escapes, < and control characters are removed first;
  • @import only from https://fonts.googleapis.com/css2?…; @font-face sources only from https://fonts.gstatic.com; @media, @supports, @container, @layer and @keyframes are kept; every other at-rule is dropped;
  • url() must be https:// (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/.