diff --git a/jest.config.ts b/jest.config.ts index 7708e15..282eaeb 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -13,8 +13,7 @@ const config: Config.InitialOptions = { "^.+\\.(t|j)sx?$": "@swc/jest", }, moduleNameMapper: { - "\\.(svg|jpg|jpeg|png)$": "ol-test-utilities/filemocks/imagemock.js", - "\\.(css|scss)$": "ol-test-utilities/filemocks/filemock.js", + "\\.(css|scss|svg|jpg|jpeg|png)$": "/test-utils/filemock.js", }, rootDir: "./src", } diff --git a/package.json b/package.json index 9dd3b8b..4712b44 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,9 @@ "typecheck": "tsc --noEmit", "build:esm": "tsc", "build:cjs": "tsc --module commonjs --outDir dist/cjs", + "build:static": "cp -r ./static dist/static", "build:type-augmentation": "cp -r src/type-augmentation dist/type-augmentation", - "build": "rm -rf dist && rm -f .tsbuildinfo && npm run build:esm && npm run build:cjs && npm run build:type-augmentation", + "build": "./scripts/build.sh", "lint-check": "eslint src/ .storybook/", "lint-fix": "yarn lint-check --fix", "fmt-check": "prettier --ignore-path .gitignore --ignore-path .prettierignore --check .", diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..7589386 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -e -o pipefail + +rm -rf dist && + rm -f .tsbuildinfo && + npm run build:esm && + npm run build:cjs && + npm run build:type-augmentation && + npm run build:static diff --git a/src/Installation.mdx b/src/Installation.mdx new file mode 100644 index 0000000..cdf49ed --- /dev/null +++ b/src/Installation.mdx @@ -0,0 +1,15 @@ +import { Meta } from "@storybook/blocks" + + + +# Installation + +## ThemeProvider + +All components must be descendants of a `ThemeProvider` component. This component provides the theme to all styled components in the tree. See [https://mitodl.github.io/smoot-design/?path=/docs/smoot-design-themeprovider--docs] for more. + +## Image Imports + +Some components import images in their source code. Your bundler must be configured to handle these imports. + +- **NextJS:** If using NextJS, provide `ImgAdapter: Image` to the theme and specify `transpilePackages: ["@mitodl/smoot-design/ai"],` in the `next.config.js` file. diff --git a/src/components/AiChat/AiChat.stories.tsx b/src/components/AiChat/AiChat.stories.tsx index f994526..9712ca2 100644 --- a/src/components/AiChat/AiChat.stories.tsx +++ b/src/components/AiChat/AiChat.stories.tsx @@ -4,6 +4,7 @@ import { AiChat } from "./AiChat" import type { AiChatProps } from "./types" import { mockJson, mockStreaming } from "./story-utils" import styled from "@emotion/styled" +import { fn } from "@storybook/test" const TEST_API_STREAMING = "http://localhost:4567/streaming" const TEST_API_JSON = "http://localhost:4567/json" @@ -23,11 +24,11 @@ const STARTERS = [ const Container = styled.div({ width: "100%", - height: "350px", + height: "500px", }) const meta: Meta = { - title: "smoot-design/AiChat", + title: "smoot-design/ai/AiChat", component: AiChat, render: (args) => , decorators: (Story) => { @@ -41,6 +42,8 @@ const meta: Meta = { initialMessages: INITIAL_MESSAGES, requestOpts: { apiUrl: TEST_API_STREAMING }, conversationStarters: STARTERS, + title: "Chat with AI", + onClose: fn(), }, argTypes: { conversationStarters: { diff --git a/src/components/AiChat/AiChat.test.tsx b/src/components/AiChat/AiChat.test.tsx index 6a82af2..abb9c5f 100644 --- a/src/components/AiChat/AiChat.test.tsx +++ b/src/components/AiChat/AiChat.test.tsx @@ -30,6 +30,11 @@ jest.mock("react-markdown", () => { } }) +const msg = { + ai: (text: string) => `Assistant said: ${text}`, + you: (text: string) => `You said: ${text}`, +} + const getMessages = (): HTMLElement[] => { return Array.from(document.querySelectorAll(".MitAiChat--message")) } @@ -55,8 +60,9 @@ describe("AiChat", () => { { content: faker.lorem.sentence() }, { content: faker.lorem.sentence() }, ] - render( + const view = render( { { wrapper: ThemeProvider }, ) - return { initialMessages, conversationStarters } + const rerender = (newProps: Partial) => { + view.rerender( + , + ) + } + + return { initialMessages, conversationStarters, rerender } } test("Clicking conversation starters and sending chats", async () => { @@ -106,6 +124,25 @@ describe("AiChat", () => { expect(afterSending[4]).toHaveTextContent("AI Response 1") }) + test("Messages persist if chat has same chatId", async () => { + const { rerender } = setup({ chatId: "test-123" }) + const starterEls = getConversationStarters() + const chosen = faker.helpers.arrayElement([0, 1]) + + await user.click(starterEls[chosen]) + await whenCount(getMessages, 3) + + // New chat ... starters should be shown + rerender({ chatId: "test-345" }) + expect(getConversationStarters().length).toBeGreaterThan(0) + await whenCount(getMessages, 1) + + // existing chat ... starters should not be shown, messages should be restored + rerender({ chatId: "test-123" }) + expect(getConversationStarters().length).toBe(0) + await whenCount(getMessages, 3) + }) + test("transformBody is called before sending requests", async () => { const fakeBody = { message: faker.lorem.sentence() } const apiUrl = faker.internet.url() @@ -151,12 +188,24 @@ describe("AiChat", () => { await whenCount(getMessages, initialMessages.length + 4) const messagesTexts = getMessages().map((el) => el.textContent) + expect(messagesTexts).toEqual([ - initialMessages[0].content, - conversationStarters[0].content, - "Parsed: AI Response 0", - "User message", - "Parsed: AI Response 1", + msg.ai(initialMessages[0].content), + msg.you(conversationStarters[0].content), + msg.ai("Parsed: AI Response 0"), + msg.you("User message"), + msg.ai("Parsed: AI Response 1"), ]) }) + + test("Passes extra attributes to root", () => { + const fakeBody = { message: faker.lorem.sentence() } + const apiUrl = faker.internet.url() + const transformBody = jest.fn(() => fakeBody) + setup({ + requestOpts: { apiUrl, transformBody }, + parseContent: jest.fn((content) => `Parsed: ${content}`), + }) + expect(screen.getByTestId("ai-chat")).toBeInTheDocument() + }) }) diff --git a/src/components/AiChat/AiChat.tsx b/src/components/AiChat/AiChat.tsx index 0a7ae64..df2a9d53 100644 --- a/src/components/AiChat/AiChat.tsx +++ b/src/components/AiChat/AiChat.tsx @@ -3,7 +3,7 @@ import styled from "@emotion/styled" import Skeleton from "@mui/material/Skeleton" import { Input } from "../Input/Input" import { ActionButton } from "../Button/ActionButton" -import { RiSendPlaneFill } from "@remixicon/react" +import { RiCloseLine, RiRobot2Line, RiSendPlaneFill } from "@remixicon/react" import { useAiChat } from "./utils" import Markdown from "react-markdown" @@ -12,39 +12,82 @@ import { ScrollSnap } from "../ScrollSnap/ScrollSnap" import classNames from "classnames" import { SrAnnouncer } from "../SrAnnouncer/SrAnnouncer" -const ChatContainer = styled.div(({ theme }) => ({ +import mascot from "../../../static/images/mit_mascot_tim.png" +import { VisuallyHidden } from "../VisuallyHidden/VisuallyHidden" +import { ImageAdapter } from "../ImageAdapter/ImageAdapter" +import Typography from "@mui/material/Typography" + +const classes = { + root: "MitAiChat--root", + conversationStarter: "MitAiChat--conversationStarter", + messagesContainer: "MitAiChat--messagesContainer", + messageRow: "MitAiChat--messageRow", + messageRowUser: "MitAiChat--messageRowUser", + messageRowAssistant: "MitAiChat--messageRowAssistant", + message: "MitAiChat--message", + avatar: "MitAiChat--avatar", + input: "MitAiChat--input", +} + +const ChatContainer = styled.div({ width: "100%", height: "100%", - border: `1px solid ${theme.custom.colors.silverGrayLight}`, - backgroundColor: theme.custom.colors.lightGray1, display: "flex", flexDirection: "column", -})) +}) -const MessagesContainer = styled(ScrollSnap)({ +const MessagesContainer = styled(ScrollSnap)(({ theme }) => ({ display: "flex", flexDirection: "column", flex: 1, padding: "24px", - paddingBottom: "0px", + paddingBottom: "12px", overflow: "auto", -}) + gap: "24px", + backgroundColor: theme.custom.colors.lightGray1, + borderColor: theme.custom.colors.silverGrayLight, + borderStyle: "solid", + borderWidth: "0 1px", +})) const MessageRow = styled.div<{ reverse?: boolean -}>(({ reverse }) => [ - { - margin: "8px 0", - display: "flex", - width: "100%", - flexDirection: reverse ? "row-reverse" : "row", +}>({ + display: "flex", + width: "100%", + gap: "10px", + [`&.${classes.messageRowUser}`]: { + flexDirection: "row-reverse", + }, + "> *": { + minWidth: 0, + }, + position: "relative", +}) +const Avatar = styled.div(({ theme }) => ({ + flexShrink: 0, + borderRadius: "50%", + backgroundColor: theme.custom.colors.white, + display: "flex", + alignItems: "center", + justifyContent: "center", + img: { + width: "66%", }, -]) -const Avatar = styled.div({}) + width: "32px", + height: "32px", + position: "absolute", + top: "-16px", + [`.${classes.messageRowAssistant} &`]: { + left: "-10px", + }, + [`.${classes.messageRowUser} &`]: { + right: "16px", + }, +})) const Message = styled.div(({ theme }) => ({ border: `1px solid ${theme.custom.colors.silverGrayLight}`, backgroundColor: theme.custom.colors.white, - borderRadius: "24px", - padding: "4px 16px", + padding: "12px", ...theme.typography.body2, "p:first-of-type": { marginTop: 0, @@ -60,37 +103,45 @@ const Message = styled.div(({ theme }) => ({ color: theme.custom.colors.red, textDecoration: "underline", }, + borderRadius: "12px", + [`.${classes.messageRowAssistant} &`]: { + borderRadius: "0 12px 12px 12px", + }, + [`.${classes.messageRowUser} &`]: { + borderRadius: "12px 0 12px 12px", + }, })) const StarterContainer = styled.div({ alignSelf: "flex-end", + alignItems: "end", display: "flex", flexDirection: "column", - gap: "4px", + gap: "12px", }) const Starter = styled.button(({ theme }) => ({ border: `1px solid ${theme.custom.colors.silverGrayLight}`, backgroundColor: theme.custom.colors.white, - borderRadius: "24px", - padding: "4px 16px", - ...theme.typography.body2, + padding: "8px 16px", + ...theme.typography.subtitle3, cursor: "pointer", "&:hover": { backgroundColor: theme.custom.colors.lightGray1, }, + borderRadius: "100vh", })) -const Controls = styled.div(({ theme }) => ({ - display: "flex", - justifyContent: "space-around", - padding: "12px 24px", - backgroundColor: theme.custom.colors.white, -})) -const Form = styled.form(() => ({ - display: "flex", - width: "80%", - gap: "12px", - alignItems: "center", +const InputStyled = styled(Input)({ + borderRadius: "0 0 8px 8px", +}) +const ActionButtonStyled = styled(ActionButton)(({ theme }) => ({ + backgroundColor: theme.custom.colors.red, + flexShrink: 0, + marginRight: "24px", + marginLeft: "12px", + "&:hover:not(:disabled)": { + backgroundColor: theme.custom.colors.mitRed, + }, })) const DotsContainer = styled.span(({ theme }) => ({ @@ -110,25 +161,62 @@ const Dots = () => { ) } -const classes = { - root: "MitAiChat--root", - conversationStarter: "MitAiChat--conversationStarter", - messagesContainer: "MitAiChat--messagesContainer", - messageRow: "MitAiChat--messageRow", - message: "MitAiChat--message", - avatar: "MitAiChat--avatar", - input: "MitAiChat--input", +const CloseButton = styled(ActionButton)(({ theme }) => ({ + color: "inherit", + backgroundColor: theme.custom.colors.red, + "&:hover:not(:disabled)": { + backgroundColor: theme.custom.colors.mitRed, + }, +})) +const RobotIcon = styled(RiRobot2Line)({ + width: "40px", + height: "40px", +}) + +type ChatTitleProps = { + title?: string + onClose?: () => void + className?: string } +const ChatTitle = styled(({ title, onClose, className }: ChatTitleProps) => { + return ( +
+ + + {title} + + {onClose ? ( + + + + ) : null} +
+ ) +})(({ theme }) => ({ + backgroundColor: theme.custom.colors.red, + display: "flex", + alignItems: "center", + justifyContent: "space-between", + padding: "12px 24px", + gap: "16px", + color: theme.custom.colors.white, + borderRadius: "8px 8px 0 0", +})) -const AiChat: React.FC = function AiChat({ +const AiChatInternal: React.FC = function AiChat({ + chatId, className, conversationStarters, requestOpts, initialMessages: initMsgs, parseContent, srLoadingMessages, + title, + onClose, + ImgComponent, + placeholder = "Type a message...", + ...others // Could contain data attributes }) { - const [showStarters, setShowStarters] = React.useState(true) const messagesRef = React.useRef(null) const initialMessages = React.useMemo(() => { const prefix = Math.random().toString().slice(2) @@ -143,6 +231,7 @@ const AiChat: React.FC = function AiChat({ isLoading, } = useAiChat(requestOpts, { initialMessages: initialMessages, + id: chatId, }) const messages = React.useMemo(() => { @@ -156,6 +245,8 @@ const AiChat: React.FC = function AiChat({ }) }, [parseContent, unparsed, initialMessages]) + const showStarters = messages.length === initialMessages.length + const waiting = !showStarters && messages[messages.length - 1]?.role === "user" @@ -169,7 +260,8 @@ const AiChat: React.FC = function AiChat({ const lastMsg = messages[messages.length - 1] return ( - + + {} = function AiChat({ {messages.map((m) => ( - + {m.role === "assistant" ? ( + + + + ) : null} - {m.content} + + {m.role === "user" ? "You said: " : "Assistant said: "} + + {m.content} ))} @@ -194,7 +295,6 @@ const AiChat: React.FC = function AiChat({ className={classes.conversationStarter} key={m.content} onClick={() => { - setShowStarters(false) scrollToBottom() append({ role: "user", content: m.content }) }} @@ -205,39 +305,49 @@ const AiChat: React.FC = function AiChat({ ) : null} {waiting ? ( - - + + + + ) : null} - -
{ - setShowStarters(false) - scrollToBottom() - handleSubmit(e) - }} - > - - - - -
-
+
{ + scrollToBottom() + handleSubmit(e) + }} + > + + + + } + /> + = function AiChat({ ) } +const AiChat: React.FC = (props) => ( + /** + * Changing the `useChat` chatId seems to persist some state between + * hook calls. This can cause strange effects like loading API responses + * for previous chatId into new chatId. + * + * To avoid this, let's chnge the key, this will force React to make a new component + * not sharing any of the old state. + */ + +) + export { AiChat } export type { AiChatProps } diff --git a/src/components/AiChat/mit_mascot_tim.png b/src/components/AiChat/mit_mascot_tim.png new file mode 100644 index 0000000..030bf82 Binary files /dev/null and b/src/components/AiChat/mit_mascot_tim.png differ diff --git a/src/components/AiChat/story-utils.ts b/src/components/AiChat/story-utils.ts index 30dd6b5..5b1a284 100644 --- a/src/components/AiChat/story-utils.ts +++ b/src/components/AiChat/story-utils.ts @@ -4,6 +4,7 @@ const SAMPLE_RESPONSES = [ 1. **[Machine Learning, Modeling, and Simulation Principles](https://xpro.mit.edu/courses/course-v1:xPRO+MLx1/)**: Offered by MIT xPRO, this course is part of the program "Machine Learning, Modeling, and Simulation: Engineering Problem-Solving in the Age of AI." It focuses on the principles of machine learning and how they can be applied to solve engineering problems, which is highly relevant for business applications of AI. This course is not free, but it provides a certification upon completion, which can be valuable for professionals looking to apply AI in business contexts. It covers essential concepts that can help you understand how AI can be leveraged to improve business processes and decision-making. + `, ` To understand global warming, I recommend the following resources from MIT: @@ -13,6 +14,7 @@ To understand global warming, I recommend the following resources from MIT: 2. **[Global Warming Science](https://openlearninglibrary.mit.edu/courses/course-v1:MITx+12.340x+1T2020/about)**: Another offering of the same course by MITx, available through the Open Learning Library. It provides the same in-depth exploration of the earth's climate system. These courses are free and provide a solid foundation in understanding the scientific aspects of global warming. They are suitable for anyone interested in the topic, regardless of prior knowledge. + `, ` Here are some courses on linear algebra that you can explore: @@ -24,6 +26,7 @@ Here are some courses on linear algebra that you can explore: 3. **[Quantum Information Science I, Part 1 (MITx)](https://openlearninglibrary.mit.edu/courses/course-v1:MITx+8.370.1x+1T2018/about)**: While primarily focused on quantum information science, this course requires some knowledge of linear algebra and is suitable for those interested in quantum mechanics. It is free and available through MITx. These courses provide a comprehensive introduction to linear algebra and its applications across various fields. + `, ] @@ -50,7 +53,7 @@ const mockStreaming = async function mockApi() { const num = chunks.length let i = 0 - await new Promise((resolve) => setTimeout(resolve, 1500)) + await new Promise((resolve) => setTimeout(resolve, 800)) const body = new ReadableStream({ start(controller) { @@ -62,7 +65,7 @@ const mockStreaming = async function mockApi() { controller.close() clearInterval(timerId) } - }, 250) + }, 100) }, cancel() { if (timerId) { @@ -82,7 +85,7 @@ const mockStreaming = async function mockApi() { const mockJson = async () => { const message = SAMPLE_RESPONSES[rand(0, SAMPLE_RESPONSES.length - 1)] - await new Promise((res) => setTimeout(res, 2000)) + await new Promise((res) => setTimeout(res, 1000)) return Promise.resolve( new Response(JSON.stringify({ message }), { headers: { diff --git a/src/components/AiChat/types.ts b/src/components/AiChat/types.ts index 2117c8d..527af10 100644 --- a/src/components/AiChat/types.ts +++ b/src/components/AiChat/types.ts @@ -18,20 +18,35 @@ type RequestOpts = { transformBody?: (messages: ChatMessage[]) => unknown /** * Extra options to pass to fetch. - * - * If headers are specified, they will override the headersOpts. */ fetchOpts?: RequestInit - /** - * Extra headers to pass to fetch. - */ - headersOpts?: HeadersInit + onFinish?: (message: ChatMessage) => void } type AiChatProps = { + /** + * Changing the `chatId` will reset the chat. Changing the `chatId` to a + * previously used value will restore the session state. + */ + chatId?: string + /** + * If provided, renders a title bar. + */ + title?: string + /** + * Plaeholder message for chat input + */ + placeholder?: string + /** + * Fired when "Close" button within title bar is clicked. + */ + onClose?: () => void className?: string initialMessages: Omit[] conversationStarters?: { content: string }[] + /** + * Options for making requests to the AI service. + */ requestOpts: RequestOpts parseContent?: (content: unknown) => string /** @@ -43,6 +58,11 @@ type AiChatProps = { delay: number text: string }[] + /** + * If provided, element to use for rendering avatar images. + * By default, the theme's ImageAdater is used. + */ + ImgComponent?: React.ElementType } export type { RequestOpts, AiChatProps, ChatMessage } diff --git a/src/components/AiChat/utils.ts b/src/components/AiChat/utils.ts index ef1fdd9..cb45403 100644 --- a/src/components/AiChat/utils.ts +++ b/src/components/AiChat/utils.ts @@ -16,12 +16,12 @@ const getFetcher: (requestOpts: RequestOpts) => typeof fetch = const options: RequestInit = { ...opts, body: JSON.stringify(transformBody(messages)), + ...requestOpts.fetchOpts, headers: { ...opts?.headers, "Content-Type": "application/json", - ...requestOpts.headersOpts, + ...requestOpts.fetchOpts?.headers, }, - ...requestOpts.fetchOpts, } return fetch(url, options) } @@ -32,6 +32,14 @@ const useAiChat = (requestOpts: RequestOpts, opts: UseChatOptions) => { api: requestOpts.apiUrl, streamProtocol: "text", fetch: fetcher, + onFinish: (message) => { + if (!requestOpts.onFinish) return + if (message.role === "assistant" || message.role === "user") { + requestOpts.onFinish?.(message as ChatMessage) + } else { + console.info("Unexpected message role.", message) + } + }, ...opts, }) } diff --git a/src/components/ImageAdapter/ImageAdapter.tsx b/src/components/ImageAdapter/ImageAdapter.tsx new file mode 100644 index 0000000..b6d7d1f --- /dev/null +++ b/src/components/ImageAdapter/ImageAdapter.tsx @@ -0,0 +1,32 @@ +import * as React from "react" +import { useTheme } from "@emotion/react" + +/** + * ImageAdapterPropsOverrides can be used with module augmentation to provide + * extra props to ButtonLink. + * + * For example, in a NextJS App, you might set `next/image` as your default + * image implementation, and use ImageAdapterPropsOverrides to provide + * `next/image`-specific props. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +interface ImageAdapterPropsOverrides {} +type ImageAdapterProps = React.ComponentProps<"img"> & { + Component?: React.ElementType +} & ImageAdapterPropsOverrides +/** + * Overrideable Image component. + * - If `Component` is provided, renders as `Component` + * - else, if `theme.custom.ImageAdapter` is provided, renders as `theme.custom.ImageAdapter` + * - else, renders as `img` tag + */ +const ImageAdapter = React.forwardRef( + function ImageAdapter({ Component, ...props }, ref) { + const theme = useTheme() + const ImgComponent = Component ?? theme.custom.ImgAdapter ?? "img" + return + }, +) + +export { ImageAdapter } +export type { ImageAdapterPropsOverrides, ImageAdapterProps } diff --git a/src/components/LinkAdapter/LinkAdapter.tsx b/src/components/LinkAdapter/LinkAdapter.tsx index c026f17..1a85b91 100644 --- a/src/components/LinkAdapter/LinkAdapter.tsx +++ b/src/components/LinkAdapter/LinkAdapter.tsx @@ -35,4 +35,4 @@ const LinkAdapter = React.forwardRef( ) export { LinkAdapter } -export type { LinkAdapterPropsOverrides } +export type { LinkAdapterPropsOverrides, LinkAdapterProps } diff --git a/src/components/ThemeProvider/ThemeProvider.stories.tsx b/src/components/ThemeProvider/ThemeProvider.stories.tsx index a9cd9c1..781e996 100644 --- a/src/components/ThemeProvider/ThemeProvider.stories.tsx +++ b/src/components/ThemeProvider/ThemeProvider.stories.tsx @@ -66,7 +66,6 @@ type Story = StoryObj * * ``` * - * * ### Custom Link Adapter * One particularly notable property is `theme.custom.LinkAdapter`. Some `smoot-design` * components render links. These links are native anchor tags by default. In @@ -97,6 +96,12 @@ type Story = StoryObj * } * } * ``` + * + * ### ImageAdapter + * Similarly, `theme.custom.ImageAdapter` can be used to customize the image + * component used by `smoot-design`. By default, `ImageAdapter` uses a simple `img` + * tag. Interface `ImageAdapterPropsOverrides` is similarly available for + * augmentation. */ export const LinkAdapterOverride: Story = { args: { diff --git a/src/test-utils/filemock.js b/src/test-utils/filemock.js new file mode 100644 index 0000000..159727a --- /dev/null +++ b/src/test-utils/filemock.js @@ -0,0 +1 @@ +module.exports = "some file" diff --git a/src/type-augmentation/TypescriptDocs.mdx b/src/type-augmentation/TypescriptDocs.mdx index 109fa97..bb73248 100644 --- a/src/type-augmentation/TypescriptDocs.mdx +++ b/src/type-augmentation/TypescriptDocs.mdx @@ -1,6 +1,6 @@ import { Meta } from "@storybook/blocks" - + # Type Augmentation diff --git a/src/type-augmentation/imports.d.ts b/src/type-augmentation/imports.d.ts new file mode 100644 index 0000000..103e5e0 --- /dev/null +++ b/src/type-augmentation/imports.d.ts @@ -0,0 +1,3 @@ +declare module "*.png" { + export default any +} diff --git a/src/type-augmentation/index.d.ts b/src/type-augmentation/index.d.ts index c0a2e88..6f33896 100644 --- a/src/type-augmentation/index.d.ts +++ b/src/type-augmentation/index.d.ts @@ -1,2 +1,3 @@ import "./theme" import "./typography" +import "./imports" diff --git a/src/type-augmentation/theme.d.ts b/src/type-augmentation/theme.d.ts index 91442a8..9c4d89c 100644 --- a/src/type-augmentation/theme.d.ts +++ b/src/type-augmentation/theme.d.ts @@ -45,6 +45,7 @@ export interface CustomTheme { headerHeightSm: string } LinkAdapter?: React.ElementType + ImgAdapter?: React.ElementType } /* https://mui.com/material-ui/customization/theming/#typescript */ diff --git a/static/images/mit_mascot_tim.png b/static/images/mit_mascot_tim.png new file mode 100644 index 0000000..030bf82 Binary files /dev/null and b/static/images/mit_mascot_tim.png differ diff --git a/tsconfig.json b/tsconfig.json index 1459f61..456c87b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,5 +19,5 @@ "outDir": "./dist/esm", "rootDir": "./src" }, - "include": ["./src/**/*.ts", "./src/**/*.tsx"] + "include": ["./src/**/*.ts", "./src/**/*.tsx", "./static/**/*"] }