+
+
+
+ {!!theme && (
+
+ )}
+
+
+
+
+
+ );
+ }
+}
diff --git a/tgui/packages/tgui-say/package.json b/tgui/packages/tgui-say/package.json
new file mode 100644
index 00000000000..354d52ece55
--- /dev/null
+++ b/tgui/packages/tgui-say/package.json
@@ -0,0 +1,13 @@
+{
+ "private": true,
+ "name": "tgui-say",
+ "version": "1.0.0",
+ "dependencies": {
+ "common": "workspace:*",
+ "dompurify": "^2.3.1",
+ "inferno": "^7.4.8",
+ "tgui": "workspace:*",
+ "tgui-dev-server": "workspace:*",
+ "tgui-polyfill": "workspace:*"
+ }
+}
diff --git a/tgui/packages/tgui-say/styles/button.scss b/tgui/packages/tgui-say/styles/button.scss
new file mode 100644
index 00000000000..e2d32412395
--- /dev/null
+++ b/tgui/packages/tgui-say/styles/button.scss
@@ -0,0 +1,47 @@
+@use 'sass:color';
+@use 'sass:selector';
+@use './colors.scss';
+
+.button {
+ background-color: colors.$button;
+ border: thin solid;
+ border-radius: 2px;
+ color: colors.$background;
+ font-family: 'Consolas', monospace;
+ font-weight: bold;
+ font-size: 0.9rem;
+ height: 100%;
+ padding: 0;
+ text-align: center;
+ vertical-align: middle;
+ width: 4rem;
+
+ &:hover {
+ background-color: lighten(colors.$button, 10%);
+ }
+}
+
+.button-lightMode {
+ @extend .button;
+ background-color: colors.$lightBorder;
+ border: none;
+ color: black;
+
+ &:hover {
+ background-color: colors.$lightHover;
+ }
+}
+
+/** Creates a button for each channel */
+@each $channel,
+$color in colors.$channel-map {
+ .button-#{$channel} {
+ color: $color;
+ border-color: darken($color, 10%);
+
+ &:hover {
+ border-color: lighten($color, 10%);
+ color: lighten($color, 5%);
+ }
+ }
+}
diff --git a/tgui/packages/tgui-say/styles/colors.scss b/tgui/packages/tgui-say/styles/colors.scss
new file mode 100644
index 00000000000..6ba748c99d3
--- /dev/null
+++ b/tgui/packages/tgui-say/styles/colors.scss
@@ -0,0 +1,60 @@
+@use 'sass:color';
+@use 'sass:map';
+
+$background: #131313;
+$button: #1f1f1f;
+$lightMode: #FFFFFF;
+$lightBorder: #BBBBBB;
+$lightHover: #EAEAEA;
+
+////////////////////////////////////////////////
+// Normal chat colors
+$say: #a4bad6;
+$radio: #1ecc43;
+$me: #5975da;
+$ooc: #cca300;
+
+////////////////////////////////////////////////
+// Subchannel chat colors
+$ai: #d65d95;
+$binary: #1e90ff;
+$centcom: #2681a5;
+$command: #fcdf03;
+$engi: #f37746;
+$hive: #855d85;
+$medical: #57b8f0;
+$science: #c68cfa;
+$security: #dd3535;
+$syndicate: #8f4a4b;
+$service: #6ca729;
+$supply: #b88646;
+
+$_channel_map: (
+ 'say': $say,
+ 'radio': $radio,
+ 'me': $me,
+ 'ooc': $ooc,
+
+ 'ai': $ai,
+ 'binary': $binary,
+ 'centcom': $centcom,
+ 'command': $command,
+ 'engi': $engi,
+ 'hive': $hive,
+ 'medical': $medical,
+ 'science': $science,
+ 'security': $security,
+ 'syndicate': $syndicate,
+ 'service': $service,
+ 'supply': $supply,
+);
+
+$channel_keys: map.keys($_channel_map) !default;
+
+$channel-map: (
+);
+
+@each $channel in $channel_keys {
+ $channel-map: map-merge($channel-map, ($channel: map.get($_channel_map, $channel),
+ ));
+}
diff --git a/tgui/packages/tgui-say/styles/dragzone.scss b/tgui/packages/tgui-say/styles/dragzone.scss
new file mode 100644
index 00000000000..33968665720
--- /dev/null
+++ b/tgui/packages/tgui-say/styles/dragzone.scss
@@ -0,0 +1,73 @@
+@use 'sass:color';
+@use './colors.scss';
+
+.dragzone-horizontal {
+ color: transparent;
+ height: 5px;
+ width: 100%;
+}
+
+.dragzone-vertical {
+ color: transparent;
+ height: 100%;
+ position: absolute;
+ top: 0;
+ width: 5px;
+}
+
+@each $channel,
+$color in colors.$channel-map {
+ $darkened: darken($color, 20%);
+
+ .dragzone-bottom-#{$channel} {
+ @extend .dragzone-horizontal;
+ border-left: 2px solid $darkened;
+ border-right: 2px solid $darkened;
+ }
+
+ .dragzone-left-#{$channel} {
+ @extend .dragzone-vertical;
+ left: 0;
+ border-left: 2px solid $darkened;
+ }
+
+ .dragzone-right-#{$channel} {
+ @extend .dragzone-vertical;
+ right: 0;
+ border-right: 2px solid $darkened;
+ }
+
+ .dragzone-top-#{$channel} {
+ @extend .dragzone-horizontal;
+ border-left: 2px solid $darkened;
+ border-right: 2px solid $darkened;
+ border-top: 2px solid $darkened;
+ }
+}
+
+/** Lightmode static theme */
+.dragzone-bottom-lightMode {
+ @extend .dragzone-horizontal;
+ border-left: 2px solid colors.$lightBorder;
+ border-right: 2px solid colors.$lightBorder;
+ border-bottom: 2px solid colors.$lightBorder;
+}
+
+.dragzone-left-lightMode {
+ @extend .dragzone-vertical;
+ left: 0;
+ border-left: 2px solid colors.$lightBorder;
+}
+
+.dragzone-right-lightMode {
+ @extend .dragzone-vertical;
+ right: 0;
+ border-right: 2px solid colors.$lightBorder;
+}
+
+.dragzone-top-lightMode {
+ @extend .dragzone-horizontal;
+ border-left: 2px solid colors.$lightBorder;
+ border-right: 2px solid colors.$lightBorder;
+ border-top: 2px solid colors.$lightBorder;
+}
diff --git a/tgui/packages/tgui-say/styles/main.scss b/tgui/packages/tgui-say/styles/main.scss
new file mode 100644
index 00000000000..db460291f66
--- /dev/null
+++ b/tgui/packages/tgui-say/styles/main.scss
@@ -0,0 +1,13 @@
+@use 'sass:meta';
+
+// Core styles
+@include meta.load-css('~tgui/styles/reset.scss');
+// Atomic styles
+@include meta.load-css('~tgui/styles/atomic/text.scss');
+// External styles
+@include meta.load-css('~tgui/styles/components/TextArea.scss');
+// Local styles
+@include meta.load-css('./button.scss');
+@include meta.load-css('./dragzone.scss');
+@include meta.load-css('./modal.scss');
+@include meta.load-css('./textarea.scss');
diff --git a/tgui/packages/tgui-say/styles/modal.scss b/tgui/packages/tgui-say/styles/modal.scss
new file mode 100644
index 00000000000..b436e4035de
--- /dev/null
+++ b/tgui/packages/tgui-say/styles/modal.scss
@@ -0,0 +1,70 @@
+@use 'sass:color';
+@use './colors.scss';
+
+@keyframes gradient {
+ 0% {
+ background-position: 0 0;
+ }
+
+ 100% {
+ background-position: 100% 0;
+ }
+}
+
+.modal {
+ background-color: colors.$background;
+ display: flex;
+ flex-direction: column;
+ max-width: 231px;
+ width: 100%;
+}
+
+.modal-lightMode {
+ @extend .modal;
+ background-color: colors.$lightMode;
+}
+
+.modal__content {
+ display: flex;
+ flex-direction: row;
+ height: 100%;
+ padding: 1px 0 1px 5px;
+}
+
+/** Window sizes */
+.modal-30 {
+ height: 30px;
+}
+
+.modal-50 {
+ height: 50px;
+}
+
+.modal-70 {
+ height: 70px;
+}
+
+/** Creates an animated border for each channel */
+@each $channel,
+$color in colors.$channel-map {
+ .modal-#{$channel} {
+ &:after {
+ animation: gradient 10s linear infinite;
+ background: linear-gradient(to right,
+ darken($color, 35%),
+ $color,
+ lighten($color, 10%),
+ $color,
+ darken($color, 35%));
+ background-position: 0% 0%;
+ background-size: 500% auto;
+ bottom: 0px;
+ content: '';
+ height: 2px;
+ left: 0px;
+ position: absolute;
+ right: 0px;
+ z-index: 999;
+ }
+ }
+}
diff --git a/tgui/packages/tgui-say/styles/textarea.scss b/tgui/packages/tgui-say/styles/textarea.scss
new file mode 100644
index 00000000000..a34e5ba1963
--- /dev/null
+++ b/tgui/packages/tgui-say/styles/textarea.scss
@@ -0,0 +1,24 @@
+@use './colors.scss';
+
+.textarea {
+ background-color: transparent;
+ border: none;
+ color: colors.$background;
+ flex-grow: 1;
+ font-family: "Lucida Console", monospace;
+ font-size: 1.1rem;
+ margin-top: 2px;
+}
+
+.textarea-lightMode {
+ @extend .textarea;
+ color: black;
+}
+
+/** Creates an input for each channel */
+@each $channel,
+$color in colors.$channel-map {
+ .textarea-#{$channel} {
+ color: $color;
+ }
+}
diff --git a/tgui/packages/tgui-say/types/index.tsx b/tgui/packages/tgui-say/types/index.tsx
new file mode 100644
index 00000000000..c75f7e4d31b
--- /dev/null
+++ b/tgui/packages/tgui-say/types/index.tsx
@@ -0,0 +1,57 @@
+import { RefObject } from 'inferno';
+
+export type Modal = {
+ events: Events;
+ fields: Fields;
+ setState: (state: {}) => void;
+ state: State;
+ timers: Timers;
+};
+
+type Events = {
+ onArrowKeys: (direction: number) => void;
+ onBackspaceDelete: () => void;
+ onClick: () => void;
+ onEscape: () => void;
+ onEnter: (event: KeyboardEvent, value: string) => void;
+ onForce: () => void;
+ onKeyDown: (event: KeyboardEvent) => void;
+ onIncrementChannel: () => void;
+ onInput: (event: InputEvent, value: string) => void;
+ onComponentMount: () => void;
+ onComponentUpdate: () => void;
+ onRadioPrefix: () => void;
+ onReset: (channel?: number) => void;
+ onSetSize: (size: number) => void;
+ onViewHistory: () => void;
+};
+
+type Fields = {
+ historyCounter: number;
+ innerRef: RefObject