diff --git a/packages/atomic/cypress/e2e/generated-answer-actions.ts b/packages/atomic/cypress/e2e/generated-answer-actions.ts new file mode 100644 index 00000000000..39fa6e36637 --- /dev/null +++ b/packages/atomic/cypress/e2e/generated-answer-actions.ts @@ -0,0 +1,49 @@ +import {RouteAlias} from '../fixtures/fixture-common'; +import {TestFixture, generateComponentHTML} from '../fixtures/test-fixture'; + +export const getStreamInterceptAlias = (streamId: string) => + `${RouteAlias.GenQAStream}-${streamId}`; + +export function mockStreamResponse(streamId: string, body: unknown) { + cy.intercept( + { + method: 'GET', + url: `**/machinelearning/streaming/${streamId}`, + }, + (request) => { + request.reply(200, `data: ${JSON.stringify(body)} \n\n`, { + 'content-type': 'text/event-stream', + }); + } + ).as(getStreamInterceptAlias(streamId).substring(1)); +} + +export function mockStreamError(streamId: string, errorCode: number) { + cy.intercept( + { + method: 'GET', + url: `**/machinelearning/streaming/${streamId}`, + }, + (request) => { + request.reply( + errorCode, + {}, + { + 'content-type': 'text/event-stream', + } + ); + } + ).as(getStreamInterceptAlias(streamId).substring(1)); +} + +export const addGeneratedAnswer = + (streamId?: string) => (fixture: TestFixture) => { + const element = generateComponentHTML('atomic-generated-answer'); + fixture.withElement(element).withCustomResponse((response) => { + if (streamId) { + response.extendedResults = { + generativeQuestionAnsweringId: streamId, + }; + } + }); + }; diff --git a/packages/atomic/cypress/e2e/generated-answer-selectors.ts b/packages/atomic/cypress/e2e/generated-answer-selectors.ts new file mode 100644 index 00000000000..f983ea50d95 --- /dev/null +++ b/packages/atomic/cypress/e2e/generated-answer-selectors.ts @@ -0,0 +1,26 @@ +export const generatedAnswerComponent = 'atomic-generated-answer'; +export const GeneratedAnswerSelectors = { + shadow: () => cy.get(generatedAnswerComponent).shadow(), + container: () => GeneratedAnswerSelectors.shadow().find('[part="container"]'), + content: () => + GeneratedAnswerSelectors.shadow().find('[part="generated-content"]'), + answer: () => + GeneratedAnswerSelectors.shadow().find('[part="generated-text"]'), + headerLabel: () => + GeneratedAnswerSelectors.shadow().find('[part="header-label"]'), + likeButton: () => + GeneratedAnswerSelectors.shadow().find('.feedback-button.like'), + dislikeButton: () => + GeneratedAnswerSelectors.shadow().find('.feedback-button.dislike'), + citation: () => GeneratedAnswerSelectors.shadow().find('.citation'), + citationsLabel: () => + GeneratedAnswerSelectors.shadow().find('.citations-label'), + citationTitle: () => + GeneratedAnswerSelectors.citation().find('.citation-title'), + citationIndex: () => + GeneratedAnswerSelectors.citation().find('.citation-index'), + loader: () => GeneratedAnswerSelectors.shadow().find('.typing-indicator'), + retryContainer: () => + GeneratedAnswerSelectors.shadow().find('[part="retry-container"]'), + retryButton: () => GeneratedAnswerSelectors.retryContainer().find('button'), +}; diff --git a/packages/atomic/cypress/e2e/generated-answer.cypress.ts b/packages/atomic/cypress/e2e/generated-answer.cypress.ts new file mode 100644 index 00000000000..aa4b7fdd8ae --- /dev/null +++ b/packages/atomic/cypress/e2e/generated-answer.cypress.ts @@ -0,0 +1,174 @@ +import {TestFixture} from '../fixtures/test-fixture'; +import { + addGeneratedAnswer, + getStreamInterceptAlias, + mockStreamError, + mockStreamResponse, +} from './generated-answer-actions'; +import {GeneratedAnswerSelectors} from './generated-answer-selectors'; + +describe('Generated Answer Test Suites', () => { + describe('Generated Answer', () => { + function setupGeneratedAnswer(streamId?: string) { + new TestFixture().with(addGeneratedAnswer(streamId)).init(); + } + + describe('when no stream ID is returned', () => { + beforeEach(() => { + setupGeneratedAnswer(); + }); + + it('should not display the component', () => { + GeneratedAnswerSelectors.container().should('not.exist'); + }); + }); + + describe('when a stream ID is returned', () => { + describe('when a message event is received', () => { + const streamId = crypto.randomUUID(); + + const testTextDelta = 'Some text'; + const testMessagePayload = { + payloadType: 'genqa.messageType', + payload: JSON.stringify({ + textDelta: testTextDelta, + }), + finishReason: 'COMPLETED', + }; + + beforeEach(() => { + mockStreamResponse(streamId, testMessagePayload); + setupGeneratedAnswer(streamId); + cy.wait(getStreamInterceptAlias(streamId)); + }); + + it('should log the stream ID in the search event custom data', () => { + TestFixture.getUACustomData().then((customData) => { + expect(customData).to.have.property( + 'generativeQuestionAnsweringId', + streamId + ); + }); + }); + + it('should display the message', () => { + GeneratedAnswerSelectors.answer().should('have.text', testTextDelta); + }); + + it('should display feedback buttons', () => { + GeneratedAnswerSelectors.likeButton().should('exist'); + GeneratedAnswerSelectors.dislikeButton().should('exist'); + }); + }); + + describe('when a citation event is received', () => { + const streamId = crypto.randomUUID(); + + const testCitation = { + id: 'some-id-123', + title: 'Some Title', + uri: 'https://www.coveo.com', + permanentid: 'some-permanent-id-123', + clickUri: 'https://www.coveo.com/en', + }; + const testMessagePayload = { + payloadType: 'genqa.citationsType', + payload: JSON.stringify({ + citations: [testCitation], + }), + finishReason: 'COMPLETED', + }; + + beforeEach(() => { + mockStreamResponse(streamId, testMessagePayload); + setupGeneratedAnswer(streamId); + cy.wait(getStreamInterceptAlias(streamId)); + }); + + it('should display the citation link', () => { + GeneratedAnswerSelectors.citationsLabel().should( + 'have.text', + 'Learn more' + ); + GeneratedAnswerSelectors.citationTitle().should( + 'have.text', + testCitation.title + ); + GeneratedAnswerSelectors.citationIndex().should('have.text', '1'); + GeneratedAnswerSelectors.citation().should( + 'have.attr', + 'href', + testCitation.clickUri + ); + }); + }); + + describe('when an error event is received', () => { + const streamId = crypto.randomUUID(); + + const testErrorPayload = { + finishReason: 'ERROR', + errorMessage: 'An error message', + errorCode: 500, + }; + + beforeEach(() => { + mockStreamResponse(streamId, testErrorPayload); + setupGeneratedAnswer(streamId); + cy.wait(getStreamInterceptAlias(streamId)); + }); + + it('should not display the component', () => { + GeneratedAnswerSelectors.container().should('not.exist'); + }); + }); + + describe('when the stream connection fails', () => { + const streamId = crypto.randomUUID(); + + describe('Non-retryable error (4XX)', () => { + beforeEach(() => { + mockStreamError(streamId, 406); + setupGeneratedAnswer(streamId); + cy.wait(getStreamInterceptAlias(streamId)); + }); + + it('should not show the component', () => { + GeneratedAnswerSelectors.container().should('not.exist'); + }); + }); + + describe('Retryable error', () => { + [500, 429].forEach((errorCode) => { + describe(`${errorCode} error`, () => { + beforeEach(() => { + Cypress.on('uncaught:exception', () => false); + mockStreamError(streamId, 500); + setupGeneratedAnswer(streamId); + cy.wait(getStreamInterceptAlias(streamId)); + }); + + it('should retry the stream 3 times then offer a retry button', () => { + for (let times = 0; times < 3; times++) { + GeneratedAnswerSelectors.container().should('not.exist'); + + cy.wait(getStreamInterceptAlias(streamId)); + } + + const retryAlias = '@retrySearch'; + cy.intercept({ + method: 'POST', + url: '**/rest/search/v2?*', + }).as(retryAlias.substring(1)); + + GeneratedAnswerSelectors.retryButton().click(); + + cy.wait(retryAlias); + }); + }); + }); + }); + }); + }); + }); +}); diff --git a/packages/atomic/cypress/fixtures/fixture-common.ts b/packages/atomic/cypress/fixtures/fixture-common.ts index 60325df129d..1f9f22fb224 100644 --- a/packages/atomic/cypress/fixtures/fixture-common.ts +++ b/packages/atomic/cypress/fixtures/fixture-common.ts @@ -28,6 +28,7 @@ export const RouteAlias = { FacetSearch: '@coveoFacetSearch', Quickview: '@coveoQuickview', Locale: '@locale', + GenQAStream: '@genQAStream', }; export const ConsoleAliases = { diff --git a/packages/atomic/src/components.d.ts b/packages/atomic/src/components.d.ts index 4a9753bd93f..039dac94161 100644 --- a/packages/atomic/src/components.d.ts +++ b/packages/atomic/src/components.d.ts @@ -346,6 +346,8 @@ export namespace Components { } interface AtomicFrequentlyBoughtTogether { } + interface AtomicGeneratedAnswer { + } interface AtomicHtml { /** * Specify if the content should be sanitized, using [`DOMPurify`](https://www.npmjs.com/package/dompurify). @@ -2124,6 +2126,12 @@ declare global { prototype: HTMLAtomicFrequentlyBoughtTogetherElement; new (): HTMLAtomicFrequentlyBoughtTogetherElement; }; + interface HTMLAtomicGeneratedAnswerElement extends Components.AtomicGeneratedAnswer, HTMLStencilElement { + } + var HTMLAtomicGeneratedAnswerElement: { + prototype: HTMLAtomicGeneratedAnswerElement; + new (): HTMLAtomicGeneratedAnswerElement; + }; interface HTMLAtomicHtmlElement extends Components.AtomicHtml, HTMLStencilElement { } var HTMLAtomicHtmlElement: { @@ -2822,6 +2830,7 @@ declare global { "atomic-format-number": HTMLAtomicFormatNumberElement; "atomic-format-unit": HTMLAtomicFormatUnitElement; "atomic-frequently-bought-together": HTMLAtomicFrequentlyBoughtTogetherElement; + "atomic-generated-answer": HTMLAtomicGeneratedAnswerElement; "atomic-html": HTMLAtomicHtmlElement; "atomic-icon": HTMLAtomicIconElement; "atomic-insight-edit-toggle": HTMLAtomicInsightEditToggleElement; @@ -3253,6 +3262,8 @@ declare namespace LocalJSX { } interface AtomicFrequentlyBoughtTogether { } + interface AtomicGeneratedAnswer { + } interface AtomicHtml { /** * Specify if the content should be sanitized, using [`DOMPurify`](https://www.npmjs.com/package/dompurify). @@ -4779,6 +4790,7 @@ declare namespace LocalJSX { "atomic-format-number": AtomicFormatNumber; "atomic-format-unit": AtomicFormatUnit; "atomic-frequently-bought-together": AtomicFrequentlyBoughtTogether; + "atomic-generated-answer": AtomicGeneratedAnswer; "atomic-html": AtomicHtml; "atomic-icon": AtomicIcon; "atomic-insight-edit-toggle": AtomicInsightEditToggle; @@ -4917,6 +4929,7 @@ declare module "@stencil/core" { "atomic-format-number": LocalJSX.AtomicFormatNumber & JSXBase.HTMLAttributes; "atomic-format-unit": LocalJSX.AtomicFormatUnit & JSXBase.HTMLAttributes; "atomic-frequently-bought-together": LocalJSX.AtomicFrequentlyBoughtTogether & JSXBase.HTMLAttributes; + "atomic-generated-answer": LocalJSX.AtomicGeneratedAnswer & JSXBase.HTMLAttributes; "atomic-html": LocalJSX.AtomicHtml & JSXBase.HTMLAttributes; "atomic-icon": LocalJSX.AtomicIcon & JSXBase.HTMLAttributes; "atomic-insight-edit-toggle": LocalJSX.AtomicInsightEditToggle & JSXBase.HTMLAttributes; diff --git a/packages/atomic/src/components/search/atomic-generated-answer/atomic-generated-answer.pcss b/packages/atomic/src/components/search/atomic-generated-answer/atomic-generated-answer.pcss new file mode 100644 index 00000000000..3e05c3d818f --- /dev/null +++ b/packages/atomic/src/components/search/atomic-generated-answer/atomic-generated-answer.pcss @@ -0,0 +1,89 @@ +@import '../../../global/global.pcss'; + +[part='container'] { + box-shadow: 0px 4px 8px rgba(4, 8, 31, 0.08), inset 0px 0px 0px 1px var(--atomic-neutral); +} + +[part='header-label'] { + color: var(--atomic-primary); +} + +[part='generated-text'] { + color: var(--atomic-on-background); +} + +.feedback-buttons { + .feedback-button { + width: 2.2rem; + height: 2.2rem; + + &.active { + &.like { + background: var(--atomic-success-background); + } + + &.dislike { + background: var(--atomic-error-background); + } + } + } +} + +.text-bg-blue { + color: var(--atomic-primary); + background: var(--atomic-primary-background); +} + +.source-citations { + .citations-container { + .citation { + max-width: 160px; + } + + .citation-index { + width: 20px; + height: 20px; + } + } +} + +.typing-indicator { + background-color: var(--atomic-primary-background); + will-change: transform; + width: auto; + border-radius: 50px; + padding: 8px 4px; + display: table; + margin: 0 auto; + position: relative; + animation: 2s bulge infinite ease-out; +} +.typing-indicator span { + height: 8px; + width: 8px; + float: left; + margin: 0 4px; + background-color: #1372ec; + display: block; + border-radius: 50%; + opacity: 0.4; +} +.typing-indicator span:nth-of-type(1) { + animation: 1s blink infinite 0.3333s; +} +.typing-indicator span:nth-of-type(2) { + animation: 1s blink infinite 0.6666s; +} +.typing-indicator span:nth-of-type(3) { + animation: 1s blink infinite 0.9999s; +} +@keyframes blink { + 50% { + opacity: 1; + } +} +@keyframes bulge { + 50% { + transform: scale(1.05); + } +} diff --git a/packages/atomic/src/components/search/atomic-generated-answer/atomic-generated-answer.tsx b/packages/atomic/src/components/search/atomic-generated-answer/atomic-generated-answer.tsx new file mode 100644 index 00000000000..96ff0e9bf07 --- /dev/null +++ b/packages/atomic/src/components/search/atomic-generated-answer/atomic-generated-answer.tsx @@ -0,0 +1,139 @@ +import { + SearchStatus, + SearchStatusState, + buildSearchStatus, + buildGeneratedAnswer, + GeneratedAnswer, + GeneratedAnswerState, +} from '@coveo/headless'; +import {Component, h, State} from '@stencil/core'; +import { + BindStateToController, + InitializableComponent, + InitializeBindings, +} from '../../../utils/initialization-utils'; +import {Heading} from '../../common/heading'; +import {Bindings} from '../atomic-search-interface/atomic-search-interface'; +import {FeedbackButton} from './feedback-button'; +import {GeneratedContent} from './generated-content'; +import {RetryPrompt} from './retry-prompt'; +import {TypingLoader} from './typing-loader'; + +/** + * @internal + */ +@Component({ + tag: 'atomic-generated-answer', + styleUrl: 'atomic-generated-answer.pcss', + shadow: true, +}) +export class AtomicGeneratedAnswer implements InitializableComponent { + @InitializeBindings() public bindings!: Bindings; + public generatedAnswer!: GeneratedAnswer; + public searchStatus!: SearchStatus; + + @BindStateToController('generatedAnswer') + @State() + private generatedAnswerState!: GeneratedAnswerState; + + @BindStateToController('searchStatus') + @State() + private searchStatusState!: SearchStatusState; + + @State() + public error!: Error; + + @State() + hidden = true; + + public initialize() { + this.generatedAnswer = buildGeneratedAnswer(this.bindings.engine); + this.searchStatus = buildSearchStatus(this.bindings.engine); + } + + private get hasRetryableError() { + return ( + !this.searchStatusState.hasError && + this.generatedAnswerState.error?.isRetryable + ); + } + + private get shouldBeHidden() { + const {isLoading, answer, citations} = this.generatedAnswerState; + return ( + !(isLoading || answer !== undefined || citations.length) && + !this.hasRetryableError + ); + } + + private renderContent() { + return ( +
+
+ + {this.bindings.i18n.t('generated-answer-title')} + + + {!this.hasRetryableError && ( + + )} +
+ {this.hasRetryableError ? ( + + ) : ( + + this.generatedAnswer.logCitationClick(citation.id) + } + /> + )} +
+ ); + } + + public render() { + if (this.shouldBeHidden) { + return null; + } + return ( + + ); + } +} diff --git a/packages/atomic/src/components/search/atomic-generated-answer/feedback-button.tsx b/packages/atomic/src/components/search/atomic-generated-answer/feedback-button.tsx new file mode 100644 index 00000000000..16eb2244147 --- /dev/null +++ b/packages/atomic/src/components/search/atomic-generated-answer/feedback-button.tsx @@ -0,0 +1,35 @@ +import {FunctionalComponent, h} from '@stencil/core'; +import ThumbsDownIcon from '../../../images/thumbs-down.svg'; +import ThumbsUpIcon from '../../../images/thumbs-up.svg'; +import {Button} from '../../common/button'; + +type FeedbackVariant = 'like' | 'dislike'; + +interface FeedbackButtonProps { + title: string; + variant: FeedbackVariant; + active: boolean; + onClick: () => void; +} + +export const FeedbackButton: FunctionalComponent = ( + props +) => { + const getIcon = () => { + return props.variant === 'like' ? ThumbsUpIcon : ThumbsDownIcon; + }; + + return ( + + ); +}; diff --git a/packages/atomic/src/components/search/atomic-generated-answer/generated-content.tsx b/packages/atomic/src/components/search/atomic-generated-answer/generated-content.tsx new file mode 100644 index 00000000000..899352dfc22 --- /dev/null +++ b/packages/atomic/src/components/search/atomic-generated-answer/generated-content.tsx @@ -0,0 +1,25 @@ +import {GeneratedAnswerCitation} from '@coveo/headless'; +import {FunctionalComponent, h} from '@stencil/core'; +import {SourceCitations} from './source-citations'; + +interface GeneratedContentProps { + answer?: string; + citationsLabel: string; + citations: GeneratedAnswerCitation[]; + onCitationClick: (citation: GeneratedAnswerCitation) => void; +} + +export const GeneratedContent: FunctionalComponent = ( + props +) => ( +
+

+ {props.answer} +

+ +
+); diff --git a/packages/atomic/src/components/search/atomic-generated-answer/retry-prompt.tsx b/packages/atomic/src/components/search/atomic-generated-answer/retry-prompt.tsx new file mode 100644 index 00000000000..661a044cc11 --- /dev/null +++ b/packages/atomic/src/components/search/atomic-generated-answer/retry-prompt.tsx @@ -0,0 +1,21 @@ +import {FunctionalComponent, h} from '@stencil/core'; +import {Button} from '../../common/button'; + +interface RetryPromptProps { + message: string; + buttonLabel: string; + onClick: () => void; +} + +export const RetryPrompt: FunctionalComponent = (props) => ( +
+
{props.message}
+ +
+); diff --git a/packages/atomic/src/components/search/atomic-generated-answer/source-citations.tsx b/packages/atomic/src/components/search/atomic-generated-answer/source-citations.tsx new file mode 100644 index 00000000000..30a02e6f575 --- /dev/null +++ b/packages/atomic/src/components/search/atomic-generated-answer/source-citations.tsx @@ -0,0 +1,42 @@ +import {GeneratedAnswerCitation} from '@coveo/headless'; +import {FunctionalComponent, h} from '@stencil/core'; + +interface SourceCitationsProps { + label: string; + citations: GeneratedAnswerCitation[]; + onCitationClick: (citation: GeneratedAnswerCitation) => void; +} + +export const SourceCitations: FunctionalComponent = ( + props +) => + props.citations.length ? ( + + ) : null; diff --git a/packages/atomic/src/components/search/atomic-generated-answer/typing-loader.tsx b/packages/atomic/src/components/search/atomic-generated-answer/typing-loader.tsx new file mode 100644 index 00000000000..2626a4b2be2 --- /dev/null +++ b/packages/atomic/src/components/search/atomic-generated-answer/typing-loader.tsx @@ -0,0 +1,18 @@ +import {FunctionalComponent, h} from '@stencil/core'; + +interface TypingLoaderProps { + loadingLabel: string; +} + +export const TypingLoader: FunctionalComponent = (props) => ( +
+ +
+ {props.loadingLabel} +
+
+); diff --git a/packages/atomic/src/images/thumbs-down.svg b/packages/atomic/src/images/thumbs-down.svg new file mode 100644 index 00000000000..9d9e51007ec --- /dev/null +++ b/packages/atomic/src/images/thumbs-down.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/atomic/src/images/thumbs-up.svg b/packages/atomic/src/images/thumbs-up.svg new file mode 100644 index 00000000000..a3249bc96f7 --- /dev/null +++ b/packages/atomic/src/images/thumbs-up.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/atomic/src/locales.json b/packages/atomic/src/locales.json index c208ffc0e0b..470602bae21 100644 --- a/packages/atomic/src/locales.json +++ b/packages/atomic/src/locales.json @@ -5573,5 +5573,29 @@ "zh": "弹出式菜单以获取更多选项卡", "zh-cn": "用于更多选项卡的弹出式菜单", "zh-tw": "用於更多標籤的快顯式選單" + }, + "generated-answer-title": { + "en": "Generated answer for you", + "fr": "Réponse générée pour vous" + }, + "this-answer-was-helpful": { + "en": "This answer was helpful", + "fr": "Cette réponse m'a été utile" + }, + "this-answer-was-not-helpful": { + "en": "This answer was not helpful", + "fr": "Cette réponse ne m'a pas été utile" + }, + "generated-answer-loading": { + "en": "Looking for an answer...", + "fr": "Nous cherchons une réponse..." + }, + "retry": { + "en": "Retry", + "fr": "Réessayer" + }, + "retry-stream-message": { + "en": "Oops! Something went wrong while trying to generate an answer.", + "fr": "Oops! Quelque chose s'est mal passé lors de la tentative de génération d'une réponse." } } \ No newline at end of file diff --git a/packages/atomic/src/pages/examples/genqa.html b/packages/atomic/src/pages/examples/genqa.html new file mode 100644 index 00000000000..7557eb67c35 --- /dev/null +++ b/packages/atomic/src/pages/examples/genqa.html @@ -0,0 +1,283 @@ + + + + + + Coveo Atomic With Generative Question Answering + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + diff --git a/packages/atomic/src/pages/header.js b/packages/atomic/src/pages/header.js index bdbb8ed3fb7..5a757d47f78 100644 --- a/packages/atomic/src/pages/header.js +++ b/packages/atomic/src/pages/header.js @@ -20,6 +20,7 @@ const links = [ label: 'Accessible Commerce Full', }, {href: '/examples/ipx.html', label: 'IPX'}, + {href: '/examples/genqa.html', label: 'Gen Q&A'}, ]; const header = document.createElement('header'); diff --git a/packages/atomic/src/themes/accessible.css b/packages/atomic/src/themes/accessible.css index e9b1c210ca1..7b0943f801b 100644 --- a/packages/atomic/src/themes/accessible.css +++ b/packages/atomic/src/themes/accessible.css @@ -18,6 +18,9 @@ --atomic-error: #ce3f00; --atomic-visited: #752e9c; --atomic-disabled: #c5cacf; + --atomic-success-background: #c5f4e6; + --atomic-error-background: #f6a5a8; + --atomic-primary-background: #edf6ff; /* Border radius */ --atomic-border-radius: 0.25rem; diff --git a/packages/atomic/src/themes/coveo.css b/packages/atomic/src/themes/coveo.css index ba9ba8f7ee2..ae5e9d78af6 100644 --- a/packages/atomic/src/themes/coveo.css +++ b/packages/atomic/src/themes/coveo.css @@ -18,6 +18,9 @@ --atomic-error: #ce3f00; --atomic-visited: #752e9c; --atomic-disabled: #c5cacf; + --atomic-success-background: #c5f4e6; + --atomic-error-background: #f6a5a8; + --atomic-primary-background: #edf6ff; /* Border radius */ --atomic-border-radius: 0.25rem;