diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bd66cf6d24b9..690e076ce309 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -98,6 +98,7 @@ jobs: - *shared - 'packages/browser/**' - 'packages/replay/**' + - 'packages/feedback/**' browser_integration: - *shared - *browser diff --git a/.size-limit.js b/.size-limit.js index b00bae9ff5be..36c4212adea6 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -121,4 +121,11 @@ module.exports = [ gzip: true, limit: '57 KB', }, + { + name: '@sentry-internal/feedback - Webpack (gzipped)', + path: 'packages/feedback/build/npm/esm/index.js', + import: '{ Feedback }', + gzip: true, + limit: '35 KB', + }, ]; diff --git a/packages/feedback/README.md b/packages/feedback/README.md index 56be5e23ee7c..4ed6b8efcc55 100644 --- a/packages/feedback/README.md +++ b/packages/feedback/README.md @@ -55,6 +55,7 @@ The following options can be configured as options to the integration, in `new F | key | type | default | description | | --------- | ------- | ------- | ----------- | | `autoInject` | `boolean` | `true` | Injects the Feedback widget into the application when the integration is added. This is useful to turn off if you bring your own button, or only want to show the widget on certain views. | +| `showBranding` | `boolean` | `true` | Displays the Sentry logo inside of the dialog | | `colorScheme` | `"system" \| "light" \| "dark"` | `"system"` | The color theme to use. `"system"` will follow your OS colorscheme. | ### User/form Related Configuration @@ -93,7 +94,7 @@ Most text that you see in the default Feedback widget can be customized. | `formTitle` | `Report a Bug` | The title at the top of the feedback form dialog. | | `nameLabel` | `Name` | The label of the name input field. | | `namePlaceholder` | `Your Name` | The placeholder for the name input field. | -| `emailLabel` | `Email` | The label of the email input field. || +| `emailLabel` | `Email` | The label of the email input field. | | `emailPlaceholder` | `your.email@example.org` | The placeholder for the email input field. | | `messageLabel` | `Description` | The label for the feedback description input field. | | `messagePlaceholder` | `What's the bug? What did you expect?` | The placeholder for the feedback description input field. | diff --git a/packages/feedback/jest.config.js b/packages/feedback/jest.config.js index 24f49ab59a4c..cd02790794a7 100644 --- a/packages/feedback/jest.config.js +++ b/packages/feedback/jest.config.js @@ -1 +1,6 @@ -module.exports = require('../../jest/jest.config.js'); +const baseConfig = require('../../jest/jest.config.js'); + +module.exports = { + ...baseConfig, + testEnvironment: 'jsdom', +}; diff --git a/packages/feedback/package.json b/packages/feedback/package.json index f0d2927d2dd3..2150aa7c4a34 100644 --- a/packages/feedback/package.json +++ b/packages/feedback/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/feedback", - "version": "7.77.0", + "version": "0.0.1-alpha.9", "description": "Sentry SDK integration for user feedback", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/feedback", @@ -23,9 +23,10 @@ "access": "public" }, "dependencies": { - "@sentry/core": "7.70.0", - "@sentry/types": "7.70.0", - "@sentry/utils": "7.70.0" + "@sentry/browser": "7.77.0", + "@sentry/core": "7.77.0", + "@sentry/types": "7.77.0", + "@sentry/utils": "7.77.0" }, "scripts": { "build": "run-p build:transpile build:types build:bundle", @@ -51,7 +52,7 @@ "lint:prettier": "prettier --check \"{src,test,scripts}/**/*.ts\"", "test": "jest", "test:watch": "jest --watch", - "yalc:publish": "ts-node ../../scripts/prepack.ts --bundles && yalc publish ./build/npm --push" + "yalc:publish": "ts-node ../../scripts/prepack.ts --bundles && yalc publish ./build/npm --push --sig" }, "volta": { "extends": "../../package.json" diff --git a/packages/feedback/src/constants.ts b/packages/feedback/src/constants.ts new file mode 100644 index 000000000000..71389e52bc57 --- /dev/null +++ b/packages/feedback/src/constants.ts @@ -0,0 +1,57 @@ +const LIGHT_BACKGROUND = '#ffffff'; +const INHERIT = 'inherit'; +const LIGHT_THEME = { + fontFamily: "'Helvetica Neue', Arial, sans-serif", + fontSize: '14px', + + background: LIGHT_BACKGROUND, + backgroundHover: '#f6f6f7', + foreground: '#2b2233', + border: '1.5px solid rgba(41, 35, 47, 0.13)', + boxShadow: '0px 4px 24px 0px rgba(43, 34, 51, 0.12)', + + success: '#268d75', + error: '#df3338', + + submitBackground: 'rgba(88, 74, 192, 1)', + submitBackgroundHover: 'rgba(108, 95, 199, 1)', + submitBorder: 'rgba(108, 95, 199, 1)', + submitForeground: LIGHT_BACKGROUND, + + cancelBackground: 'transparent', + cancelBackgroundHover: 'var(--background-hover)', + cancelBorder: 'var(--border)', + cancelForeground: 'var(--foreground)', + + inputBackground: INHERIT, + inputForeground: INHERIT, + inputBorder: 'var(--border)', + inputBorderFocus: 'rgba(108, 95, 199, 1)', +}; + +export const DEFAULT_THEME = { + light: LIGHT_THEME, + dark: { + ...LIGHT_THEME, + + background: '#29232f', + backgroundHover: '#352f3b', + foreground: '#ebe6ef', + border: '1.5px solid rgba(235, 230, 239, 0.15)', + + success: '#2da98c', + error: '#f55459', + }, +}; + +export const ACTOR_LABEL = 'Report a Bug'; +export const CANCEL_BUTTON_LABEL = 'Cancel'; +export const SUBMIT_BUTTON_LABEL = 'Send Bug Report'; +export const FORM_TITLE = 'Report a Bug'; +export const EMAIL_PLACEHOLDER = 'your.email@example.org'; +export const EMAIL_LABEL = 'Email'; +export const MESSAGE_PLACEHOLDER = "What's the bug? What did you expect?"; +export const MESSAGE_LABEL = 'Description'; +export const NAME_PLACEHOLDER = 'Your Name'; +export const NAME_LABEL = 'Name'; +export const SUCCESS_MESSAGE_TEXT = 'Thank you for your report!'; diff --git a/packages/feedback/src/index.ts b/packages/feedback/src/index.ts index 834e9dcce670..31efcfca2386 100644 --- a/packages/feedback/src/index.ts +++ b/packages/feedback/src/index.ts @@ -1 +1,2 @@ export { sendFeedbackRequest } from './util/sendFeedbackRequest'; +export { Feedback } from './integration'; diff --git a/packages/feedback/src/integration.ts b/packages/feedback/src/integration.ts new file mode 100644 index 000000000000..c1ff31824258 --- /dev/null +++ b/packages/feedback/src/integration.ts @@ -0,0 +1,324 @@ +import { WINDOW } from '@sentry/browser'; +import type { Integration } from '@sentry/types'; +import { isBrowser, logger } from '@sentry/utils'; + +import { + ACTOR_LABEL, + CANCEL_BUTTON_LABEL, + DEFAULT_THEME, + EMAIL_LABEL, + EMAIL_PLACEHOLDER, + FORM_TITLE, + MESSAGE_LABEL, + MESSAGE_PLACEHOLDER, + NAME_LABEL, + NAME_PLACEHOLDER, + SUBMIT_BUTTON_LABEL, + SUCCESS_MESSAGE_TEXT, +} from './constants'; +import type { FeedbackInternalOptions, OptionalFeedbackConfiguration, Widget } from './types'; +import { mergeOptions } from './util/mergeOptions'; +import { createActorStyles } from './widget/Actor.css'; +import { createShadowHost } from './widget/createShadowHost'; +import { createWidget } from './widget/createWidget'; + +const doc = WINDOW.document; + +/** + * Feedback integration. When added as an integration to the SDK, it will + * inject a button in the bottom-right corner of the window that opens a + * feedback modal when clicked. + */ +export class Feedback implements Integration { + /** + * @inheritDoc + */ + public static id: string = 'Feedback'; + + /** + * @inheritDoc + */ + public name: string; + + /** + * Feedback configuration options + */ + public options: FeedbackInternalOptions; + + /** + * Reference to widget element that is created when autoInject is true + */ + private _widget: Widget | null; + + /** + * List of all widgets that are created from the integration + */ + private _widgets: Set; + + /** + * Reference to the host element where widget is inserted + */ + private _host: HTMLDivElement | null; + + /** + * Refernce to Shadow DOM root + */ + private _shadow: ShadowRoot | null; + + /** + * Tracks if actor styles have ever been inserted into shadow DOM + */ + private _hasInsertedActorStyles: boolean; + + public constructor({ + id = 'sentry-feedback', + showBranding = true, + autoInject = true, + showEmail = true, + showName = true, + useSentryUser = { + email: 'email', + name: 'username', + }, + isAnonymous = false, + isEmailRequired = false, + isNameRequired = false, + + themeDark, + themeLight, + colorScheme = 'system', + + buttonLabel = ACTOR_LABEL, + cancelButtonLabel = CANCEL_BUTTON_LABEL, + submitButtonLabel = SUBMIT_BUTTON_LABEL, + formTitle = FORM_TITLE, + emailPlaceholder = EMAIL_PLACEHOLDER, + emailLabel = EMAIL_LABEL, + messagePlaceholder = MESSAGE_PLACEHOLDER, + messageLabel = MESSAGE_LABEL, + namePlaceholder = NAME_PLACEHOLDER, + nameLabel = NAME_LABEL, + successMessageText = SUCCESS_MESSAGE_TEXT, + + onActorClick, + onDialogClose, + onDialogOpen, + onSubmitError, + onSubmitSuccess, + }: OptionalFeedbackConfiguration = {}) { + // Initializations + this.name = Feedback.id; + + // tsc fails if these are not initialized explicitly constructor, e.g. can't call `_initialize()` + this._host = null; + this._shadow = null; + this._widget = null; + this._widgets = new Set(); + this._hasInsertedActorStyles = false; + + this.options = { + id, + showBranding, + autoInject, + isAnonymous, + isEmailRequired, + isNameRequired, + showEmail, + showName, + useSentryUser, + + colorScheme, + themeDark: { + ...DEFAULT_THEME.dark, + ...themeDark, + }, + themeLight: { + ...DEFAULT_THEME.light, + ...themeLight, + }, + + buttonLabel, + cancelButtonLabel, + submitButtonLabel, + formTitle, + emailLabel, + emailPlaceholder, + messageLabel, + messagePlaceholder, + nameLabel, + namePlaceholder, + successMessageText, + + onActorClick, + onDialogClose, + onDialogOpen, + onSubmitError, + onSubmitSuccess, + }; + } + + /** + * Setup and initialize replay container + */ + public setupOnce(): void { + if (!isBrowser()) { + return; + } + + try { + // TODO: This is only here for hot reloading + if (this._host) { + this.remove(); + } + const existingFeedback = doc.querySelector(`#${this.options.id}`); + if (existingFeedback) { + existingFeedback.remove(); + } + // TODO: End hotloading + + const { autoInject } = this.options; + + if (!autoInject) { + // Nothing to do here + return; + } + + this._widget = this._createWidget(this.options); + } catch (err) { + logger.error(err); + } + } + + /** + * Adds click listener to attached element to open a feedback dialog + */ + public attachTo(el: Element | string, optionOverrides: OptionalFeedbackConfiguration): Widget | null { + try { + const options = mergeOptions(this.options, optionOverrides); + + return this._ensureShadowHost(options, ({ shadow }) => { + const targetEl = + typeof el === 'string' ? doc.querySelector(el) : typeof el.addEventListener === 'function' ? el : null; + + if (!targetEl) { + logger.error('[Feedback] Unable to attach to target element'); + return null; + } + + const widget = createWidget({ shadow, options, attachTo: targetEl }); + this._widgets.add(widget); + return widget; + }); + } catch (err) { + logger.error(err); + return null; + } + } + + /** + * Creates a new widget. Accepts partial options to override any options passed to constructor. + */ + public createWidget(optionOverrides: OptionalFeedbackConfiguration): Widget | null { + try { + return this._createWidget(mergeOptions(this.options, optionOverrides)); + } catch (err) { + logger.error(err); + return null; + } + } + + /** + * Removes a single widget + */ + public removeWidget(widget: Widget | null | undefined): boolean { + if (!widget) { + return false; + } + + try { + if (this._widgets.has(widget)) { + widget.removeActor(); + widget.removeDialog(); + this._widgets.delete(widget); + return true; + } + } catch (err) { + logger.error(err); + } + + return false; + } + + /** + * Removes the Feedback integration (including host, shadow DOM, and all widgets) + */ + public remove(): void { + if (this._host) { + this._host.remove(); + } + this._initialize(); + } + + /** + * Initializes values of protected properties + */ + protected _initialize(): void { + this._host = null; + this._shadow = null; + this._widget = null; + this._widgets = new Set(); + this._hasInsertedActorStyles = false; + } + + /** + * Creates a new widget, after ensuring shadow DOM exists + */ + protected _createWidget(options: FeedbackInternalOptions): Widget | null { + return this._ensureShadowHost(options, ({ shadow }) => { + const widget = createWidget({ shadow, options }); + + if (!this._hasInsertedActorStyles && widget.actor) { + shadow.appendChild(createActorStyles(doc)); + this._hasInsertedActorStyles = true; + } + + this._widgets.add(widget); + return widget; + }); + } + + /** + * Ensures that shadow DOM exists and is added to the DOM + */ + protected _ensureShadowHost( + options: FeedbackInternalOptions, + cb: (createShadowHostResult: ReturnType) => T, + ): T | null { + let needsAppendHost = false; + + // Don't create if it already exists + if (!this._shadow || !this._host) { + const { id, colorScheme, themeLight, themeDark } = options; + const { shadow, host } = createShadowHost({ + id, + colorScheme, + themeLight, + themeDark, + }); + this._shadow = shadow; + this._host = host; + needsAppendHost = true; + } + + // set data attribute on host for different themes + this._host.dataset.sentryFeedbackColorscheme = options.colorScheme; + + const result = cb({ shadow: this._shadow, host: this._host }); + + if (needsAppendHost) { + doc.body.appendChild(this._host); + } + + return result; + } +} diff --git a/packages/feedback/src/sendFeedback.ts b/packages/feedback/src/sendFeedback.ts new file mode 100644 index 000000000000..e149a290e82a --- /dev/null +++ b/packages/feedback/src/sendFeedback.ts @@ -0,0 +1,42 @@ +import type { BrowserClient, Replay } from '@sentry/browser'; +import { getCurrentHub } from '@sentry/core'; +import { getLocationHref } from '@sentry/utils'; + +import type { SendFeedbackOptions } from './types'; +import { sendFeedbackRequest } from './util/sendFeedbackRequest'; + +interface SendFeedbackParams { + message: string; + name?: string; + email?: string; + url?: string; +} + +/** + * Public API to send a Feedback item to Sentry + */ +export function sendFeedback( + { name, email, message, url = getLocationHref() }: SendFeedbackParams, + { includeReplay = true }: SendFeedbackOptions = {}, +): ReturnType { + const client = getCurrentHub().getClient(); + const replay = includeReplay && client ? (client.getIntegrationById('Replay') as Replay | undefined) : undefined; + + // Prepare session replay + replay && replay.flush(); + const replayId = replay && replay.getReplayId(); + + if (!message) { + throw new Error('Unable to submit feedback with empty message'); + } + + return sendFeedbackRequest({ + feedback: { + name, + email, + message, + url, + replay_id: replayId, + }, + }); +} diff --git a/packages/feedback/src/types/index.ts b/packages/feedback/src/types/index.ts index 01a12814c88b..9f4d0609c417 100644 --- a/packages/feedback/src/types/index.ts +++ b/packages/feedback/src/types/index.ts @@ -1,5 +1,8 @@ import type { Event, Primitive } from '@sentry/types'; +import type { ActorComponent } from '../widget/Actor'; +import type { DialogComponent } from '../widget/Dialog'; + export type SentryTags = { [key: string]: Primitive } | undefined; /** @@ -27,3 +30,309 @@ export interface SendFeedbackData { name?: string; }; } + +export interface SendFeedbackOptions { + /** + * Should include replay with the feedback? + */ + includeReplay?: boolean; +} + +/** + * Feedback data expected from UI/form + */ +export interface FeedbackFormData { + message: string; + email?: string; + name?: string; +} + +/** + * General feedback configuration + */ +export interface FeedbackGeneralConfiguration { + /** + * id to use for the main widget container (this will host the shadow DOM) + */ + id: string; + + /** + * Show the Sentry branding + */ + showBranding: boolean; + + /** + * Auto-inject default Feedback actor button to the DOM when integration is + * added. + */ + autoInject: boolean; + + /** + * If true, will not collect user data (email/name). + */ + isAnonymous: boolean; + + /** + * Should the email field be required? + */ + isEmailRequired: boolean; + + /** + * Should the name field be required? + */ + isNameRequired: boolean; + + /** + * Should the email input field be visible? Note: email will still be collected if set via `Sentry.setUser()` + */ + showEmail: boolean; + + /** + * Should the name input field be visible? Note: name will still be collected if set via `Sentry.setUser()` + */ + showName: boolean; + + /** + * Fill in email/name input fields with Sentry user context if it exists. + * The value of the email/name keys represent the properties of your user context. + */ + useSentryUser: { + email: string; + name: string; + }; +} + +/** + * Theme-related configuration + */ +export interface FeedbackThemeConfiguration { + /** + * The colorscheme to use. "system" will choose the scheme based on the user's system settings + */ + colorScheme: 'system' | 'light' | 'dark'; + + /** + * Light theme customization, will be merged with default theme values. + */ + themeLight: FeedbackTheme; + /** + * Dark theme customization, will be merged with default theme values. + */ + themeDark: FeedbackTheme; +} + +/** + * All of the different text labels that can be customized + */ +export interface FeedbackTextConfiguration { + /** + * The label for the Feedback widget button that opens the dialog + */ + buttonLabel: string; + /** + * The label for the Feedback form cancel button that closes dialog + */ + cancelButtonLabel: string; + /** + * The label for the Feedback form submit button that sends feedback + */ + submitButtonLabel: string; + /** + * The title of the Feedback form + */ + formTitle: string; + /** + * Label for the email input + */ + emailLabel: string; + /** + * Placeholder text for Feedback email input + */ + emailPlaceholder: string; + /** + * Label for the message input + */ + messageLabel: string; + /** + * Placeholder text for Feedback message input + */ + messagePlaceholder: string; + /** + * Label for the name input + */ + nameLabel: string; + /** + * Placeholder text for Feedback name input + */ + namePlaceholder: string; + /** + * Message after feedback was sent successfully + */ + successMessageText: string; +} + +/** + * The public callbacks available for the feedback integration + */ +export interface FeedbackCallbacks { + /** + * Callback when dialog is closed + */ + onDialogClose?: () => void; + + /** + * Callback when dialog is opened + */ + onDialogOpen?: () => void; + + /** + * Callback when widget actor is clicked + */ + onActorClick?: () => void; + + /** + * Callback when feedback is successfully submitted + */ + onSubmitSuccess?: () => void; + + /** + * Callback when feedback is unsuccessfully submitted + */ + onSubmitError?: () => void; +} + +/** + * The integration's internal `options` member where every value should be set + */ +export interface FeedbackInternalOptions + extends FeedbackGeneralConfiguration, + FeedbackThemeConfiguration, + FeedbackTextConfiguration, + FeedbackCallbacks {} + +/** + * Partial configuration that overrides default configuration values + */ +export interface OptionalFeedbackConfiguration + extends Omit, 'themeLight' | 'themeDark'> { + themeLight?: Partial; + themeDark?: Partial; +} + +export interface FeedbackTheme { + /** + * Font family for widget + */ + fontFamily: string; + /** + * Font size for widget + */ + fontSize: string; + /** + * Background color for actor and dialog + */ + background: string; + /** + * Background color on hover + */ + backgroundHover: string; + /** + * Border styling for actor and dialog + */ + border: string; + /** + * Box shadow for actor and dialog + */ + boxShadow: string; + /** + * Foreground color (i.e. text color) + */ + foreground: string; + /** + * Success color + */ + success: string; + /** + * Error color + */ + error: string; + + /** + * Background color for the submit button + */ + submitBackground: string; + /** + * Background color when hovering over the submit button + */ + submitBackgroundHover: string; + /** + * Border style for the submit button + */ + submitBorder: string; + /** + * Foreground color for the submit button + */ + submitForeground: string; + + /** + * Background color for the cancel button + */ + cancelBackground: string; + /** + * Background color when hovering over the cancel button + */ + cancelBackgroundHover: string; + /** + * Border style for the cancel button + */ + cancelBorder: string; + /** + * Foreground color for the cancel button + */ + cancelForeground: string; + + /** + * Background color for form inputs + */ + inputBackground: string; + /** + * Foreground color for form inputs + */ + inputForeground: string; + /** + * Border styles for form inputs + */ + inputBorder: string; + /** + * Border styles for form inputs when focused + */ + inputBorderFocus: string; +} + +export interface FeedbackThemes { + dark: FeedbackTheme; + light: FeedbackTheme; +} + +export interface FeedbackComponent { + el: T | null; +} + +/** + * A widget consists of: + * - actor button [that opens dialog] + * - dialog + feedback form + * - shadow root? + */ +export interface Widget { + actor: ActorComponent | undefined; + dialog: DialogComponent | undefined; + + showActor: () => void; + hideActor: () => void; + removeActor: () => void; + + openDialog: () => void; + hideDialog: () => void; + removeDialog: () => void; +} diff --git a/packages/feedback/src/util/handleFeedbackSubmit.ts b/packages/feedback/src/util/handleFeedbackSubmit.ts new file mode 100644 index 000000000000..8388300a5a4c --- /dev/null +++ b/packages/feedback/src/util/handleFeedbackSubmit.ts @@ -0,0 +1,42 @@ +import { sendFeedback } from '../sendFeedback'; +import type { FeedbackFormData, SendFeedbackOptions } from '../types'; +import type { DialogComponent } from '../widget/Dialog'; + +/** + * Calls `sendFeedback` to send feedback, handles UI behavior of dialog. + */ +export async function handleFeedbackSubmit( + dialog: DialogComponent | null, + feedback: FeedbackFormData, + options?: SendFeedbackOptions, +): Promise { + if (!dialog) { + // Not sure when this would happen + return false; + } + + const showFetchError = (): void => { + if (!dialog) { + return; + } + dialog.showError('There was a problem submitting feedback, please wait and try again.'); + }; + + try { + dialog.hideError(); + const resp = await sendFeedback(feedback, options); + + if (!resp) { + // Errored... re-enable submit button + showFetchError(); + return false; + } + + // Success! + return resp; + } catch { + // Errored... re-enable submit button + showFetchError(); + return false; + } +} diff --git a/packages/feedback/src/util/mergeOptions.ts b/packages/feedback/src/util/mergeOptions.ts new file mode 100644 index 000000000000..bfaae524c7f6 --- /dev/null +++ b/packages/feedback/src/util/mergeOptions.ts @@ -0,0 +1,22 @@ +import type { FeedbackInternalOptions, OptionalFeedbackConfiguration } from '../types'; + +/** + * Quick and dirty deep merge for the Feedback integration options + */ +export function mergeOptions( + defaultOptions: FeedbackInternalOptions, + optionOverrides: OptionalFeedbackConfiguration, +): FeedbackInternalOptions { + return { + ...defaultOptions, + ...optionOverrides, + themeDark: { + ...defaultOptions.themeDark, + ...optionOverrides.themeDark, + }, + themeLight: { + ...defaultOptions.themeLight, + ...optionOverrides.themeLight, + }, + }; +} diff --git a/packages/feedback/src/util/setAttributesNS.ts b/packages/feedback/src/util/setAttributesNS.ts new file mode 100644 index 000000000000..74f0889a1f83 --- /dev/null +++ b/packages/feedback/src/util/setAttributesNS.ts @@ -0,0 +1,9 @@ +/** + * Helper function to set a dict of attributes on element (w/ specified namespace) + */ +export function setAttributesNS(el: T, attributes: Record): T { + Object.entries(attributes).forEach(([key, val]) => { + el.setAttributeNS(null, key, val); + }); + return el; +} diff --git a/packages/feedback/src/widget/Actor.css.ts b/packages/feedback/src/widget/Actor.css.ts new file mode 100644 index 000000000000..c0fd8d3bd68f --- /dev/null +++ b/packages/feedback/src/widget/Actor.css.ts @@ -0,0 +1,54 @@ +/** + * Creates