diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e7c606f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Test + run: npm run test diff --git a/index.html b/index.html index 003788c..65168e6 100644 --- a/index.html +++ b/index.html @@ -6,14 +6,14 @@ Touchpoint Playground diff --git a/package.json b/package.json index c73ba91..264dafb 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "prepublishOnly": "npm run build", "lint": "eslint src/ --fix", "format": "git ls-files | grep -E '\\.(js|jsx|ts|tsx|css|html)$' | xargs prettier --write", - "test": "typedoc --emit none && vitest run", + "format:check": "git ls-files | grep -E '\\.(js|jsx|ts|tsx|css|html)$' | xargs prettier --check", + "test": "npm run lint:check && npm run format:check && npm run build && typedoc --emit none && vitest run", "tsc": "tsc", "update-readme": "rm -rf docs/ && typedoc --excludeExternals --externalPattern src/components/ui/Icons.tsx && find docs -mindepth 1 -maxdepth 1 -type d -exec rm -rf {} +" }, diff --git a/src/components/ErrorMessage.tsx b/src/components/ErrorMessage.tsx index d042de0..00ee3cf 100644 --- a/src/components/ErrorMessage.tsx +++ b/src/components/ErrorMessage.tsx @@ -6,7 +6,10 @@ export const ErrorMessage: FC<{ message: string; }> = ({ message }) => { return ( -
+

{message}

diff --git a/src/components/FullscreenVoice.tsx b/src/components/FullscreenVoice.tsx index cc576e5..dda7fc8 100644 --- a/src/components/FullscreenVoice.tsx +++ b/src/components/FullscreenVoice.tsx @@ -276,7 +276,7 @@ export const FullscreenVoice: FC = ({ { setMicEnabled((prev) => !prev); }} diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 56401f2..9223e39 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -8,7 +8,6 @@ import { type ColorMode } from "../interface"; import { Close, Settings, - Undo, Restart, Volume, VolumeOff, @@ -74,7 +73,7 @@ export const Header: FC = ({ className="ml-auto" Icon={Settings} label="Settings" - type={isSettingsOpen ? "sound" : iconButtonType} + type={isSettingsOpen ? "subtle" : iconButtonType} onClick={enabled ? toggleSettings : undefined} /> ) : null} diff --git a/src/components/Input.tsx b/src/components/Input.tsx index 863521a..86f8122 100644 --- a/src/components/Input.tsx +++ b/src/components/Input.tsx @@ -227,7 +227,10 @@ export const Input: FC = ({ )} > {uploadErrorMessage != null && ( -
+
{uploadErrorMessage}
@@ -275,6 +278,7 @@ export const Input: FC = ({ <>
diff --git a/src/components/Messages.tsx b/src/components/Messages.tsx index 605abbd..400c1f5 100644 --- a/src/components/Messages.tsx +++ b/src/components/Messages.tsx @@ -259,11 +259,12 @@ export const UserMessage: FC<{ )} > {files.map((file, index) => ( - // TODO: style, add file name as alt text + // TODO: style {file.name} ))}
diff --git a/src/components/Theme.tsx b/src/components/Theme.tsx index d821de6..da3ac69 100644 --- a/src/components/Theme.tsx +++ b/src/components/Theme.tsx @@ -31,7 +31,6 @@ export const toCustomProperties = (theme: Theme): CSSProperties => { "--color-accent": theme.accent, "--color-accent-20": theme.accent20, - "--color-on-accent": theme.onAccent, "--color-background": theme.background, "--color-overlay": theme.overlay, @@ -45,7 +44,7 @@ export const toCustomProperties = (theme: Theme): CSSProperties => { } as CSSProperties; }; -const customProperties: Theme = { +export const defaultTheme: Theme = { fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"', innerBorderRadius: "20px", @@ -73,10 +72,8 @@ const customProperties: Theme = { // Accent defaults to black/white (matching primary) so that it stays // understated out of the box, and setting a brand accent is clearly visible. - accent: "light-dark(rgba(0, 0, 0, 0.9), rgba(255, 255, 255, 0.95))", + accent: "light-dark(rgba(0, 0, 0, 1), rgba(255, 255, 255, 1))", accent20: "light-dark(rgba(0, 0, 0, 0.2), rgba(255, 255, 255, 0.25))", - // The contrasting foreground on the default black/white accent (i.e. secondary). - onAccent: "light-dark(rgb(255, 255, 255), rgb(0, 2, 9))", // Base surface fill (per Figma): light #F2F2F2 @ 90%, dark #1B1B21 @ 95%. background: "light-dark(rgba(242, 242, 242, 0.9), rgba(27, 27, 33, 0.95))", overlay: "light-dark(rgba(0, 2, 9, 0.4), rgba(0, 0, 0, 0.4))", @@ -90,58 +87,6 @@ const customProperties: Theme = { focus: "light-dark(rgba(0, 127, 217, 0.9), rgba(0, 149, 255, 0.7))", }; -/** - * Parses a solid CSS color (hex or rgb/rgba) into `[r, g, b]`. Returns null for - * anything mode-dependent or otherwise unresolvable (e.g. `light-dark(...)`, - * `var(...)`, named colors), where a single foreground can't be derived. - */ -const parseRgb = (color: string): [number, number, number] | null => { - const value = color.trim(); - const hex = /^#([0-9a-f]{3,8})$/i.exec(value); - if (hex != null) { - let h = hex[1]; - if (h.length === 3 || h.length === 4) { - h = h - .split("") - .map((c) => c + c) - .join(""); - } - return [ - parseInt(h.slice(0, 2), 16), - parseInt(h.slice(2, 4), 16), - parseInt(h.slice(4, 6), 16), - ]; - } - const rgb = /^rgba?\(([^)]+)\)$/i.exec(value); - if (rgb != null) { - const parts = rgb[1] - .split(/[,/\s]+/) - .filter(Boolean) - .slice(0, 3) - .map(Number); - if (parts.length === 3 && parts.every((n) => !isNaN(n))) { - return [parts[0], parts[1], parts[2]]; - } - } - return null; -}; - -/** - * Derives a legible foreground color for content on top of `accent`. Colored - * accents keep a light foreground for a branded look; only near-white accents - * flip to dark. Falls back to secondary (light/dark) when the accent isn't a - * resolvable solid color. - */ -const deriveOnAccent = (accent: string): string => { - const rgb = parseRgb(accent); - if (rgb == null) { - return "light-dark(rgb(255, 255, 255), rgb(0, 2, 9))"; - } - const [r, g, b] = rgb; - const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; - return luminance > 0.82 ? "rgb(0, 2, 9)" : "rgb(255, 255, 255)"; -}; - export const intelligentMerge = (theme: Partial): Theme => { const computed: Partial = {}; @@ -149,10 +94,6 @@ export const intelligentMerge = (theme: Partial): Theme => { computed.accent20 = `color-mix(in srgb, ${theme.accent} 20%, transparent)`; } - if (theme.accent != null && theme.onAccent == null) { - computed.onAccent = deriveOnAccent(theme.accent); - } - if (theme.primary != null) { if (theme.primary90 == null) computed.primary90 = `rgb(from ${theme.primary} r g b / 0.9)`; @@ -192,7 +133,7 @@ export const intelligentMerge = (theme: Partial): Theme => { computed.secondary1 = `rgb(from ${theme.secondary} r g b / 0.01)`; } return { - ...customProperties, + ...defaultTheme, ...computed, ...theme, }; diff --git a/src/components/VoiceMini.tsx b/src/components/VoiceMini.tsx index ce4d84b..cf263c8 100644 --- a/src/components/VoiceMini.tsx +++ b/src/components/VoiceMini.tsx @@ -94,6 +94,7 @@ export const VoiceMini: FC<{ ) : null; diff --git a/src/components/ui/CustomCard.tsx b/src/components/ui/CustomCard.tsx index f7c1d5d..7ede827 100644 --- a/src/components/ui/CustomCard.tsx +++ b/src/components/ui/CustomCard.tsx @@ -64,10 +64,13 @@ export const CustomCard: FC = ({ newTab, }) => { const containerClassName = clsx( - "block shrink-0 rounded-inner overflow-hidden", + "relative block shrink-0 rounded-inner bg-secondary-40 overflow-hidden", + "before:content-[''] before:absolute before:inset-0 before:bg-transparent", "w-80 space-y-px", - selected ? "outline-solid outline-2 outline-accent" : "", - onClick != null || href != null ? "hover:bg-primary-5" : "", + "focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-focus", + "border border-solid", + selected ? "border-accent" : "border-primary-10", + onClick != null || href != null ? "hover:before:bg-primary-5" : "", className, ); @@ -174,7 +177,7 @@ export const CustomCardRow: FC = ({
{left}
diff --git a/src/components/ui/DateInput.tsx b/src/components/ui/DateInput.tsx index 850e755..4eb74d1 100644 --- a/src/components/ui/DateInput.tsx +++ b/src/components/ui/DateInput.tsx @@ -105,6 +105,7 @@ export const DateInput: FC = ({ onSubmit, className }) => { : "placeholder-primary-40", )} placeholder="MM / DD / YYYY" + aria-label="Date" onChange={(event) => { const value = event.target.value; setMaskedValue(value); diff --git a/src/components/ui/IconButton.tsx b/src/components/ui/IconButton.tsx index 84ab642..aea4ed8 100644 --- a/src/components/ui/IconButton.tsx +++ b/src/components/ui/IconButton.tsx @@ -10,11 +10,11 @@ import { useAppRoot } from "../../utils/useAppRoot"; * * - `main`: The primary icon button. * - `ghost`: A transparent or less prominent icon button. - * - `sound`: An icon button that indicates an active state. + * - `subtle`: An icon button that indicates an active state. * - `coverup`: An icon button used to cover up or mask something. * @category Modality components */ -export type IconButtonType = "main" | "ghost" | "sound" | "coverup" | "error"; +export type IconButtonType = "main" | "ghost" | "subtle" | "coverup" | "error"; /** * Props for the IconButton component @@ -51,16 +51,16 @@ const mainClass = "bg-primary-90 text-secondary-90 enabled:hover:before:bg-primary-90 enabled:active:before:bg-secondary-10 disabled:bg-primary-10 disabled:text-secondary-40"; const ghostClass = - "text-primary-80 enabled:hover:before:bg-primary-10 enabled:active:before:bg-secondary-10 disabled:text-primary-20"; + "text-primary-60 enabled:hover:before:bg-primary-5 enabled:active:before:bg-secondary-10 disabled:text-primary-20"; -const soundClass = - "bg-accent text-on-accent enabled:hover:before:bg-primary-40 enabled:active:before:bg-secondary-10 disabled:bg-accent-20"; +const subtleClass = + "bg-primary-10 text-primary-80 enabled:hover:before:bg-primary-5 enabled:active:before:bg-primary-10 disabled:bg-primary-5 disabled:text-primary-20"; const coverupClass = - "bg-secondary-60 backdrop-blur-sm text-primary-80 enabled:hover:before:bg-primary-10 enabled:active:before:bg-secondary-10 disabled:bg-secondary-20 disabled:text-primary-20"; + "bg-secondary-60 backdrop-blur-sm text-primary-80 enabled:hover:before:bg-primary-5 enabled:active:before:bg-primary-10 disabled:bg-secondary-20 disabled:text-primary-20"; const errorClass = - "bg-error-primary text-secondary enabled:hover:before:bg-primary-10 disabled:bg-secondary-20 disabled:text-primary-20"; + "bg-error-primary text-secondary-90 enabled:hover:before:bg-primary-5 enabled:active:before:bg-primary-5 disabled:bg-secondary-20 disabled:text-primary-20"; // Used in tags export const UnsemanticIconButton: FC<{ @@ -74,7 +74,7 @@ export const UnsemanticIconButton: FC<{ "block", type === "main" ? mainClass : null, type === "ghost" ? ghostClass : null, - type === "sound" ? soundClass : null, + type === "subtle" ? subtleClass : null, type === "coverup" ? coverupClass : null, type === "error" ? errorClass : null, )} @@ -155,7 +155,7 @@ export const IconButton: FC = ({ baseClass, type === "main" ? mainClass : null, type === "ghost" ? ghostClass : null, - type === "sound" ? soundClass : null, + type === "subtle" ? subtleClass : null, type === "coverup" ? coverupClass : null, type === "error" ? errorClass : null, className, diff --git a/src/components/ui/Icons.tsx b/src/components/ui/Icons.tsx index 9f15a13..66f5648 100644 --- a/src/components/ui/Icons.tsx +++ b/src/components/ui/Icons.tsx @@ -24,6 +24,10 @@ const iconSvgProps = (props: IconProps): SVGProps => ({ width: props.size != null ? `${props.size}px` : "100%", height: props.size != null ? `${props.size}px` : "100%", className: props.className, + // Icons are decorative: the accessible name always comes from the + // surrounding button or text, so keep them out of the accessibility tree. + "aria-hidden": true, + focusable: false, }); export const Action: Icon = (props) => { diff --git a/src/components/ui/LaunchButton.tsx b/src/components/ui/LaunchButton.tsx index bf8c869..bdc1138 100644 --- a/src/components/ui/LaunchButton.tsx +++ b/src/components/ui/LaunchButton.tsx @@ -27,12 +27,18 @@ export const LaunchButton: FC = (props) => { )} disabled={props.onClick == null} onClick={props.onClick} + aria-label={props.label} > {props.iconUrl == null ? ( ) : ( - + )} {(props.showLabel ?? false) ? {props.label} : null} diff --git a/src/components/ui/Loader.tsx b/src/components/ui/Loader.tsx index f5b12e3..4721ce8 100644 --- a/src/components/ui/Loader.tsx +++ b/src/components/ui/Loader.tsx @@ -132,7 +132,7 @@ export const LoaderAnimation: FC = () => { stroke="none" fill="currentColor" style={{ - filter: `drop-shadow(0 0 ${dropShadowRadius}px var(--accent))`, + filter: `drop-shadow(0 0 ${dropShadowRadius}px var(--color-focus))`, }} > @@ -150,8 +150,11 @@ export const Loader: FC = ({ label, className }) => { className, )} > -
-
+
+ {label != null ? ( diff --git a/src/components/ui/MessageButton.tsx b/src/components/ui/MessageButton.tsx index c4f0941..86789fd 100644 --- a/src/components/ui/MessageButton.tsx +++ b/src/components/ui/MessageButton.tsx @@ -5,16 +5,14 @@ import { type IconProps } from "./Icons"; import { HeadlessIconButton } from "./IconButton"; /** - * Represents the different types of icon buttons available in the application. + * Represents the different types of message buttons available in the application. * - * - `main`: The primary icon button. - * - `ghost`: A transparent or less prominent icon button. - * - `activated`: An icon button that indicates an active state. - * - `coverup`: An icon button used to cover up or mask something. - * - `overlay`: An icon button that appears over other content. + * - `default`: The default message button. + * - `selected`: A message button showing the selected state. + * - `unselected`: A message button showing the unselected state. * @category Modality components */ -export type MessageButtonType = "main" | "activated"; +export type MessageButtonType = "default" | "selected" | "unselected"; /** * Props for the MessageButton component @@ -36,8 +34,9 @@ export interface MessageButtonProps { className?: string; /** * Visual style variant of the button. One of MessageButtonType. + * @default "default" */ - type: MessageButtonType; + type?: MessageButtonType; /** * Icon component to display inside the button */ @@ -45,13 +44,16 @@ export interface MessageButtonProps { } const baseClass = - "p-2.5 w-8 h-8 transition-colors rounded-inner relative z-10 overflow-hidden focus:outline-0"; + "p-2.5 w-8 h-8 transition-colors rounded-inner relative z-10 overflow-hidden focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-focus"; -const mainClass = - "text-primary-60 hover:bg-primary-10 focus:bg-primary-10 active:bg-secondary-20 disabled:text-primary-20"; +const defaultClass = + "text-primary-60 enabled:hover:bg-primary-5 enabled:active:bg-primary-10 disabled:text-primary-20"; -const activatedClass = - "bg-accent text-on-accent enabled:hover:before:bg-primary-40 focus:before:bg-primary-40 enabled:active:before:bg-secondary-10 disabled:bg-accent-20"; +const selectedClass = + "text-primary-90 enabled:hover:bg-primary-5 enabled:active:bg-primary-10 disabled:text-primary-40"; + +const unselectedClass = + "text-primary-40 enabled:hover:bg-primary-5 enabled:active:bg-primary-10 disabled:text-primary-10"; /** * A button showing only an icon (textual label is provided for accessibility) @@ -63,7 +65,6 @@ const activatedClass = * alert('Icon button clicked!')} - * type="main" * Icon={Icons.ArrowForward} * /> * ); @@ -72,7 +73,7 @@ const activatedClass = */ export const MessageButton: FC = ({ onClick, - type, + type = "default", label, className, Icon, @@ -83,8 +84,9 @@ export const MessageButton: FC = ({ label={label} className={clsx( baseClass, - type === "main" ? mainClass : null, - type === "activated" ? activatedClass : null, + type === "default" ? defaultClass : null, + type === "selected" ? selectedClass : null, + type === "unselected" ? unselectedClass : null, className, )} > diff --git a/src/components/ui/Radio.tsx b/src/components/ui/Radio.tsx deleted file mode 100644 index 85ca58a..0000000 --- a/src/components/ui/Radio.tsx +++ /dev/null @@ -1,53 +0,0 @@ -/* eslint-disable jsdoc/require-jsdoc */ -import { type FC } from "react"; -import { clsx } from "clsx"; - -export interface RadioOption { - value: T; - label: string; -} - -interface RadioProps { - options: Array>; - value: T; - onChange?: (value: T) => void; - name: string; - className?: string; -} - -export const Radio: FC> = ({ - options, - value, - onChange, - name, - className, -}) => { - const disabled = onChange == null; - - return ( -
- {options.map((option) => ( - - ))} -
- ); -}; diff --git a/src/index.css b/src/index.css index ccc6882..6a2de6e 100644 --- a/src/index.css +++ b/src/index.css @@ -34,7 +34,6 @@ --color-secondary-1: inherit; --color-accent: inherit; --color-accent-20: inherit; - --color-on-accent: inherit; --color-background: inherit; --color-overlay: inherit; --color-warning-primary: inherit; diff --git a/src/index.tsx b/src/index.tsx index dbb54f3..f170dbb 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -8,6 +8,7 @@ import cssRaw from "./index.css?inline"; import * as Icons from "./components/ui/Icons"; import { TextButton } from "./components/ui/TextButton"; import { IconButton } from "./components/ui/IconButton"; +import { MessageButton } from "./components/ui/MessageButton"; import { Ripple } from "./components/Ripple"; import { BaseText, SmallText } from "./components/ui/Typography"; import { @@ -60,7 +61,7 @@ const createHtml = ( * * const MyCustomModality = ({data, conversationHandler}) => * html`
- * + * {cancel();}} /> * void; onExpand: () => void; windowSize: WindowSize; + /** Theme overrides layered on top of the mock's own theme. */ + theme?: Partial; }> = (props) => { const colorMode = props.colorMode ?? "dark"; const { isExpanded, onClose, onExpand, windowSize } = props; + const theme = { ...mockTheme, ...props.theme }; const [settingsOpen, setSettingsOpen] = useState(false); @@ -30,7 +33,7 @@ export const MockText: FC<{ return ( @@ -49,7 +52,7 @@ export const MockText: FC<{ "grid grid-cols-2 xl:grid-cols-[1fr_632px]", props.embedded ? "w-full h-full" : "fixed inset-0 z-touchpoint", )} - theme={mockTheme} + theme={theme} colorMode={colorMode} languageCode="en-US" > diff --git a/src/mocks/MockVoice.tsx b/src/mocks/MockVoice.tsx index 488b3f3..e88011b 100644 --- a/src/mocks/MockVoice.tsx +++ b/src/mocks/MockVoice.tsx @@ -8,7 +8,7 @@ import { Main, HeaderContainer } from "../components/Layout"; import { IconButton } from "../components/ui/IconButton"; import { Close, Mic, Settings as SettingsIcon } from "../components/ui/Icons"; import { mockConversationHandler, mockTheme, responses } from "./shared"; -import { type WindowSize, type ColorMode } from "../interface"; +import { type WindowSize, type ColorMode, type Theme } from "../interface"; import { VoiceIcon } from "../components/FullscreenVoice"; import { defaultModalities } from "../components/defaultModalities"; import { VoiceModalities } from "../components/VoiceModalities"; @@ -22,9 +22,12 @@ export const MockVoice: FC<{ onClose: () => void; onExpand: () => void; windowSize: WindowSize; + /** Theme overrides layered on top of the mock's own theme. */ + theme?: Partial; }> = (props) => { const colorMode = props.colorMode ?? "dark"; const { isExpanded, onClose, onExpand, windowSize } = props; + const theme = { ...mockTheme, ...props.theme }; const [settingsOpen, setSettingsOpen] = useState(false); @@ -32,7 +35,7 @@ export const MockVoice: FC<{ return ( @@ -51,7 +54,7 @@ export const MockVoice: FC<{ "grid grid-cols-2 xl:grid-cols-[1fr_632px]", props.embedded ? "w-full h-full" : "fixed inset-0 z-touchpoint", )} - theme={mockTheme} + theme={theme} colorMode={colorMode} languageCode="en-US" > @@ -131,7 +134,7 @@ export const MockVoice: FC<{ {}} />
diff --git a/src/mocks/MockVoiceMini.tsx b/src/mocks/MockVoiceMini.tsx index 3f5a97d..5291b37 100644 --- a/src/mocks/MockVoiceMini.tsx +++ b/src/mocks/MockVoiceMini.tsx @@ -6,7 +6,7 @@ import { clsx } from "clsx"; import { IconButton } from "../components/ui/IconButton"; import { Close } from "../components/ui/Icons"; import { mockConversationHandler, mockTheme, responses } from "./shared"; -import { type ColorMode } from "../interface"; +import { type ColorMode, type Theme } from "../interface"; import { VoiceMiniControls, voiceMiniPanelClass } from "../components/Layout"; import { VoiceModalities } from "../components/VoiceModalities"; import { defaultModalities } from "../components/defaultModalities"; @@ -16,15 +16,18 @@ export const MockVoiceMini: FC<{ isExpanded: boolean; onClose: () => void; onExpand: () => void; + /** Theme overrides layered on top of the mock's own theme. */ + theme?: Partial; }> = (props) => { const colorMode = props.colorMode ?? "dark"; const { isExpanded, onClose, onExpand } = props; + const theme = { ...mockTheme, ...props.theme }; if (!isExpanded) { return ( @@ -40,7 +43,7 @@ export const MockVoiceMini: FC<{ return ( diff --git a/src/playground/App.tsx b/src/playground/App.tsx index 26b8483..11aae15 100644 --- a/src/playground/App.tsx +++ b/src/playground/App.tsx @@ -3,12 +3,12 @@ import type { ColorMode } from "../interface"; import { TopBar } from "./components/TopBar"; import { ConfigScreen } from "./screens/ConfigScreen"; import { GuideScreen } from "./screens/GuideScreen"; +import { useColorMode } from "./colorMode"; import { type Settings, settingsFromParams, writeSettingsToUrl, } from "./settings"; -import { useTheme } from "./theme"; /** What the guide needs, captured at launch so later edits can't disturb it. */ interface Launched { @@ -24,7 +24,7 @@ interface Launched { * carry a whole setup. */ export const App: FC = () => { - const [theme, setTheme] = useTheme(); + const [colorMode, setColorMode] = useColorMode(); const [settings, setSettings] = useState(() => settingsFromParams(new URLSearchParams(window.location.search)), ); @@ -32,7 +32,7 @@ export const App: FC = () => { return ( <> - +
{launched == null ? ( { settings: trimmed, // Contrast the widget against the page: dark page → light // widget, and vice versa. - colorMode: theme === "dark" ? "light" : "dark", + colorMode: colorMode === "dark" ? "light" : "dark", }); }} /> diff --git a/src/playground/colorMode.ts b/src/playground/colorMode.ts new file mode 100644 index 0000000..d19ca54 --- /dev/null +++ b/src/playground/colorMode.ts @@ -0,0 +1,41 @@ +import { useCallback, useEffect, useState } from "react"; +import { type ColorMode } from "../interface"; + +const STORAGE_KEY = "lsColorMode"; + +/** + * Reads the playground page's stored color mode. Only `light`/`dark` are + * offered — the playground UI has no `light dark` option. + */ +const readStoredColorMode = (): ColorMode => { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored === "light" || stored === "dark") { + return stored; + } + } catch (_e) { + /* localStorage unavailable */ + } + return "dark"; +}; + +/** + * Page color mode state, mirrored onto `` (which the + * palette in `playground.css` keys off) and persisted to local storage. + * `index.html` reads the same key before first paint to avoid a flash of the + * wrong palette. + */ +export const useColorMode = (): [ColorMode, (mode: ColorMode) => void] => { + const [colorMode, setColorMode] = useState(readStoredColorMode); + + useEffect(() => { + document.documentElement.dataset.colorMode = colorMode; + try { + localStorage.setItem(STORAGE_KEY, colorMode); + } catch (_e) { + /* localStorage unavailable */ + } + }, [colorMode]); + + return [colorMode, useCallback((next: ColorMode) => setColorMode(next), [])]; +}; diff --git a/src/playground/components/TopBar.tsx b/src/playground/components/TopBar.tsx index 16f40ce..d6981fa 100644 --- a/src/playground/components/TopBar.tsx +++ b/src/playground/components/TopBar.tsx @@ -1,11 +1,11 @@ import clsx from "clsx"; import { type FC, useState } from "react"; +import type { ColorMode } from "../../interface"; import { Link, useRouter } from "../Router"; import { activePageHref, PAGES } from "../routes"; -import type { PageTheme } from "../theme"; import { BrandMark, CloseIcon, MenuIcon, MoonIcon, SunIcon } from "../ui/icons"; -const THEMES: { value: PageTheme; label: string; icon: FC }[] = [ +const THEMES: { value: ColorMode; label: string; icon: FC }[] = [ { value: "light", label: "Light", icon: SunIcon }, { value: "dark", label: "Dark", icon: MoonIcon }, ]; @@ -13,9 +13,9 @@ const THEMES: { value: PageTheme; label: string; icon: FC }[] = [ /** Light/dark switch for the page (independent of the widget's color mode). */ const ThemeToggle: FC<{ /** Active theme. */ - theme: PageTheme; + theme: ColorMode; /** Called with the newly selected theme. */ - onChange: (theme: PageTheme) => void; + onChange: (theme: ColorMode) => void; }> = ({ theme, onChange }) => (
void; + onThemeChange: (theme: ColorMode) => void; }> = ({ theme, onThemeChange }) => { const { hash } = useRouter(); const [menuOpen, setMenuOpen] = useState(false); diff --git a/src/playground/customTheme.ts b/src/playground/customTheme.ts new file mode 100644 index 0000000..2a5f4a7 --- /dev/null +++ b/src/playground/customTheme.ts @@ -0,0 +1,113 @@ +import { useSyncExternalStore } from "react"; + +/** Theme color keys the playground exposes for live editing. */ +export type EditableColorKey = "accent" | "primary" | "secondary"; + +/** + * The editable colors, in the order they appear in the design system. Editing + * only these three is enough: `intelligentMerge` (see `components/Theme.tsx`) + * derives `accent20`/`onAccent` from `accent` and every opacity variant from + * `primary`/`secondary`, so the rest of the palette follows for free. + */ +export const EDITABLE_COLOR_KEYS: EditableColorKey[] = [ + "accent", + "primary", + "secondary", +]; + +/** Overrides map: only edited keys are present. Shape is a `Partial`. */ +export type ColorOverrides = Partial>; + +const STORAGE_KEY = "lsCustomTheme"; + +/** Narrows an arbitrary string to one of the editable color keys. */ +export const isEditableColorKey = (key: string): key is EditableColorKey => + (EDITABLE_COLOR_KEYS as string[]).includes(key); + +const readStored = (): ColorOverrides => { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw == null) { + return {}; + } + const parsed = JSON.parse(raw) as unknown; + if (parsed == null || typeof parsed !== "object") { + return {}; + } + const result: ColorOverrides = {}; + for (const [key, value] of Object.entries(parsed)) { + if (isEditableColorKey(key) && typeof value === "string") { + result[key] = value; + } + } + return result; + } catch (_e) { + return {}; + } +}; + +let overrides: ColorOverrides = readStored(); +const listeners = new Set<() => void>(); + +const persist = (): void => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(overrides)); + } catch (_e) { + /* localStorage unavailable */ + } +}; + +const emit = (): void => { + for (const listener of listeners) { + listener(); + } +}; + +/** + * Module-level store for the playground's custom theme. It lives outside React + * (rather than in a context) because the design-system specimens render in + * their own React root inside a shadow DOM — a context can't cross that + * boundary, but a singleton subscribed to via `useSyncExternalStore` can. The + * overrides are persisted to local storage and restored on load. + */ +export const customThemeStore = { + subscribe: (listener: () => void): (() => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + getSnapshot: (): ColorOverrides => overrides, + setColor: (key: EditableColorKey, value: string): void => { + overrides = { ...overrides, [key]: value }; + persist(); + emit(); + }, + resetColor: (key: EditableColorKey): void => { + if (!(key in overrides)) { + return; + } + // Rebuild without the key rather than `delete` (which lint disallows on a + // dynamically computed key). + overrides = Object.fromEntries( + Object.entries(overrides).filter(([existing]) => existing !== key), + ); + persist(); + emit(); + }, + restoreDefaults: (): void => { + if (Object.keys(overrides).length === 0) { + return; + } + overrides = {}; + persist(); + emit(); + }, +}; + +/** Subscribes to the custom-theme overrides. Safe across React roots. */ +export const useCustomTheme = (): ColorOverrides => + useSyncExternalStore( + customThemeStore.subscribe, + customThemeStore.getSnapshot, + ); diff --git a/src/playground/designSystem/DesignSystem.tsx b/src/playground/designSystem/DesignSystem.tsx index 2d8643d..1f8270a 100644 --- a/src/playground/designSystem/DesignSystem.tsx +++ b/src/playground/designSystem/DesignSystem.tsx @@ -8,7 +8,10 @@ import { MockVoiceMini } from "../../mocks/MockVoiceMini"; import { TopBar } from "../components/TopBar"; import { Link, useRouter } from "../Router"; import { DESIGN_SYSTEM_ROUTE } from "../routes"; -import { useTheme } from "../theme"; +import { useColorMode } from "../colorMode"; +import { useCustomTheme } from "../customTheme"; +import { CodeBlock } from "../ui/CodeBlock"; +import { Disclosure } from "../ui/Disclosure"; import { Segmented } from "../ui/Segmented"; import { LibrarySurface } from "./LibrarySurface"; import { MockHost } from "./MockHost"; @@ -49,7 +52,9 @@ const WINDOW_SIZE_OPTIONS: { value: WindowSize; label: string }[] = [ * specimen is showing, switchable from the sidebar or the 1/2/3 keys. */ export const DesignSystem: FC = () => { - const [theme, setTheme] = useTheme(); + const [colorMode, setColorMode] = useColorMode(); + // The playground's custom color overrides, propagated to the preview frames. + const customTheme = useCustomTheme(); // The fragment is the address of a specimen, so back/forward and a pasted // link both land on the right one. const { hash } = useRouter(); @@ -73,10 +78,7 @@ export const DesignSystem: FC = () => { }); useEffect(() => { - sessionStorage.setItem( - "touchpoint-isMockExpanded", - String(isMockExpanded), - ); + sessionStorage.setItem("touchpoint-isMockExpanded", String(isMockExpanded)); }, [isMockExpanded]); const [windowSize, setWindowSize] = useState(() => { @@ -134,9 +136,7 @@ export const DesignSystem: FC = () => { [activeMock], ); - useKeyboardEvent((event) => event.code === "Enter", toggleMock, [ - toggleMock, - ]); + useKeyboardEvent((event) => event.code === "Enter", toggleMock, [toggleMock]); useKeyboardEvent((event) => event.code === "Escape", collapseMock, [ collapseMock, @@ -144,7 +144,7 @@ export const DesignSystem: FC = () => { return ( <> - + {/* Same max width and gutters as the TopBar and the launch form, so the header rule lines up with the content below it. */}
@@ -210,19 +210,29 @@ export const DesignSystem: FC = () => {

{active != null && ( - + {/* Keyed so switching specimens starts each gallery fresh rather than reconciling one into the next. */} )} + {active?.code != null && ( + // Keyed so the disclosure collapses again when switching specimens. + + + + )}
{activeMock === "mock1" && ( { {activeMock === "mock2" && ( { )} {activeMock === "mock3" && ( = { @@ -27,6 +28,9 @@ export const LibrarySurface: FC<{ /** The components to show. */ children: ReactNode; }> = ({ colorMode, children }) => { + // The playground's custom theme, applied so every specimen (including the + // Colors gallery's own swatches) reflects the edited palette live. + const theme = useCustomTheme(); const host = useRef(null); const root = useRef(null); // Attaching the shadow root is a DOM effect, so the first render has no root @@ -53,7 +57,7 @@ export const LibrarySurface: FC<{ @@ -61,7 +65,7 @@ export const LibrarySurface: FC<{ , ); - }, [attached, colorMode, children]); + }, [attached, colorMode, children, theme]); useEffect( () => () => { diff --git a/src/playground/designSystem/specimens.tsx b/src/playground/designSystem/specimens.tsx index b5a4134..d52712d 100644 --- a/src/playground/designSystem/specimens.tsx +++ b/src/playground/designSystem/specimens.tsx @@ -1,5 +1,5 @@ import clsx from "clsx"; -import { type FC, type ReactNode, useState } from "react"; +import { type FC, type ReactNode, useEffect, useState } from "react"; import { Carousel } from "../../components/ui/Carousel"; import { CustomCard, @@ -15,11 +15,20 @@ import * as Icons from "../../components/ui/Icons"; import { type MessageStatus } from "../../interface"; import { LaunchButton } from "../../components/ui/LaunchButton"; import { Loader } from "../../components/ui/Loader"; -import { MessageButton } from "../../components/ui/MessageButton"; +import { + MessageButton, + type MessageButtonType, +} from "../../components/ui/MessageButton"; import { MessageStatusRow } from "../../components/ui/MessageStatusRow"; -import { Radio } from "../../components/ui/Radio"; import { TextButton } from "../../components/ui/TextButton"; import { BaseText, SmallText } from "../../components/ui/Typography"; +import { defaultTheme } from "../../components/Theme"; +import { + customThemeStore, + type EditableColorKey, + isEditableColorKey, + useCustomTheme, +} from "../customTheme"; /* Everything in this file renders inside the library's shadow root (see @@ -69,7 +78,7 @@ const CARD_IMAGE = `data:image/svg+xml;utf8,${encodeURIComponent( const TextButtons: FC = () => ( <> - + ( /> - + ( Icon={Icons.ArrowForward} /> - + ( const ICON_BUTTON_TYPES: IconButtonType[] = [ "main", "ghost", - "sound", + "subtle", "coverup", "error", ]; @@ -114,7 +123,7 @@ const ICON_BUTTON_TYPES: IconButtonType[] = [ const IconButtons: FC = () => ( <> {ICON_BUTTON_TYPES.map((type) => ( - + ( ); +const MESSAGE_BUTTON_TYPES: MessageButtonType[] = [ + "default", + "selected", + "unselected", +]; + const MessageButtons: FC = () => ( <> - - - - - - - - + {MESSAGE_BUTTON_TYPES.map((type) => ( + + + + + ))} ); @@ -301,32 +309,6 @@ const DateInputs: FC = () => { ); }; -const Radios: FC = () => { - const [cabin, setCabin] = useState("economy"); - const options = [ - { value: "economy", label: "Economy" }, - { value: "premium", label: "Premium economy" }, - { value: "business", label: "Business" }, - ]; - return ( - <> - - { - setCabin(String(value)); - }} - /> - - - - - - ); -}; - const Loaders: FC = () => ( <> @@ -407,7 +389,6 @@ const COLOR_GROUPS: ColorGroup[] = [ colors: [ { name: "accent", className: "bg-accent" }, { name: "accent20", className: "bg-accent-20" }, - { name: "onAccent", className: "bg-on-accent" }, { name: "background", className: "bg-background" }, { name: "overlay", className: "bg-overlay" }, ], @@ -439,29 +420,307 @@ const ColorSwatch: FC = ({ name, className }) => (
); -const ColorGrid: FC = () => ( - <> - {COLOR_GROUPS.map((group) => ( -
- {group.label} +/* + The editor UI below renders in the shadow root alongside the swatches, so it + can't rely on the playground's Tailwind theme. Rather than depend on which + utilities the *library* stylesheet happens to emit, it styles itself with + inline styles that read the theme's own CSS custom properties (--color-*, + --radius-inner) — the same variables ProviderStack sets — so the popup tracks + light/dark and the edited palette automatically. +*/ + +/** A subtle text button used inside the color editor and its header. */ +const EditorButton: FC<{ + onClick: () => void; + disabled?: boolean; + children: ReactNode; +}> = ({ onClick, disabled = false, children }) => ( + +); + +/** Matches a `#rgb`/`#rrggbb` color the native picker can display. */ +const HEX_RE = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i; + +/** Popup with a color picker and a single CSS-color input for one color. */ +const ColorEditorPopup: FC<{ + colorKey: EditableColorKey; + onClose: () => void; +}> = ({ colorKey, onClose }) => { + const overrides = useCustomTheme(); + const isEdited = colorKey in overrides; + const value = overrides[colorKey] ?? defaultTheme[colorKey]; + // The native picker only understands hex; when the CSS color isn't one (e.g. + // `rebeccapurple`, `rgb(...)`, `light-dark(...)`) it falls back to the seed + // so it stays usable. + const pickerValue = HEX_RE.test(value.trim()) + ? value.trim() + : defaultTheme[colorKey]; + + useEffect(() => { + const onKey = (event: KeyboardEvent): void => { + if (event.key === "Escape") { + onClose(); + } + }; + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("keydown", onKey); + }; + }, [onClose]); + + return ( + <> + {/* Click-away backdrop; covers the viewport so a click anywhere closes. */} +
+
{ + event.stopPropagation(); + }} + style={{ + position: "absolute", + top: "calc(100% + 8px)", + left: "50%", + transform: "translateX(-50%)", + zIndex: 50, + width: 208, + padding: 12, + display: "flex", + flexDirection: "column", + gap: 10, + borderRadius: "var(--radius-inner)", + border: "1px solid var(--color-primary-20)", + background: "var(--color-background)", + backdropFilter: "blur(8px)", + boxShadow: "0 8px 24px rgba(0, 0, 0, 0.25)", + }} + > + { + customThemeStore.setColor(colorKey, event.target.value); + }} + style={{ + width: "100%", + height: 36, + padding: 0, + border: "none", + background: "none", + cursor: "pointer", + }} + /> + { + customThemeStore.setColor(colorKey, event.target.value); + }} + style={{ + width: "100%", + padding: "6px 8px", + fontSize: 12, + fontFamily: "monospace", + borderRadius: 6, + border: "1px solid var(--color-primary-20)", + background: "transparent", + color: "var(--color-primary)", + }} + />
- {group.colors.map((color) => ( - - ))} + { + customThemeStore.resetColor(colorKey); + }} + > + + Reset + + Done
- ))} - -); + + ); +}; + +/** A swatch that opens {@link ColorEditorPopup}, flagged editable and edited. */ +const EditableColorSwatch: FC<{ + colorKey: EditableColorKey; + className: string; + isOpen: boolean; + onToggle: () => void; + onClose: () => void; +}> = ({ colorKey, className, isOpen, onToggle, onClose }) => { + const overrides = useCustomTheme(); + const isEdited = colorKey in overrides; + return ( +
+ + + {colorKey} + {isEdited ? " (edited)" : ""} + + {isOpen && } +
+ ); +}; + +const ColorGrid: FC = () => { + const overrides = useCustomTheme(); + const [openKey, setOpenKey] = useState(null); + const hasEdits = Object.keys(overrides).length > 0; + + return ( + <> +
+ + Click accent, primary or secondary to edit. Opacity variants derive + automatically. + + {hasEdits && ( + { + customThemeStore.restoreDefaults(); + setOpenKey(null); + }} + > + + Restore defaults + + )} +
+ {COLOR_GROUPS.map((group) => ( +
+ {group.label} +
+ {group.colors.map((color) => { + const key = color.name; + if (!isEditableColorKey(key)) { + return ; + } + return ( + { + setOpenKey((prev) => (prev === key ? null : key)); + }} + onClose={() => { + setOpenKey(null); + }} + /> + ); + })} +
+
+ ))} + + ); +}; /** One entry in the design system's navigation. */ export interface Specimen { @@ -473,6 +732,13 @@ export interface Specimen { description: string; /** The gallery of variants. */ Component: FC; + /** + * `html`-tagged-template snippet reproducing this gallery in a custom + * modality, shown only for components exported to that `html` instance + * (see `src/index.tsx`). Omitted for gallery-only entries like colors, + * icons, or components not exposed to custom modalities. + */ + code?: string; } /** Every component gallery, in navigation order. */ @@ -490,6 +756,24 @@ export const SPECIMENS: Specimen[] = [ description: "Full-width buttons with a visible label. Omitting onClick disables the button.", Component: TextButtons, + code: `import { html } from "@amazon-connect-touchpoint/web"; + +const MyModality = ({ conversationHandler }) => html\` +
+ conversationHandler.sendText("Confirm")} + /> + conversationHandler.sendText("Cancel")} + /> +
+\`;`, }, { id: "icon-buttons", @@ -497,12 +781,48 @@ export const SPECIMENS: Specimen[] = [ description: "Round icon-only buttons; the label becomes the accessible name and the tooltip.", Component: IconButtons, + code: `import { html } from "@amazon-connect-touchpoint/web"; + +const MyModality = ({ conversationHandler }) => html\` +
+ conversationHandler.sendText("Dismiss")} + /> + conversationHandler.sendText("Dismiss")} + /> +
+\`;`, }, { id: "message-buttons", title: "Message buttons", description: "Compact icon buttons used within the message transcript.", Component: MessageButtons, + code: `import { html } from "@amazon-connect-touchpoint/web"; + +const MyModality = ({ data, conversationHandler }) => html\` +
+ conversationHandler.sendText("Like")} + /> + conversationHandler.sendText("Dislike")} + /> +
+\`;`, }, { id: "message-status-row", @@ -523,6 +843,15 @@ export const SPECIMENS: Specimen[] = [ title: "Typography", description: "The two text primitives available to custom modalities.", Component: Typography, + code: `import { html } from "@amazon-connect-touchpoint/web"; + +const MyModality = () => html\` +
+ This is some standard text. + This is some faded text. + This is some small text. +
+\`;`, }, { id: "cards", @@ -530,6 +859,21 @@ export const SPECIMENS: Specimen[] = [ description: "Composable cards: rows of left/right content, an image row, and selected/clickable/link states.", Component: Cards, + code: `import { html } from "@amazon-connect-touchpoint/web"; + +const MyModality = ({ data, conversationHandler }) => html\` + conversationHandler.sendText(data.label)} + > + + \${data.label}\`} + right=\${html\`\${data.price}\`} + icon=\${Icons.ArrowForward} + /> + +\`;`, }, { id: "carousel", @@ -537,6 +881,33 @@ export const SPECIMENS: Specimen[] = [ description: "Horizontally scrollable row of cards, draggable with the pointer.", Component: Carousels, + code: `import { html } from "@amazon-connect-touchpoint/web"; + +// This modality expects the application message to provide a "cities" array, e.g.: +// { +// "cities": [ +// { "name": "Seattle", "price": "from $189", "image": "https://example.com/seattle.jpg" }, +// { "name": "Portland", "price": "from $189", "image": "https://example.com/portland.jpg" }, +// { "name": "Vancouver", "price": "from $189", "image": "https://example.com/vancouver.jpg" }, +// { "name": "San Diego", "price": "from $189", "image": "https://example.com/san-diego.jpg" }, +// { "name": "Austin", "price": "from $189", "image": "https://example.com/austin.jpg" } +// ] +// } +const MyModality = ({ data, conversationHandler }) => html\` + + \${data.cities.map( + (city) => html\` + conversationHandler.sendText(city.name)}> + + \${city.name}\`} + right=\${html\`\${city.price}\`} + /> + + \`, + )} + +\`;`, }, { id: "date-input", @@ -544,12 +915,11 @@ export const SPECIMENS: Specimen[] = [ description: "Masked date field with a native picker; submits an ISO (YYYY-MM-DD) date.", Component: DateInputs, - }, - { - id: "radio", - title: "Radio", - description: "Single-choice list.", - Component: Radios, + code: `import { html } from "@amazon-connect-touchpoint/web"; + +const MyModality = ({ conversationHandler }) => html\` + conversationHandler.sendText(date)} /> +\`;`, }, { id: "loader", @@ -562,5 +932,16 @@ export const SPECIMENS: Specimen[] = [ title: "Icons", description: "Every icon exported as `Icons` from the package.", Component: IconGrid, + code: `import { html } from "@amazon-connect-touchpoint/web"; + +// Icons are available under their own name, without an "Icons." prefix. +const MyModality = () => html\` +
+ + + + +
+\`;`, }, ]; diff --git a/src/playground/playground.css b/src/playground/playground.css index f08e620..82eb5e9 100644 --- a/src/playground/playground.css +++ b/src/playground/playground.css @@ -17,7 +17,7 @@ /* Palette tokens are aliases (`@theme inline`) so the generated utilities emit `var(--pg-…)` rather than a resolved color. That is what lets a single - `bg-card` respond to the `[data-theme]` switch below. + `bg-card` respond to the `[data-color-mode]` switch below. */ @theme inline { --color-canvas: var(--pg-bg); @@ -45,7 +45,7 @@ } :root, -[data-theme="light"] { +[data-color-mode="light"] { color-scheme: light; --pg-bg: #ffffff; --pg-card: #ffffff; @@ -71,7 +71,7 @@ --pg-danger: #e5484d; } -[data-theme="dark"] { +[data-color-mode="dark"] { color-scheme: dark; --pg-bg: #0f1419; --pg-card: #1a2029; diff --git a/src/playground/screens/GuideScreen.tsx b/src/playground/screens/GuideScreen.tsx index a1b932a..a640e19 100644 --- a/src/playground/screens/GuideScreen.tsx +++ b/src/playground/screens/GuideScreen.tsx @@ -11,6 +11,7 @@ import { SECTION_IDS, SECTIONS, } from "../sections"; +import { useCustomTheme } from "../customTheme"; import { type Settings, UUID_RE } from "../settings"; import { buildCreateSnippet, buildStepSnippet } from "../snippets"; import { useTouchpoint } from "../useTouchpoint"; @@ -80,6 +81,7 @@ export const GuideScreen: FC<{ const [demo, demoActions] = useDemoState(); const flightSearch = useFlightSearch(); const activeId = useActiveSection(SECTION_IDS); + const customTheme = useCustomTheme(); const [contactId, setContactId] = useState(""); const [contactResult, setContactResult] = useState(null); @@ -97,7 +99,12 @@ export const GuideScreen: FC<{ ); const touchpoint = useTouchpoint({ settings, colorMode, context }); - const createSnippet = buildCreateSnippet({ settings, colorMode, contactId }); + const createSnippet = buildCreateSnippet({ + settings, + colorMode, + contactId, + theme: customTheme, + }); const stepSnippet = buildStepSnippet(step); const malformedContactId = contactId !== "" && !UUID_RE.test(contactId); diff --git a/src/playground/snippets.ts b/src/playground/snippets.ts index d123451..ed441f7 100644 --- a/src/playground/snippets.ts +++ b/src/playground/snippets.ts @@ -1,4 +1,5 @@ import type { ColorMode } from "../interface"; +import type { ColorOverrides } from "./customTheme"; import { isLiveSyncConfigured, isVoiceMode, @@ -16,6 +17,8 @@ interface CreateSnippetParams { colorMode: ColorMode; /** Contact ID currently entered in the Live Sync section, if any. */ contactId: string; + /** Custom theme color overrides set in the design system, if any. */ + theme: ColorOverrides; } /** @@ -30,7 +33,9 @@ export const buildCreateSnippet = ({ settings, colorMode, contactId, + theme, }: CreateSnippetParams): string => { + const themeEntries = Object.entries(theme); const { inputMode, windowSize, avatars, welcomeScreen, avatarShape } = settings; const isText = inputMode === "text"; @@ -76,6 +81,13 @@ export const buildCreateSnippet = ({ ? ` avatarShape: ${q(avatarShape)},` : null, isText && welcomeScreen === "off" ? " welcomeScreen: false," : null, + ...(themeEntries.length > 0 + ? [ + " theme: {", + ...themeEntries.map(([key, value]) => ` ${key}: ${q(value)},`), + " },", + ] + : []), showLiveSync ? " liveSync: {" : null, showLiveSync ? ` deploymentKey: ${q(settings.deploymentKey)},` : null, showLiveSync ? ` apiKey: ${q(settings.apiKey)},` : null, diff --git a/src/playground/theme.ts b/src/playground/theme.ts deleted file mode 100644 index 09e1b6e..0000000 --- a/src/playground/theme.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { useCallback, useEffect, useState } from "react"; - -/** Page theme of the playground itself (not the Touchpoint widget). */ -export type PageTheme = "light" | "dark"; - -const STORAGE_KEY = "lsTheme"; - -const readStoredTheme = (): PageTheme => { - try { - const stored = localStorage.getItem(STORAGE_KEY); - if (stored === "light" || stored === "dark") { - return stored; - } - } catch (_e) { - /* localStorage unavailable */ - } - return "dark"; -}; - -/** - * Page theme state, mirrored onto `` (which the palette in - * `playground.css` keys off) and persisted to local storage. `index.html` reads - * the same key before first paint to avoid a flash of the wrong palette. - */ -export const useTheme = (): [PageTheme, (theme: PageTheme) => void] => { - const [theme, setTheme] = useState(readStoredTheme); - - useEffect(() => { - document.documentElement.dataset.theme = theme; - try { - localStorage.setItem(STORAGE_KEY, theme); - } catch (_e) { - /* localStorage unavailable */ - } - }, [theme]); - - return [theme, useCallback((next: PageTheme) => setTheme(next), [])]; -}; diff --git a/src/playground/useTouchpoint.ts b/src/playground/useTouchpoint.ts index 11a69fe..f89c825 100644 --- a/src/playground/useTouchpoint.ts +++ b/src/playground/useTouchpoint.ts @@ -7,6 +7,7 @@ import type { TouchpointInstance, } from "../interface"; import type { ConnectConfig } from "../connect"; +import { customThemeStore } from "./customTheme"; import { isLiveSyncConfigured, type Settings } from "./settings"; const ignore = (): void => { @@ -66,6 +67,9 @@ export const useTouchpoint = (params: UseTouchpointParams): Touchpoint => { const mount = useCallback((contactId?: string) => { const { settings, colorMode } = paramsRef.current; const liveSyncEnabled = isLiveSyncConfigured(settings); + // The custom theme is chosen in the design system; carry it into the live + // widget so a set palette applies here too. + const theme = customThemeStore.getSnapshot(); instance.current?.teardown(); instance.current = null; void create({ @@ -73,6 +77,7 @@ export const useTouchpoint = (params: UseTouchpointParams): Touchpoint => { input: settings.inputMode, colorMode, windowSize: settings.windowSize, + ...(Object.keys(theme).length > 0 ? { theme } : {}), ...(settings.brandIcon !== "" ? { brandIcon: settings.brandIcon } : {}), // Show participant names/avatars in the chat transcript (toggle). showParticipantInfo: settings.avatars === "on",