diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 60a6fbdad96..3def1e0390c 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -71,6 +71,11 @@
/icons/ @ShizCalev
/sound/ @ShizCalev
+# stylemistake
+
+/tgui @stylemistake
+/tgui-next @stylemistake
+
# Qustinnus
/code/datums/components/mood.dm @Qustinnus
/code/datums/mood_events/ @Qustinnus
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
index e9cb60a587a..0abac7a5338 100644
--- a/.vscode/extensions.json
+++ b/.vscode/extensions.json
@@ -2,6 +2,7 @@
"recommendations": [
"gbasood.byond-dm-language-support",
"platymuus.dm-langclient",
- "EditorConfig.EditorConfig"
+ "EditorConfig.EditorConfig",
+ "dbaeumer.vscode-eslint"
]
}
diff --git a/code/__HELPERS/_logging.dm b/code/__HELPERS/_logging.dm
index d685ef9e46e..68f1d8dc605 100644
--- a/code/__HELPERS/_logging.dm
+++ b/code/__HELPERS/_logging.dm
@@ -187,6 +187,11 @@
/proc/log_mapping(text)
WRITE_LOG(GLOB.world_map_error_log, text)
+/* ui logging */
+
+/proc/log_tgui(text)
+ WRITE_LOG(GLOB.tgui_log, text)
+
/* For logging round startup. */
/proc/start_log(log)
WRITE_LOG(log, "Starting up round ID [GLOB.round_id].\n-------------------------")
diff --git a/code/_globalvars/logging.dm b/code/_globalvars/logging.dm
index 45e96b9b9b6..0693158a653 100644
--- a/code/_globalvars/logging.dm
+++ b/code/_globalvars/logging.dm
@@ -38,6 +38,8 @@ GLOBAL_VAR(world_map_error_log)
GLOBAL_PROTECT(world_map_error_log)
GLOBAL_VAR(world_paper_log)
GLOBAL_PROTECT(world_paper_log)
+GLOBAL_VAR(tgui_log)
+GLOBAL_PROTECT(tgui_log)
GLOBAL_LIST_EMPTY(bombers)
GLOBAL_PROTECT(bombers)
diff --git a/code/_onclick/ai.dm b/code/_onclick/ai.dm
index 3a3f58b5c5f..4dcb8f1906a 100644
--- a/code/_onclick/ai.dm
+++ b/code/_onclick/ai.dm
@@ -137,10 +137,7 @@
if(obj_flags & EMAGGED)
return
- if(locked)
- bolt_raise(usr)
- else
- bolt_drop(usr)
+ toggle_bolt(usr)
add_hiddenprint(usr)
/obj/machinery/door/airlock/AIAltClick() // Eletrifies doors.
@@ -163,10 +160,7 @@
if(obj_flags & EMAGGED)
return
- if(!emergency)
- emergency_on(usr)
- else
- emergency_off(usr)
+ toggle_emergency(usr)
add_hiddenprint(usr)
/* APC */
diff --git a/code/controllers/subsystem/tgui.dm b/code/controllers/subsystem/tgui.dm
index b10d0af8c51..c687a3b20e0 100644
--- a/code/controllers/subsystem/tgui.dm
+++ b/code/controllers/subsystem/tgui.dm
@@ -11,7 +11,7 @@ SUBSYSTEM_DEF(tgui)
var/basehtml // The HTML base used for all UIs.
/datum/controller/subsystem/tgui/PreInit()
- basehtml = file2text('tgui/tgui.html')
+ basehtml = file2text('tgui-next/packages/tgui/public/tgui-main.html')
/datum/controller/subsystem/tgui/Shutdown()
close_all_uis()
@@ -34,4 +34,3 @@ SUBSYSTEM_DEF(tgui)
processing_uis.Remove(ui)
if (MC_TICK_CHECK)
return
-
diff --git a/code/datums/wires/_wires.dm b/code/datums/wires/_wires.dm
index ce8cb318560..8204e98653b 100644
--- a/code/datums/wires/_wires.dm
+++ b/code/datums/wires/_wires.dm
@@ -222,7 +222,7 @@
datum/tgui/master_ui = null, datum/ui_state/state = GLOB.physical_state)
ui = SStgui.try_update_ui(user, src, ui_key, ui, force_open)
if (!ui)
- ui = new(user, src, ui_key, "wires", "[holder.name] wires", 350, 150 + wires.len * 30, master_ui, state)
+ ui = new(user, src, ui_key, "wires", "[holder.name] Wires", 350, 150 + wires.len * 30, master_ui, state)
ui.open()
/datum/wires/ui_data(mob/user)
diff --git a/code/game/machinery/doors/airlock.dm b/code/game/machinery/doors/airlock.dm
index ff8ee8b3bf8..83d6c40f4a4 100644
--- a/code/game/machinery/doors/airlock.dm
+++ b/code/game/machinery/doors/airlock.dm
@@ -1365,7 +1365,7 @@
datum/tgui/master_ui = null, datum/ui_state/state = GLOB.default_state)
ui = SStgui.try_update_ui(user, src, ui_key, ui, force_open)
if(!ui)
- ui = new(user, src, ui_key, "ai_airlock", name, 550, 456, master_ui, state)
+ ui = new(user, src, ui_key, "ai_airlock", name, 500, 390, master_ui, state)
ui.open()
return TRUE
@@ -1434,84 +1434,24 @@
if("shock-perm")
shock_perm(usr)
. = TRUE
- if("idscan-on")
- if(wires.is_cut(WIRE_IDSCAN))
- to_chat(usr, "You can't enable IdScan - The IdScan wire has been cut.")
- else if(aiDisabledIdScanner)
- aiDisabledIdScanner = FALSE
- else
- to_chat(usr, "The IdScan feature is not disabled.")
+ if("idscan-toggle")
+ aiDisabledIdScanner = !aiDisabledIdScanner
. = TRUE
- if("idscan-off")
- if(wires.is_cut(WIRE_IDSCAN))
- to_chat(usr, "The IdScan wire has been cut - So, you can't disable it, but it is already disabled anyways.")
- else if(aiDisabledIdScanner)
- to_chat(usr, "You've already disabled the IdScan feature.")
- else
- aiDisabledIdScanner = TRUE
+ if("emergency-toggle")
+ toggle_emergency(usr)
. = TRUE
- if("emergency-on")
- emergency_on(usr)
+ if("bolt-toggle")
+ toggle_bolt(usr)
. = TRUE
- if("emergency-off")
- emergency_off(usr)
+ if("light-toggle")
+ lights = !lights
+ update_icon()
. = TRUE
- if("bolt-raise")
- bolt_raise(usr)
+ if("safe-toggle")
+ safe = !safe
. = TRUE
- if("bolt-drop")
- bolt_drop(usr)
- . = TRUE
- if("light-on")
- if(wires.is_cut(WIRE_LIGHT))
- to_chat(usr, "Control to door bolt lights has been severed.")
- else if (!lights)
- lights = TRUE
- update_icon()
- else
- to_chat(usr, text("Door bolt lights are already enabled!"))
- . = TRUE
- if("light-off")
- if(wires.is_cut(WIRE_LIGHT))
- to_chat(usr, "Control to door bolt lights has been severed.")
- else if (lights)
- lights = FALSE
- update_icon()
- else
- to_chat(usr, "Door bolt lights are already disabled!")
- . = TRUE
- if("safe-on")
- if(wires.is_cut(WIRE_SAFETY))
- to_chat(usr, "Control to door sensors is disabled.")
- else if (!safe)
- safe = TRUE
- else
- to_chat(usr, "Firmware reports safeties already in place.")
- . = TRUE
- if("safe-off")
- if(wires.is_cut(WIRE_SAFETY))
- to_chat(usr, "Control to door sensors is disabled.")
- else if (safe)
- safe = FALSE
- else
- to_chat(usr, "Firmware reports safeties already overridden.")
- . = TRUE
- if("speed-on")
- if(wires.is_cut(WIRE_TIMING))
- to_chat(usr, "Control to door timing circuitry has been severed.")
- else if (!normalspeed)
- normalspeed = 1
- else
- to_chat(usr,"Door timing circuitry currently operating normally.")
- . = TRUE
- if("speed-off")
- if(wires.is_cut(WIRE_TIMING))
- to_chat(usr, "Control to door timing circuitry has been severed.")
- else if (normalspeed)
- normalspeed = 0
- else
- to_chat(usr, "Door timing circuitry already accelerated.")
-
+ if("speed-toggle")
+ normalspeed = !normalspeed
. = TRUE
if("open-close")
user_toggle_open(usr)
@@ -1544,45 +1484,26 @@
else
set_electrified(MACHINE_ELECTRIFIED_PERMANENT, user)
-/obj/machinery/door/airlock/proc/emergency_on(mob/user)
- if(!user_allowed(user))
- return
- if (!emergency)
- emergency = TRUE
- update_icon()
- else
- to_chat(user, "Emergency access is already enabled!")
-
-/obj/machinery/door/airlock/proc/emergency_off(mob/user)
- if(!user_allowed(user))
- return
- if (emergency)
- emergency = FALSE
- update_icon()
- else
- to_chat(user, "Emergency access is already disabled!")
-
-/obj/machinery/door/airlock/proc/bolt_raise(mob/user)
+/obj/machinery/door/airlock/proc/toggle_bolt(mob/user)
if(!user_allowed(user))
return
if(wires.is_cut(WIRE_BOLTS))
- to_chat(user, "The door bolt drop wire is cut - you can't raise the door bolts")
- else if(!locked)
- to_chat(user, "The door bolts are already up")
- else
- if(hasPower())
- unbolt()
+ to_chat(user, "The door bolt drop wire is cut - you can't toggle the door bolts.")
+ return
+ if(locked)
+ if(!hasPower())
+ to_chat(user, "The door has no power - you can't raise the door bolts.")
else
- to_chat(user, "Cannot raise door bolts due to power failure")
-
-/obj/machinery/door/airlock/proc/bolt_drop(mob/user)
- if(!user_allowed(user))
- return
- if(wires.is_cut(WIRE_BOLTS))
- to_chat(user, "You can't drop the door bolts - The door bolt dropping wire has been cut.")
+ unbolt()
else
bolt()
+/obj/machinery/door/airlock/proc/toggle_emergency(mob/user)
+ if(!user_allowed(user))
+ return
+ emergency = !emergency
+ update_icon()
+
/obj/machinery/door/airlock/proc/user_toggle_open(mob/user)
if(!user_allowed(user))
return
diff --git a/code/game/machinery/doors/airlock_electronics.dm b/code/game/machinery/doors/airlock_electronics.dm
index 48d826fac53..395d581527a 100644
--- a/code/game/machinery/doors/airlock_electronics.dm
+++ b/code/game/machinery/doors/airlock_electronics.dm
@@ -15,7 +15,7 @@
datum/tgui/master_ui = null, datum/ui_state/state = GLOB.hands_state)
ui = SStgui.try_update_ui(user, src, ui_key, ui, force_open)
if(!ui)
- ui = new(user, src, ui_key, "airlock_electronics", name, 975, 420, master_ui, state)
+ ui = new(user, src, ui_key, "airlock_electronics", name, 420, 485, master_ui, state)
ui.open()
/obj/item/electronics/airlock/ui_data()
@@ -44,10 +44,13 @@
if(..())
return
switch(action)
- if("clear")
+ if("clear_all")
accesses = list()
one_access = 0
. = TRUE
+ if("grant_all")
+ accesses = get_all_accesses()
+ . = TRUE
if("one_access")
one_access = !one_access
. = TRUE
diff --git a/code/game/machinery/doors/brigdoors.dm b/code/game/machinery/doors/brigdoors.dm
index b193017dc37..18384a611bf 100644
--- a/code/game/machinery/doors/brigdoors.dm
+++ b/code/game/machinery/doors/brigdoors.dm
@@ -38,7 +38,7 @@
maptext_width = 32
maptext_y = -1
ui_x = 300
- ui_y = 200
+ ui_y = 138
/obj/machinery/door_timer/Initialize()
. = ..()
diff --git a/code/game/world.dm b/code/game/world.dm
index bd6d64e1ebd..2ed3151d132 100644
--- a/code/game/world.dm
+++ b/code/game/world.dm
@@ -130,6 +130,7 @@ GLOBAL_VAR(restart_counter)
GLOB.query_debug_log = "[GLOB.log_directory]/query_debug.log"
GLOB.world_job_debug_log = "[GLOB.log_directory]/job_debug.log"
GLOB.world_paper_log = "[GLOB.log_directory]/paper.log"
+ GLOB.tgui_log = "[GLOB.log_directory]/tgui.log"
#ifdef UNIT_TESTS
GLOB.test_log = file("[GLOB.log_directory]/tests.log")
@@ -144,6 +145,7 @@ GLOBAL_VAR(restart_counter)
start_log(GLOB.world_qdel_log)
start_log(GLOB.world_runtime_log)
start_log(GLOB.world_job_debug_log)
+ start_log(GLOB.tgui_log)
GLOB.changelog_hash = md5('html/changelog.html') //for telling if the changelog has changed recently
if(fexists(GLOB.config_error_log))
diff --git a/code/modules/atmospherics/machinery/components/trinary_devices/filter.dm b/code/modules/atmospherics/machinery/components/trinary_devices/filter.dm
index 5a15d4e000b..1dd0756c223 100644
--- a/code/modules/atmospherics/machinery/components/trinary_devices/filter.dm
+++ b/code/modules/atmospherics/machinery/components/trinary_devices/filter.dm
@@ -15,7 +15,7 @@
pipe_state = "filter"
ui_x = 475
- ui_y = 195
+ ui_y = 185
/obj/machinery/atmospherics/components/trinary/filter/CtrlClick(mob/user)
if(user.canUseTopic(src, BE_CLOSE, FALSE, NO_TK))
diff --git a/code/modules/cargo/console.dm b/code/modules/cargo/console.dm
index 53d5a5632ff..517b444a16e 100644
--- a/code/modules/cargo/console.dm
+++ b/code/modules/cargo/console.dm
@@ -3,8 +3,8 @@
desc = "Used to order supplies, approve requests, and control the shuttle."
icon_screen = "supply"
circuit = /obj/item/circuitboard/computer/cargo
- ui_x = 750
- ui_y = 850
+ ui_x = 780
+ ui_y = 750
var/requestonly = FALSE
var/contraband = FALSE
@@ -131,8 +131,6 @@
/obj/machinery/computer/cargo/ui_act(action, params, datum/tgui/ui)
if(..())
return
- if(action != "add" && requestonly)
- return
switch(action)
if("send")
if(!SSshuttle.supply.canMove())
diff --git a/code/modules/client/asset_cache.dm b/code/modules/client/asset_cache.dm
index 15f5371022e..a36c3c7bcdb 100644
--- a/code/modules/client/asset_cache.dm
+++ b/code/modules/client/asset_cache.dm
@@ -325,6 +325,13 @@ GLOBAL_LIST_EMPTY(asset_datums)
var/size_id = sprite[SPR_SIZE]
return {""}
+/datum/asset/spritesheet/proc/icon_class_name(sprite_name)
+ var/sprite = sprites[sprite_name]
+ if (!sprite)
+ return null
+ var/size_id = sprite[SPR_SIZE]
+ return {"[name][size_id] [sprite_name]"}
+
#undef SPR_SIZE
#undef SPR_IDX
#undef SPRSZ_COUNT
@@ -381,8 +388,19 @@ GLOBAL_LIST_EMPTY(asset_datums)
/datum/asset/simple/tgui
assets = list(
- "tgui.css" = 'tgui/assets/tgui.css',
- "tgui.js" = 'tgui/assets/tgui.js',
+ // tgui
+ "tgui.css" = 'tgui/assets/tgui.css',
+ "tgui.js" = 'tgui/assets/tgui.js',
+ // tgui-next
+ "tgui-main.html" = 'tgui-next/packages/tgui/public/tgui-main.html',
+ "tgui-fallback.html" = 'tgui-next/packages/tgui/public/tgui-fallback.html',
+ "tgui.bundle.js" = 'tgui-next/packages/tgui/public/bundles/tgui.bundle.js',
+ "tgui.bundle.css" = 'tgui-next/packages/tgui/public/bundles/tgui.bundle.css',
+ "shim-console.js" = 'tgui-next/packages/tgui/public/shim-console.js',
+ "shim-html5shiv.js" = 'tgui-next/packages/tgui/public/shim-html5shiv.js',
+ "shim-ie8.js" = 'tgui-next/packages/tgui/public/shim-ie8.js',
+ "shim-dom4.js" = 'tgui-next/packages/tgui/public/shim-dom4.js',
+ "shim-css-om.js" = 'tgui-next/packages/tgui/public/shim-css-om.js',
)
/datum/asset/group/tgui
diff --git a/code/modules/plumbing/plumbers/acclimator.dm b/code/modules/plumbing/plumbers/acclimator.dm
index a791a690df9..1a2a070504b 100644
--- a/code/modules/plumbing/plumbers/acclimator.dm
+++ b/code/modules/plumbing/plumbers/acclimator.dm
@@ -22,7 +22,7 @@
///COOLING, HEATING or NEUTRAL. We track this for change, so we dont needlessly update our icon
var/acclimate_state
- ui_x = 300
+ ui_x = 320
ui_y = 260
/obj/machinery/plumbing/acclimator/Initialize()
@@ -86,10 +86,8 @@
if("set_allowed_temperature_difference")
var/target = input("New acceptable difference:", name, allowed_temperature_difference) as num|null
allowed_temperature_difference = CLAMP(target, 0, 1000)
- if("turn_on")
- enabled = TRUE
- if("turn_off")
- enabled = FALSE
+ if("toggle_power")
+ enabled = !enabled
#undef COOLING
#undef HEATING
#undef NEUTRAL
diff --git a/code/modules/power/apc.dm b/code/modules/power/apc.dm
index 0351c9df6c3..9b1b7187fba 100644
--- a/code/modules/power/apc.dm
+++ b/code/modules/power/apc.dm
@@ -57,8 +57,8 @@
damage_deflection = 10
resistance_flags = FIRE_PROOF
interaction_flags_machine = INTERACT_MACHINE_WIRES_IF_OPEN | INTERACT_MACHINE_ALLOW_SILICON | INTERACT_MACHINE_OPEN_SILICON
- ui_x = 535
- ui_y = 515
+ ui_x = 450
+ ui_y = 460
var/lon_range = 1.5
var/area/area
diff --git a/code/modules/reagents/chemistry/machinery/chem_dispenser.dm b/code/modules/reagents/chemistry/machinery/chem_dispenser.dm
index 70faaecae22..31f7c291cf0 100644
--- a/code/modules/reagents/chemistry/machinery/chem_dispenser.dm
+++ b/code/modules/reagents/chemistry/machinery/chem_dispenser.dm
@@ -23,7 +23,7 @@
resistance_flags = FIRE_PROOF | ACID_PROOF
circuit = /obj/item/circuitboard/machine/chem_dispenser
ui_x = 565
- ui_y = 550
+ ui_y = 620
var/obj/item/stock_parts/cell/cell
var/powerefficiency = 0.1
diff --git a/code/modules/recycling/disposal/bin.dm b/code/modules/recycling/disposal/bin.dm
index 700bb3926f6..921ab8444bd 100644
--- a/code/modules/recycling/disposal/bin.dm
+++ b/code/modules/recycling/disposal/bin.dm
@@ -298,8 +298,7 @@
data["full_pressure"] = full_pressure
data["pressure_charging"] = pressure_charging
data["panel_open"] = panel_open
- var/per = CLAMP(100* air_contents.return_pressure() / (SEND_PRESSURE), 0, 100)
- data["per"] = round(per, 1)
+ data["per"] = CLAMP01(air_contents.return_pressure() / (SEND_PRESSURE))
data["isai"] = isAI(user)
return data
diff --git a/code/modules/station_goals/bsa.dm b/code/modules/station_goals/bsa.dm
index 9992e20c4c2..502fd567a60 100644
--- a/code/modules/station_goals/bsa.dm
+++ b/code/modules/station_goals/bsa.dm
@@ -215,7 +215,7 @@
icon = 'icons/obj/machines/particle_accelerator.dmi'
icon_state = "control_boxp"
ui_x = 400
- ui_y = 305
+ ui_y = 220
var/obj/machinery/bsa/full/cannon
var/notice
@@ -255,6 +255,8 @@
update_icon()
/obj/machinery/computer/bsa_control/proc/calibrate(mob/user)
+ if(!GLOB.bsa_unlock)
+ return
var/list/gps_locators = list()
for(var/datum/component/gps/G in GLOB.GPS_list) //nulls on the list somehow
if(G.tracking)
diff --git a/code/modules/tgui/tgui.dm b/code/modules/tgui/tgui.dm
index a13bb7f901e..5d7b03d742c 100644
--- a/code/modules/tgui/tgui.dm
+++ b/code/modules/tgui/tgui.dm
@@ -289,6 +289,8 @@
if(params["screen"])
ui_screen = params["screen"]
SStgui.update_uis(src_object)
+ if("tgui:log")
+ log_message(params["log"])
if("tgui:link")
user << link(params["url"])
if("tgui:fancy")
@@ -385,3 +387,6 @@
/datum/tgui/proc/set_titlebar(value)
titlebar = value
+
+/datum/tgui/proc/log_message(message)
+ log_tgui("[user] ([user.ckey]) using \"[title]\":\n[message]")
diff --git a/tgui-next/.editorconfig b/tgui-next/.editorconfig
new file mode 100644
index 00000000000..33092d4928a
--- /dev/null
+++ b/tgui-next/.editorconfig
@@ -0,0 +1,13 @@
+# http://editorconfig.org
+root = true
+
+[*]
+indent_style = space
+indent_size = 2
+end_of_line = lf
+charset = utf-8
+trim_trailing_whitespace = true
+insert_final_newline = true
+
+[*.md]
+max_line_length = 80
diff --git a/tgui-next/.eslintignore b/tgui-next/.eslintignore
new file mode 100644
index 00000000000..010416b9e88
--- /dev/null
+++ b/tgui-next/.eslintignore
@@ -0,0 +1,6 @@
+/**/node_modules
+/**/*.bundle.*
+/**/*.chunk.*
+/**/*.hot-update.*
+/packages/inferno/**
+/packages/tgui/public/shim-*.js
diff --git a/tgui-next/.eslintrc.yml b/tgui-next/.eslintrc.yml
new file mode 100644
index 00000000000..13f468ab3a8
--- /dev/null
+++ b/tgui-next/.eslintrc.yml
@@ -0,0 +1,118 @@
+parser: babel-eslint
+parserOptions:
+ ecmaVersion: 2019
+ sourceType: module
+ ecmaFeatures:
+ jsx: true
+env:
+ browser: true
+ node: true
+rules:
+ ## Possible Errors
+ # for-direction: error
+ # getter-return: error
+ no-async-promise-executor: error
+ # no-await-in-loop: error
+ # no-compare-neg-zero: error
+ no-cond-assign: error
+ # no-console: error
+ # no-constant-condition: error
+ # no-control-regex: error
+ # no-debugger: error
+ no-dupe-args: error
+ no-dupe-keys: error
+ no-duplicate-case: error
+ # no-empty: error
+ no-empty-character-class: error
+ no-ex-assign: error
+ no-extra-boolean-cast: error
+ # no-extra-parens: warn
+ no-extra-semi: error
+ no-func-assign: error
+ no-import-assign: error
+ no-inner-declarations: error
+ no-invalid-regexp: error
+ no-irregular-whitespace: error
+ no-misleading-character-class: error
+ no-obj-calls: error
+ no-prototype-builtins: error
+ no-regex-spaces: error
+ no-sparse-arrays: error
+ no-template-curly-in-string: error
+ no-unexpected-multiline: error
+ # no-unreachable: warn
+ no-unsafe-finally: error
+ no-unsafe-negation: error
+ # require-atomic-updates: error
+ use-isnan: error
+ valid-typeof: error
+
+ ## Best practices
+ complexity: [error, { max: 50 }] ## That is a VERY generous limit
+ curly: [error, all]
+ eqeqeq: [error, always]
+ dot-location: [error, property]
+ no-empty-pattern: error
+ no-multi-spaces: warn
+ no-octal: error
+ no-octal-escape: error
+ no-return-assign: error
+ no-self-assign: error
+ no-sequences: error
+ no-unused-labels: warn
+ no-useless-escape: warn
+ no-with: error
+ radix: error
+
+ ## Code style
+ array-bracket-newline: [error, consistent]
+ array-bracket-spacing: [error, never]
+ block-spacing: [error, always]
+ brace-style: [error, stroustrup, { allowSingleLine: false }]
+ comma-dangle: [error, always-multiline]
+ comma-spacing: [error, { before: false, after: true }]
+ comma-style: [error, last]
+ computed-property-spacing: [error, never]
+ func-call-spacing: [error, never]
+ func-style: [error, expression]
+ ## This rule does not honor a newline on opening paren.
+ # function-paren-newline: [error, never]
+ indent: [error, 2, {
+ SwitchCase: 1,
+ }]
+ jsx-quotes: [error, prefer-double]
+ key-spacing: [error, { beforeColon: false, afterColon: true }]
+ keyword-spacing: [error, { before: true, after: true }]
+ max-len: [error, { code: 120 }]
+ multiline-ternary: [error, always-multiline]
+ no-mixed-spaces-and-tabs: error
+ no-whitespace-before-property: error
+ operator-linebreak: [error, before]
+ # quotes: [error, single]
+ semi: error
+ semi-spacing: [error, { before: false, after: true }]
+ semi-style: [error, last]
+ space-before-blocks: [error, always]
+ space-before-function-paren: [error, {
+ anonymous: always,
+ named: never,
+ asyncArrow: always,
+ }]
+ space-in-parens: [error, never]
+ spaced-comment: [error, always]
+ switch-colon-spacing: [error, { before: false, after: true }]
+ template-tag-spacing: [error, never]
+ # unicode-bom: [error, never]
+
+ ## ES6
+ arrow-parens: [error, as-needed]
+ arrow-spacing: [error, { before: true, after: true }]
+ generator-star-spacing: [error, { before: false, after: true }]
+ no-class-assign: error
+ no-const-assign: error
+ no-dupe-class-members: error
+ no-new-symbol: error
+ no-this-before-super: error
+ no-var: error
+ prefer-arrow-callback: error
+ yield-star-spacing: [error, { before: false, after: true }]
diff --git a/tgui-next/.gitattributes b/tgui-next/.gitattributes
new file mode 100644
index 00000000000..eedf5cb0fc1
--- /dev/null
+++ b/tgui-next/.gitattributes
@@ -0,0 +1,10 @@
+* text=auto
+
+## Enforce text mode and LF line breaks
+*.js text eol=lf
+*.css text eol=lf
+*.html text eol=lf
+*.json text eol=lf
+
+## Treat bundles as binary and ignore them during conflicts
+# *.bundle.* binary merge=theirs
diff --git a/tgui-next/.gitignore b/tgui-next/.gitignore
new file mode 100644
index 00000000000..2844d947c39
--- /dev/null
+++ b/tgui-next/.gitignore
@@ -0,0 +1,4 @@
+node_modules
+*.log
+/packages/tgui/public/bundles/*.hot-update.*
+package-lock.json
diff --git a/tgui-next/README.md b/tgui-next/README.md
new file mode 100644
index 00000000000..a56d74d45b4
--- /dev/null
+++ b/tgui-next/README.md
@@ -0,0 +1,570 @@
+# tgui-next
+
+## Introduction
+
+tgui is a robust user interface framework of /tg/station. It is rendered
+completely in the browser, based on JSON data sent from the server.
+This data flow is always unidirectional, and the only way to make changes
+to the game state is to dispatch actions which are processed on the server,
+in a similar method to native BYOND Topic(). Once the action is processed,
+an updated JSON is sent.
+
+tgui is very different from most UIs you will encounter in BYOND programming,
+and is heavily reliant of Javascript and web technologies as opposed to DM.
+However, if you are familiar with NanoUI (a library which can be found on almost
+every other SS13 codebase), tgui should be fairly easy to pick up.
+
+tgui is a fork of an older tgui (based on Ractive), which is a fork of NanoUI.
+The server-side code (DM) is similar and derived from NanoUI, while the
+clientside is a wholly new project with no code in common.
+
+To get a clearer picture how to create a completely new interface from scratch,
+please refer to this [tutorial document](docs/tutorial-and-examples.md).
+If you don't know how tgui backend works, or have very little knowledge about
+both frontend and backend, or simply want a step by step instruction,
+we recommend you first read the document linked above.
+
+This project uses **Inferno** - a very fast UI rendering engine with a similar
+API to React. If you are new to Inferno or React, take your time to read
+these documents:
+
+- [React guide](https://reactjs.org/docs/hello-world.html)
+- [Inferno documentation](https://infernojs.org/docs/guides/components) -
+highlights differences with React.
+
+## Pre-requisites
+
+You will need these programs to start developing in tgui:
+
+- [Node 12.x](https://nodejs.org)
+- [MSys2](https://www.msys2.org/) (optional)
+
+> MSys2 closely replicates a unix-like environment which is necessary for
+> the `bin/tgui` script to run. It comes with a robust "mintty" terminal
+> emulator which is better than any standard Windows shell, it supports
+> "git" out of the box (almost like Git for Windows, but better), has
+> a "pacman" package manager, and you can install a text editor like "vim"
+> for a full boomer experience.
+
+## Workflow
+
+If you haven't opened the console already, you can do that by holding
+Shift and right clicking on the `tgui-next` folder, then pressing
+either `Open command window here` or `Open PowerShell window here`.
+
+Run `npm install`, then:
+
+- `npm run build` - build the project in production mode.
+- `npm run watch` - launch a development server.
+- `npm run lint` - show and fix potential problems with the code.
+- `npm run analyze` - run a bundle analyzer.
+
+For MSys2, WSL, Linux or macOS users:
+
+- `bin/tgui` - build the project in production mode.
+- `bin/tgui --dev` - launch a development server.
+- `bin/tgui --lint` - show and fix potential problems with the code.
+- `bin/tgui --analyze` - run a bundle analyzer.
+- `bin/tgui --clean` - clean up project repo.
+- `bin/tgui [webpack options]` - build the project with custom webpack
+options.
+
+For absolute brainlets, we also got a batch file in store. Double click
+it to build the project:
+
+- `bin/tgui-build.bat` - build the project in production mode.
+
+Remember to always run a full build before submitting a PR. It creates
+a compressed javascript bundle which is then referenced from DM code.
+We prefer to keep it version controlled, so that people could build the
+game just by using Dream Maker.
+
+## Project structure
+
+- `/packages` - Each folder here represents a self-contained Node module.
+- `/packages/common` - Helper functions
+- `/packages/tgui/index.js` - Application entry point.
+- `/packages/tgui/components` - Basic UI building blocks.
+- `/packages/tgui/interfaces` - Actual in-game interfaces.
+Interface takes data via the `state` prop and outputs an html-like stucture,
+which you can build using existing UI components.
+- `/packages/tgui/routes.js` - This is where you want to register new
+interfaces, otherwise they simply won't load.
+- `/packages/tgui/layout.js` - A root-level component, holding the
+window elements, like the titlebar, buttons, resize handlers. Calls
+`routes.js` to decide which component to render.
+- `/packages/tgui/styles/main.scss` - CSS entry point.
+- `/packages/tgui/styles/atomic.scss` - Atomic CSS classes.
+These are very simple, tiny, reusable CSS classes which you can use and
+combine to change appearance of your elements. Keep them small.
+- `/packages/tgui/styles/components.scss` - CSS classes which are used
+in UI components, and most of the stylesheets referenced here are located
+in `/packages/tgui/components`. These stylesheets closely follow the
+[BEM](https://en.bem.info/methodology/) methodology.
+- `/packages/tgui/styles/functions.scss` - Useful SASS functions.
+Stuff like `lighten`, `darken`, `luminance` are defined here.
+
+## Component reference
+
+> Notice: This documentation might be out of date, so always check the source
+> code to see the most up-to-date information.
+
+These are the components which you can use for interface construction.
+If you have trouble finding the exact prop you need on a component,
+please note, that most of these components inherit from other basic
+components, such as `Box`. This component in particular provides a lot
+of styling options for all components, e.g. `color` and `opacity`, thus
+it is used a lot in this framework.
+
+There are a few important semantics you need to know about:
+
+- `content` prop is a synonym to a `children` prop.
+ - `content` is better used when your element is a self-closing tag
+ (like ``), and when content is small and simple
+ enough to fit in a prop. Keep in mind, that this prop is **not** native
+ to React, and is a feature of this component system.
+ - `children` is better used when your element is a full tag (like
+ ``), and when content is long and complex. This is
+ a native React prop (unlike `content`), and contains all elements you
+ defined between the opening and the closing tag of an element.
+ - You should never use both on a same element.
+ - You should never use `children` explicitly as a prop on an element.
+- Inferno supports both camelcase (`onClick`) and lowercase (`onclick`)
+event names.
+ - Camel case names are what's called "synthetic" events, and are the
+ *preferred way* of handling events in React, for efficiency and
+ performance reasons. Please read
+ [Inferno Event Handling](https://infernojs.org/docs/guides/event-handling)
+ to understand what this is about.
+ - Lower case names are native browser events and should be used sparingly,
+ for example when you need an explicit IE8 support. **DO NOT** use
+ lowercase event handlers unless you really know what you are doing.
+ - [Button](#button) component straight up does not support lowercase event
+ handlers. Use the camel case `onClick` instead.
+
+### `AnimatedNumber`
+
+This component provides animations for numeric values.
+
+Props:
+
+- `value: number` - Value to animate.
+- `initial: number` - Initial value to use in animation when element
+first appears. If you set initial to `0` for example, number will always
+animate starting from `0`, and if omitted, it will not play an initial
+animation.
+- `format: function` - Output formatter.
+ - Example: `value => Math.round(value)`.
+
+### `Box`
+
+The Box component serves as a wrapper component for most of the CSS utility
+needs. It creates a new DOM element, a `
` by default that can be changed
+with the `as` property. Let's say you want to use a `` instead:
+
+```jsx
+
+
+
+```
+
+This works great when the changes can be isolated to a new DOM element.
+For instance, you can change the margin this way.
+
+However, sometimes you have to target the underlying DOM element.
+For instance, you want to change the text color of the button. The Button
+component defines its own color. CSS inheritance doesn't help.
+
+To workaround this problem, the Box children accept a render props function.
+This way, `Button` can pull out the `className` generated by the `Box`.
+
+```jsx
+
+ {props => }
+
+```
+
+`Box` units, like width, height and margins can be defined in two ways:
+- By plain numbers (1 unit equals `0.5em`);
+- In absolute measures, by providing a full unit string (e.g. `100px`).
+
+Units which are used in `Box` are `0.5em`, which are half font-size.
+Default font size is `12px`, so each unit is effectively `6px` in size.
+If you need more precision, you can always use fractional numbers.
+
+Props:
+
+- `as: string` - The component used for the root node.
+- `color: string` - Applies an atomic `color-` class to the element.
+ - See `styles/atomic/color.scss`.
+- `width: number` - Box width.
+- `minWidth: number` - Box minimum width.
+- `maxWidth: number` - Box maximum width.
+- `height: number` - Box height.
+- `minHeight: number` - Box minimum height.
+- `maxHeight: number` - Box maximum height.
+- `lineHeight: number` - Directly affects the height of text lines.
+Useful for adjusting button height.
+- `inline: boolean` - Forces the `Box` to appear as an `inline-block`,
+or in other words, makes the `Box` flow with the text instead of taking
+all available horizontal space.
+- `m: number` - Margin on all sides.
+- `mx: number` - Horizontal margin.
+- `my: number` - Vertical margin.
+- `mt: number` - Top margin.
+- `mb: number` - Bottom margin.
+- `ml: number` - Left margin.
+- `mr: number` - Right margin.
+- `opacity: number` - Opacity, from 0 to 1.
+- `bold: boolean` - Make text bold.
+- `italic: boolean` - Make text italic.
+- `textAlign: string` - Align text inside the box.
+ - `left` (default)
+ - `center`
+ - `right`
+- `position: string` - A direct mapping to `position` CSS property.
+ - `relative` - Relative positioning.
+ - `absolute` - Absolute positioning.
+ - `fixed` - Fixed positioning.
+- `top: number` - Vertical position of a positioned element.
+- `bottom: number` - Vertical position of a positioned element.
+- `left: number` - Horizontal position of a positioned element.
+- `right: number` - Horizontal position of a positioned element.
+
+### `Button`
+
+Buttons allow users to take actions, and make choices, with a single click.
+
+Props:
+
+- See inherited props: [Box](#box)
+- `fluid: boolean` - Tells the button to fill all available horizontal space.
+- `icon: string` - Adds an icon to the button.
+- `color: string` - Button color, as defined in `variables.scss`.
+ - There is also a special color `transparent` - makes the button
+ transparent and slightly dim when inactive.
+- `disabled: boolean` - Disables and greys out the button.
+- `selected: boolean` - Activates the button (gives it a green color).
+- `tooltip: string` - A fancy, boxy tooltip, which appears when hovering
+over the button.
+- `tooltipPosition: string` - Position of the tooltip.
+ - `top` - Show tooltip above the button.
+ - `bottom` (default) - Show tooltip below the button.
+ - `left` - Show tooltip on the left of the button.
+ - `right` - Show tooltip on the right of the button.
+- `title: string` - A native browser tooltip, which appears when hovering
+over the button.
+- `content/children: any` - Content to render inside the button.
+- `onClick: function` - Called when element is clicked.
+
+### `Flex`
+
+Quickly manage the layout, alignment, and sizing of grid columns, navigation, components, and more with a full suite of responsive flexbox utilities.
+
+If you are new to or unfamiliar with flexbox, we encourage you to read this
+[CSS-Tricks flexbox guide](https://css-tricks.com/snippets/css/a-guide-to-flexbox/).
+
+Consists of two elements: `` and ``. Both of them provide
+the most straight-forward mapping to flex CSS properties as possible.
+
+One of the most basic usage of flex, is to align certain elements
+to the left, and certain elements to the right:
+
+```jsx
+
+
+ Button description
+
+
+
+
+
+
+```
+
+Flex item with `grow` property serves as a "filler", to separate the other
+two flex items as far as possible from each other.
+
+Props:
+
+- See inherited props: [Box](#box)
+- `direction: string` - This establishes the main-axis, thus defining the
+direction flex items are placed in the flex container.
+ - `row` (default) - left to right.
+ - `row-reverse` - right to left.
+ - `column` - top to bottom.
+ - `column-reverse` - bottom to top.
+- `wrap: string` - By default, flex items will all try to fit onto one line.
+You can change that and allow the items to wrap as needed with this property.
+ - `nowrap` (default) - all flex items will be on one line
+ - `wrap` - flex items will wrap onto multiple lines, from top to bottom.
+ - `wrap-reverse` - flex items will wrap onto multiple lines from bottom to top.
+- `align: string` - Default alignment of all children.
+ - `stretch` (default) - stretch to fill the container.
+ - `start` - items are placed at the start of the cross axis.
+ - `end` - items are placed at the end of the cross axis.
+ - `center` - items are centered on the cross axis.
+ - `baseline` - items are aligned such as their baselines align.
+- `justify: string` - This defines the alignment along the main axis.
+It helps distribute extra free space leftover when either all the flex
+items on a line are inflexible, or are flexible but have reached their
+maximum size. It also exerts some control over the alignment of items
+when they overflow the line.
+ - `flex-start` (default) - items are packed toward the start of the
+ flex-direction.
+ - `flex-end` - items are packed toward the end of the flex-direction.
+ - `space-between` - items are evenly distributed in the line; first item is
+ on the start line, last item on the end line
+ - `space-around` - items are evenly distributed in the line with equal space
+ around them. Note that visually the spaces aren't equal, since all the items
+ have equal space on both sides. The first item will have one unit of space
+ against the container edge, but two units of space between the next item
+ because that next item has its own spacing that applies.
+ - `space-evenly` - items are distributed so that the spacing between any two
+ items (and the space to the edges) is equal.
+ - TBD (not all properties are supported in IE11).
+
+### `Flex.Item`
+
+Props:
+
+- See inherited props: [Box](#box)
+- `order: number` - By default, flex items are laid out in the source order.
+However, the order property controls the order in which they appear in the
+flex container.
+- `grow: number` - This defines the ability for a flex item to grow if
+necessary. It accepts a unitless value that serves as a proportion. It
+dictates what amount of the available space inside the flex container the
+item should take up. This number is unit-less and is relative to other
+siblings.
+- `shrink: number` - This defines the ability for a flex item to shrink
+if necessary. Inverse of `grow`.
+- `basis: string` - This defines the default size of an element before the
+remaining space is distributed. It can be a length (e.g. `20%`, `5rem`, etc.),
+an `auto` or `content` keyword.
+- `align: string` - This allows the default alignment (or the one specified by align-items) to be overridden for individual flex items. See: [Flex](#flex).
+
+### `Icon`
+
+Renders one of the FontAwesome icons of your choice.
+
+```jsx
+
+```
+
+To smoothen the transition from v4 to v5, we have added a v4 semantic to
+transform names with `-o` suffixes to FA Regular icons. For example:
+- `square` will get transformed to `fas square`
+- `square-o` will get transformed to `far square`
+
+Props:
+
+- See inherited props: [Box](#box)
+- `name: string` - Icon name.
+- `size: number` - Icon size. `1` is normal size, `2` is two times bigger.
+Fractional numbers are supported.
+
+### `LabeledList`
+
+LabeledList is a continuous, vertical list of text and other content, where
+every item is labeled. It works just like a two column table, where first
+column is labels, and second column is content.
+
+```jsx
+
+
+ Content
+
+
+```
+
+If you want to have a button on the right side of an item (for example,
+to perform some sort of action), there is a way to do that:
+
+```jsx
+
+
+ )}>
+ Content
+
+
+```
+
+Props:
+
+- `children: LabeledList.Item` - Items to render.
+
+### `LabeledList.Item`
+
+Props:
+
+- `label: string` - Item label.
+- `color: string` - Sets the color of the text.
+- `buttons: any` - Buttons to render aside the content.
+- `content/children: any` - Content of this labeled item.
+
+### `LabeledList.Divider`
+
+Adds some empty space between LabeledList items.
+
+Example:
+
+```jsx
+
+
+ Content
+
+
+
+```
+
+Props:
+
+- `size: number` - Size of the divider.
+
+### `ProgressBar`
+
+Progress indicators inform users about the status of ongoing processes.
+
+```jsx
+
+```
+
+- `value: number` - Current progress as a floating point number,
+from 0 to 1. Determines how filled the bar is.
+- `color: string` - Color of the progress bar.
+- `content/children: any` - Content to render inside the progress bar.
+
+### `Section`
+
+Section is a surface that displays content and actions on a single topic.
+
+They should be easy to scan for relevant and actionable information.
+Elements, like text and images, should be placed in them in a way that
+clearly indicates hierarchy.
+
+Section can also be titled to clearly define its purpose.
+
+```jsx
+
+ Here you can order supply crates.
+
+```
+
+If you want to have a button on the right side of an section title
+(for example, to perform some sort of action), there is a way to do that:
+
+```jsx
+
+ )}>
+ Here you can order supply crates.
+
+```
+
+- See inherited props: [Box](#box)
+- `title: string` - Title of the section.
+- `level: number` - Section level in hierarchy. Default is 1, higher number
+means deeper level of nesting. Must be an integer number.
+- `buttons: any` - Buttons to render aside the section title.
+- `content/children: any` - Content of this section.
+
+### `Tabs`
+
+Tabs make it easy to explore and switch between different views.
+
+Here is an example of how you would construct a simple tabbed view:
+
+```jsx
+
+
+ Content for Item one.
+
+
+ Content for Item two.
+
+
+```
+
+This is a rather simple example. In the real world, you might be
+constructing very complex tabbed views which can tax UI performance.
+This is because your tabs are being rendered regardless of their
+visibility status!
+
+There is a simple fix however. Tabs accept functions as children, which
+will be called to retrieve content only when the tab is visible:
+
+```jsx
+
+
+ {() => (
+
+ Content for Item one.
+
+ )}
+
+
+ {() => (
+
+ Content for Item two.
+
+ )}
+
+
+```
+
+You might not always need this, but it is highly recommended to always
+use this method. Notice the `key` prop on tabs - it uniquely identifies
+the tab and is used for determining which tab is currently active. It can
+be either explicitly provided as a `key` prop, or if omitted, it will be
+implicitly derived from the tab's `label` prop.
+
+Props:
+
+- `vertical: boolean` - Use a vertical configuration, where tabs will appear
+stacked on the left side of the container.
+- `children: Tab[]` - This component only accepts tabs as its children.
+
+### `Tabs.Tab`
+
+An individual tab element. Tabs function like buttons, so they inherit
+a lot of `Button` props.
+
+Props:
+
+- See inherited props: [Button](#button)
+- `key: string` - A unique identifier for the tab.
+- `label: string` - Tab label.
+- `icon: string` - Tab icon.
+- `content/children: any` - Content to render inside the tab.
+- `onClick: function` - Called when element is clicked.
+
+### `Tooltip`
+
+A boxy tooltip from tgui 1. It is very hacky in its current state, and
+requires setting `position: relative` on the container.
+
+Please note, that [Button](#button) component has a `tooltip` prop, and
+it is recommended to use that prop instead.
+
+Usage:
+
+```jsx
+
+ Sample text.
+
+
+```
+
+Props:
+
+- `position: string` - Tooltip position.
+- `content/children: string` - Content of the tooltip. Must be a plain string.
+Fragments or other elements are **not** supported.
diff --git a/tgui-next/bin/tgui b/tgui-next/bin/tgui
new file mode 100755
index 00000000000..89be2f4a46c
--- /dev/null
+++ b/tgui-next/bin/tgui
@@ -0,0 +1,51 @@
+#!/bin/bash
+## Script for building tgui. Requires MSYS2 to run.
+set -e
+cd "$(dirname "${0}")/.."
+base_dir="$(pwd)"
+
+## Add locally installed node programs to path
+PATH="${PATH}:node_modules/.bin"
+
+run-webpack() {
+ cd "${base_dir}/packages/tgui"
+ exec webpack "${@}"
+}
+
+## Mr. Proper
+if [[ ${1} == "--clean" ]]; then
+ shopt -s globstar
+ rm -rf **/node_modules
+ exit 0
+fi
+
+## Install dependencies
+if [[ ! -e node_modules ]]; then
+ npm install
+fi
+
+## Run a development server
+if [[ ${1} == "--dev" ]]; then
+ shift
+ cd "${base_dir}/packages/tgui-dev-server"
+ exec node --experimental-modules index.js "${@}"
+fi
+
+## Run a linter through all packages
+if [[ ${1} == '--lint' ]]; then
+ shift
+ exec eslint ./packages "${@}"
+fi
+
+## Analyze the bundle
+if [[ ${1} == '--analyze' ]]; then
+ run-webpack --mode=production --analyze
+fi
+
+## Make a production webpack build
+if [[ -z ${1} ]]; then
+ run-webpack --mode=production
+fi
+
+## Run webpack with custom flags
+run-webpack "${@}"
diff --git a/tgui-next/bin/tgui-build.bat b/tgui-next/bin/tgui-build.bat
new file mode 100644
index 00000000000..64d6a4798fd
--- /dev/null
+++ b/tgui-next/bin/tgui-build.bat
@@ -0,0 +1,4 @@
+@echo off
+cd "%~dp0\.."
+call npm ci
+call npm run build
diff --git a/tgui-next/docs/tutorial-and-examples.md b/tgui-next/docs/tutorial-and-examples.md
new file mode 100644
index 00000000000..2bf4e88459c
--- /dev/null
+++ b/tgui-next/docs/tutorial-and-examples.md
@@ -0,0 +1,236 @@
+# TGUI Backend Documentation
+
+## Main concepts
+
+Basic tgui backend code consists of defining a few procs. In these procs
+you will handle a request to open or update a UI (typically by updating a UI
+if it exists or setting up and opening it if it does not), a request for data,
+in which you build a list to be passed as JSON to the UI, and an action
+handler, which handles any user input.
+
+- The atom, which UI corresponds to in the game world, is in most cases
+known as the `src_object`.
+- Frontend data is built in `ui_data` proc, which munges whatever complex
+data your `src_object` has into a list.
+- The action/topic handler, `ui_act`, is what recieves input from the user
+and acts on it.
+- The request/update proc, `ui_interact` is where you open your UI and set
+options like title, size, autoupdate, theme, and more.
+- Finally, `ui_state` (set in `ui_interact`) dictates under what conditions
+a UI may be interacted with. This may be the standard checks that check if
+you are in range and conscious, or more.
+
+Once backend is complete, you create an new interface component on the
+frontend, which will receive this JSON data and render it on screen.
+
+States are easy to write and extend, and what make tgui interactions so
+powerful. Because states can be overridden from other procs, you can build
+powerful interactions for embedded objects or remote access.
+
+## Using It
+
+### Backend
+
+Let's start with a very basic hello world.
+
+```dm
+/obj/machinery/my_machine/ui_interact(mob/user, ui_key = "main", datum/tgui/ui = null, force_open = 0, datum/tgui/master_ui = null, datum/ui_state/state = default_state)
+ ui = SStgui.try_update_ui(user, src, ui_key, ui, force_open)
+ if(!ui)
+ ui = new(user, src, ui_key, "my_machine", name, 300, 300, master_ui, state)
+ ui.open()
+```
+
+This is the proc that defines our interface. There's a bit going on here, so
+let's break it down. First, we override the ui_interact proc on our object. This
+will be called by `interact` for you, which is in turn called by `attack_hand`
+(or `attack_self` for items). `ui_interact` is also called to update a UI (hence
+the `try_update_ui`), so we accept an existing UI to update. The `state` is a
+default argument so that a caller can overload it with named arguments
+(`ui_interact(state = overloaded_state)`) if needed.
+
+Inside the `if(!ui)` block (which means we are creating a new UI), we choose our
+template, title, and size; we can also set various options like `style` (for
+themes), or autoupdate. These options will be elaborated on later (as will
+`ui_state`s).
+
+After `ui_interact`, we need to define `ui_data`. This just returns a list of
+data for our object to use. Let's imagine our object has a few vars:
+
+```dm
+/obj/machinery/my_machine/ui_data(mob/user)
+ var/list/data = list()
+ data["health"] = health
+ data["color"] = color
+
+ return data
+```
+
+The `ui_data` proc is what people often find the hardest about tgui, but its
+really quite simple! You just need to represent your object as numbers, strings,
+and lists, instead of atoms and datums.
+
+Finally, the `ui_act` proc is called by the interface whenever the user used an
+input. The input's `action` and `params` are passed to the proc.
+
+```dm
+/obj/machinery/my_machine/ui_act(action, params)
+ if(..())
+ return
+ switch(action)
+ if("change_color")
+ var/new_color = params["color"]
+ if(!(color in allowed_coors))
+ return
+ color = new_color
+ . = TRUE
+ update_icon()
+```
+
+The `..()` (parent call) is very important here, as it is how we check that the
+user is allowed to use this interface (to avoid so-called href exploits). It is
+also very important to clamp and sanitize all input here. Always assume the user
+is attempting to exploit the game.
+
+Also note the use of `. = TRUE` (or `FALSE`), which is used to notify the UI
+that this input caused an update. This is especially important for UIs that do
+not auto-update, as otherwise the user will never see their change.
+
+### Frontend
+
+Finally, you have to make a UI component. This is also a source of
+confusion for many new users. If you got some basic javascript and HTML
+knowledge, that should ease the learning process, although we recommend
+getting yourself introduced to
+[React and JSX](https://reactjs.org/docs/introducing-jsx.html).
+
+A component is not a regular HTML. A component is a pure function, which
+accepts a `props` object (it contains properties passed to a component),
+and outputs an HTML-like structure consisting of regular HTML elements and
+other UI components.
+
+Interface component will always receive 1 prop which is called `state`.
+This object contains a few special values:
+
+- `config` is always the same and is part of core tgui
+(it will be explained later),
+- `data` is the data returned from `ui_data`
+- `adata` is the same, but with certain values (numbers at this time)
+interpolated in order to allow animation.
+
+```jsx
+import { Section, LabeledList } from '../components';
+
+const SampleInterface = props => {
+ // Extract state from props
+ const { state } = props;
+ // Extract config and data from the state
+ const { config, data } = state;
+ // Extract window reference (will be used later for dispatching actions)
+ const { ref } = config;
+ // Return the Virtual DOM
+ return (
+
+
+
+ {data.health}
+
+
+ {data.color}
+
+
+
+ );
+};
+```
+
+This syntax can be very confusing at first, but it is very important to
+realize that this is just a natural extension of javascript. This syntax
+simply creates a Virtual DOM object, which you can treat as any other
+object in javascript. Here are some control flow examples:
+
+Returning different elements based on a condition:
+
+```jsx
+if (condition) {
+ return ;
+}
+return ;
+```
+
+Conditionally rendering a element inside of another element:
+
+```jsx
+
+ {showProgress && (
+
+ )}
+
+```
+
+Looping over the array to make element for each item:
+
+```jsx
+
+ {items.map(item => (
+
+ {item.content}
+
+ ))}
+
+```
+
+## Copypasta
+
+We all do it, even the best of us. If you just want to make a tgui **fast**,
+here's what you need (note that you'll probably be forced to clean your shit up
+upon code review):
+
+```dm
+/obj/copypasta/ui_interact(mob/user, ui_key = "main", datum/tgui/ui = null, force_open = 0, datum/tgui/master_ui = null, datum/ui_state/state = default_state) // Remember to use the appropriate state.
+ ui = SStgui.try_update_ui(user, src, ui_key, ui, force_open)
+ if(!ui)
+ ui = new(user, src, ui_key, "copypasta", name, 300, 300, master_ui, state)
+ ui.open()
+
+/obj/copypasta/ui_data(mob/user)
+ var/list/data = list()
+ data["var"] = var
+
+ return data
+
+/obj/copypasta/ui_act(action, params)
+ if(..())
+ return
+ switch(action)
+ if("copypasta")
+ var/newvar = params["var"]
+ var = Clamp(newvar, min_val, max_val) // Just a demo of proper input sanitation.
+ . = TRUE
+ update_icon() // Not applicable to all objects.
+```
+
+And the template:
+
+```jsx
+import { Section, LabeledList } from '../components';
+
+const SampleInterface = props => {
+ // Extract state from props
+ const { state } = props;
+ // Extract config and data from the state
+ const { config, data } = state;
+ // Extract window reference (will be used later for dispatching actions)
+ const { ref } = config;
+ // Return the Virtual DOM
+ return (
+
+
+
+ {data.var}
+
+
+
+ );
+};
+```
diff --git a/tgui-next/lerna.json b/tgui-next/lerna.json
new file mode 100644
index 00000000000..89763cc18c4
--- /dev/null
+++ b/tgui-next/lerna.json
@@ -0,0 +1,6 @@
+{
+ "packages": [
+ "packages/*"
+ ],
+ "version": "0.1.0"
+}
diff --git a/tgui-next/package.json b/tgui-next/package.json
new file mode 100644
index 00000000000..93bcdccb1cf
--- /dev/null
+++ b/tgui-next/package.json
@@ -0,0 +1,21 @@
+{
+ "private": true,
+ "name": "tgui-next",
+ "version": "0.1.0",
+ "workspaces": [
+ "packages/*"
+ ],
+ "scripts": {
+ "watch": "cd packages/tgui-dev-server && node --experimental-modules index.js",
+ "build": "cd packages/tgui && npx webpack --mode=production",
+ "analyze": "cd packages/tgui && npx webpack --mode=production --env.analyze=1",
+ "lint": "eslint packages",
+ "postinstall": "lerna bootstrap --hoist --no-ci && npx symlink-dir packages/common node_modules/common && npx symlink-dir packages/tgui node_modules/tgui && npx symlink-dir packages/tgui-dev-server node_modules/tgui-dev-server"
+ },
+ "dependencies": {
+ "babel-eslint": "^10.0.3",
+ "eslint": "^6.4.0",
+ "lerna": "^3.16.4",
+ "symlink-dir": "^3.1.1"
+ }
+}
diff --git a/tgui-next/packages/common/fp.js b/tgui-next/packages/common/fp.js
new file mode 100644
index 00000000000..1c5b0a853a3
--- /dev/null
+++ b/tgui-next/packages/common/fp.js
@@ -0,0 +1,75 @@
+/**
+ * @file
+ * @copyright 2018 Aleksej Komarov
+ * @license GPL-2.0-or-later
+ */
+
+/**
+ * Creates a function that returns the result of invoking the given
+ * functions, where each successive invocation is supplied the return
+ * value of the previous.
+ */
+export const flow = (...funcs) => (input, ...rest) => {
+ let output = input;
+ for (let func of funcs) {
+ // Recurse into the array of functions
+ if (Array.isArray(func)) {
+ output = flow(...func)(output, ...rest);
+ }
+ else if (func) {
+ output = func(output, ...rest);
+ }
+ }
+ return output;
+};
+
+/**
+ * Composes single-argument functions from right to left.
+ *
+ * All functions might accept a context in form of additional arguments.
+ * If the resulting function is called with more than 1 argument, rest of
+ * the arguments are passed to all functions unchanged.
+ *
+ * @param {...Function} funcs The functions to compose
+ * @returns {Function} A function obtained by composing the argument functions
+ * from right to left. For example, compose(f, g, h) is identical to doing
+ * (input, ...rest) => f(g(h(input, ...rest), ...rest), ...rest)
+ */
+export const compose = (...funcs) => {
+ if (funcs.length === 0) {
+ return arg => arg;
+ }
+ if (funcs.length === 1) {
+ return funcs[0];
+ }
+ return funcs.reduce((a, b) => (value, ...rest) =>
+ a(b(value, ...rest), ...rest));
+};
+
+/**
+ * Creates an array of values by running each element in collection
+ * thru an iteratee function. The iteratee is invoked with three
+ * arguments: (value, index|key, collection).
+ *
+ * If collection is 'null' or 'undefined', it will be returned "as is"
+ * without emitting any errors (which can be useful in some cases).
+ */
+export const map = iteratorFn => collection => {
+ if (collection === null && collection === undefined) {
+ return collection;
+ }
+ if (Array.isArray(collection)) {
+ return collection.map(iteratorFn);
+ }
+ if (typeof collection === 'object') {
+ const hasOwnProperty = Object.prototype.hasOwnProperty;
+ const result = [];
+ for (let i in collection) {
+ if (hasOwnProperty.call(collection, i)) {
+ result.push(iteratorFn(collection[i], i, collection));
+ }
+ }
+ return result;
+ }
+ throw new Error(`map() can't iterate on type ${typeof collection}`);
+};
diff --git a/tgui-next/packages/common/logging.js b/tgui-next/packages/common/logging.js
new file mode 100644
index 00000000000..6b5c54883b2
--- /dev/null
+++ b/tgui-next/packages/common/logging.js
@@ -0,0 +1,71 @@
+/**
+ * Copyright (c) 2019 Aleksej Komarov
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+
+const inception = Date.now();
+
+// Runtime detection
+const isNode = process && process.release && process.release.name === 'node';
+let isChrome = false;
+try {
+ isChrome = window.navigator.userAgent.toLowerCase().includes('chrome');
+}
+catch {}
+
+// Timestamping function
+const getTimestamp = () => {
+ const timestamp = String(Date.now() - inception)
+ .padStart(4, '0')
+ .padStart(7, ' ');
+ const seconds = timestamp.substr(0, timestamp.length - 3);
+ const millis = timestamp.substr(-3);
+ return `${seconds}.${millis}`;
+};
+
+const getPrefix = (() => {
+ if (isNode) {
+ // Escape sequences
+ const ESC = {
+ dimmed: '\x1b[38;5;240m',
+ bright: '\x1b[37;1m',
+ reset: '\x1b[0m',
+ };
+ return ns => [
+ `${ESC.dimmed}${getTimestamp()} ${ESC.bright}${ns}${ESC.reset}`,
+ ];
+ }
+ if (isChrome) {
+ // Styles
+ const styles = {
+ dimmed: 'color: #888',
+ bright: 'font-weight: bold',
+ };
+ return ns => [
+ `%c${getTimestamp()}%c ${ns}`,
+ styles.dimmed,
+ styles.bright,
+ ];
+ }
+ return ns => [
+ `${getTimestamp()} ${ns}`,
+ ];
+})();
+
+/**
+ * Creates a logger object.
+ */
+export const createLogger = ns => ({
+ log: (...args) => console.log(...getPrefix(ns), ...args),
+ trace: (...args) => console.trace(...getPrefix(ns), ...args),
+ debug: (...args) => console.debug(...getPrefix(ns), ...args),
+ info: (...args) => console.info(...getPrefix(ns), ...args),
+ warn: (...args) => console.warn(...getPrefix(ns), ...args),
+ error: (...args) => console.error(...getPrefix(ns), ...args),
+});
+
+/**
+ * Explicitly log with chosen namespace.
+ */
+export const directLog = (ns, ...args) =>
+ console.log(...getPrefix(ns), ...args);
diff --git a/tgui-next/packages/common/math.js b/tgui-next/packages/common/math.js
new file mode 100644
index 00000000000..a33b9aa214c
--- /dev/null
+++ b/tgui-next/packages/common/math.js
@@ -0,0 +1,19 @@
+/**
+ * Limits a number to the range between 'min' and 'max'.
+ */
+export const clamp = (value, min = 0, max = 1) => {
+ return Math.max(min, Math.min(value, max));
+};
+
+/**
+ * Returns a rounded number.
+ * TODO: Replace this native rounding function with a more robust one.
+ */
+export const round = value => Math.round(value);
+
+/**
+ * Returns a string representing a number in fixed point notation.
+ */
+export const toFixed = (value, fractionDigits = 0) => {
+ return Number(value).toFixed(fractionDigits);
+};
diff --git a/tgui-next/packages/common/package.json b/tgui-next/packages/common/package.json
new file mode 100644
index 00000000000..56dd3f7fd44
--- /dev/null
+++ b/tgui-next/packages/common/package.json
@@ -0,0 +1,6 @@
+{
+ "private": true,
+ "name": "common",
+ "version": "0.1.0",
+ "type": "module"
+}
diff --git a/tgui-next/packages/common/react.js b/tgui-next/packages/common/react.js
new file mode 100644
index 00000000000..c6e0af4b160
--- /dev/null
+++ b/tgui-next/packages/common/react.js
@@ -0,0 +1,77 @@
+/**
+ * Helper for conditionally adding/removing classes in React
+ *
+ * @copyright 2018 Aleksej Komarov
+ * @license GPL-2.0-or-later
+ *
+ * @return {string}
+ */
+export const classes = (...args) => {
+ const classNames = [];
+ const hasOwn = Object.prototype.hasOwnProperty;
+ for (let i = 0; i < args.length; i++) {
+ const arg = args[i];
+ if (!arg) {
+ continue;
+ }
+ if (typeof arg === 'string' || typeof arg === 'number') {
+ classNames.push(arg);
+ }
+ else if (Array.isArray(arg) && arg.length) {
+ const inner = classes.apply(null, arg);
+ if (inner) {
+ classNames.push(inner);
+ }
+ }
+ else if (typeof arg === 'object') {
+ for (let key in arg) {
+ if (hasOwn.call(arg, key) && arg[key]) {
+ classNames.push(key);
+ }
+ }
+ }
+ }
+ return classNames.join(' ');
+};
+
+/**
+ * Normalizes children prop, so that it is always an array of VDom
+ * elements.
+ */
+export const normalizeChildren = children => {
+ if (Array.isArray(children)) {
+ return children.filter(value => value);
+ }
+ if (typeof children === 'object') {
+ return [children];
+ }
+ return [];
+};
+
+/**
+ * Shallowly checks if two objects are different.
+ * Credit: https://github.com/developit/preact-compat
+ */
+export const shallowDiffers = (a, b) => {
+ let i;
+ for (i in a) {
+ if (!(i in b)) {
+ return true;
+ }
+ }
+ for (i in b) {
+ if (a[i] !== b[i]) {
+ return true;
+ }
+ }
+ return false;
+};
+
+/**
+ * Default inferno hooks for pure components.
+ */
+export const pureComponentHooks = {
+ onComponentShouldUpdate: (lastProps, nextProps) => {
+ return shallowDiffers(lastProps, nextProps);
+ },
+};
diff --git a/tgui-next/packages/common/redux.js b/tgui-next/packages/common/redux.js
new file mode 100644
index 00000000000..257a9eebf58
--- /dev/null
+++ b/tgui-next/packages/common/redux.js
@@ -0,0 +1,65 @@
+import { compose } from './fp';
+
+/**
+ * Creates a Redux store.
+ */
+export const createStore = (reducer, enhancer) => {
+ // Apply a store enhancer (applyMiddleware is one of them).
+ if (enhancer) {
+ return enhancer(createStore)(reducer);
+ }
+
+ let currentState;
+ let listeners = [];
+
+ const getState = () => currentState;
+
+ const subscribe = listener => {
+ listeners.push(listener);
+ };
+
+ const dispatch = action => {
+ currentState = reducer(currentState, action);
+ listeners.forEach(fn => fn());
+ };
+
+ // This creates the initial store by causing each reducer to be called
+ // with an undefined state
+ dispatch({
+ type: '@@INIT',
+ });
+
+ return {
+ dispatch,
+ subscribe,
+ getState,
+ };
+};
+
+/**
+ * Creates a store enhancer which applies middleware to all dispatched
+ * actions.
+ */
+export const applyMiddleware = (...middlewares) => {
+ return createStore => (reducer, ...args) => {
+ const store = createStore(reducer, ...args);
+
+ let dispatch = () => {
+ throw new Error(
+ 'Dispatching while constructing your middleware is not allowed.');
+ };
+
+ const storeApi = {
+ getState: store.getState,
+ dispatch: (action, ...args) => dispatch(action, ...args),
+ };
+
+ const chain = middlewares.map(middleware => middleware(storeApi));
+ dispatch = compose(...chain)(store.dispatch);
+
+ return {
+ ...store,
+ dispatch,
+ };
+ };
+};
diff --git a/tgui-next/packages/common/string.js b/tgui-next/packages/common/string.js
new file mode 100644
index 00000000000..09d0a31ed30
--- /dev/null
+++ b/tgui-next/packages/common/string.js
@@ -0,0 +1,150 @@
+/**
+ * @file
+ * @copyright 2018 Aleksej Komarov
+ * @license GPL-2.0-or-later
+ */
+
+/**
+ * Removes excess whitespace and indentation from the string
+ * @param {string} str
+ * @return {string}
+ */
+export const compact = str => {
+ return str
+ .trim()
+ .split('\n')
+ .map(x => x.trim())
+ .filter(x => x.length > 0)
+ .join('\n');
+};
+
+/**
+ * Template literal tag for rendering HTML
+ */
+export const html = (strings, ...expressions) => {
+ const length = strings.length;
+ let output = '';
+ for (let i = 0; i < length; i++) {
+ output += strings[i];
+ let expr = expressions[i];
+ if (typeof expr === 'boolean' || expr === undefined || expr === null) {
+ // Nothing
+ }
+ else if (Array.isArray(expr)) {
+ output += expr.join('\n');
+ }
+ else {
+ output += expr;
+ }
+ }
+ return output;
+};
+
+/**
+ * Matches strings with wildcards.
+ * Example: testGlobPattern('*@domain')('user@domain') === true
+ */
+export const testGlobPattern = pattern => {
+ const escapeString = str => str.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&');
+ const regex = new RegExp('^'
+ + pattern.split(/\*+/).map(escapeString).join('.*')
+ + '$');
+ return str => regex.test(str);
+};
+
+export const capitalize = str => {
+ // Handle array
+ if (Array.isArray(str)) {
+ return str.map(capitalize);
+ }
+ // Handle string
+ return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
+};
+
+export const toLowerCase = str => {
+ if (typeof str !== 'string') {
+ return str;
+ }
+ return str.toLowerCase();
+};
+
+export const toUpperCase = str => {
+ if (typeof str !== 'string') {
+ return str;
+ }
+ return str.toUpperCase();
+};
+
+export const toTitleCase = str => {
+ // Handle array
+ if (Array.isArray(str)) {
+ return str.map(toTitleCase);
+ }
+ // Pass non-string
+ if (typeof str !== 'string') {
+ return str;
+ }
+ // Handle string
+ const WORDS_UPPER = ['Id', 'Tv'];
+ const WORDS_LOWER = [
+ 'A', 'An', 'And', 'As', 'At', 'But', 'By', 'For', 'For', 'From', 'In', 'Into',
+ 'Near', 'Nor', 'Of', 'On', 'Onto', 'Or', 'The', 'To', 'With',
+ ];
+ let currentStr = str.replace(/([^\W_]+[^\s-]*) */g, str => {
+ return str.charAt(0).toUpperCase() + str.substr(1).toLowerCase();
+ });
+ for (let word of WORDS_LOWER) {
+ const regex = new RegExp('\\s' + word + '\\s', 'g');
+ currentStr = currentStr.replace(regex, str => str.toLowerCase());
+ }
+ for (let word of WORDS_UPPER) {
+ const regex = new RegExp('\\b' + word + '\\b', 'g');
+ currentStr = currentStr.replace(regex, str => str.toLowerCase());
+ }
+ return currentStr;
+};
+
+/**
+ * Decodes HTML entities, and removes unnecessary HTML tags.
+ *
+ * @param {String} str Encoded HTML string
+ * @return {String} Decoded HTML string
+ */
+export const decodeHtmlEntities = str => {
+ if (!str) {
+ return str;
+ }
+ const translate_re = /&(nbsp|amp|quot|lt|gt|apos);/g;
+ const translate = {
+ nbsp: ' ',
+ amp: '&',
+ quot: '"',
+ lt: '<',
+ gt: '>',
+ apos: '\'',
+ };
+ return str
+ // Newline tags
+ .replace(/ /gi, '\n')
+ .replace(/<\/?[a-z0-9-_]+[^>]*>/gi, '')
+ // Basic entities
+ .replace(translate_re, (match, entity) => translate[entity])
+ // Decimal entities
+ .replace(/?([0-9]+);/gi, (match, numStr) => {
+ const num = parseInt(numStr, 10);
+ return String.fromCharCode(num);
+ })
+ // Hex entities
+ .replace(/?([0-9a-f]+);/gi, (match, numStr) => {
+ const num = parseInt(numStr, 16);
+ return String.fromCharCode(num);
+ });
+};
+
+/**
+ * Converts an object into a query string,
+ */
+export const buildQueryString = obj => Object.keys(obj)
+ .map(key => encodeURIComponent(key)
+ + '=' + encodeURIComponent(obj[key]))
+ .join('&');
diff --git a/tgui-next/packages/tgui-dev-server/index.js b/tgui-next/packages/tgui-dev-server/index.js
new file mode 100644
index 00000000000..6af06c5ad46
--- /dev/null
+++ b/tgui-next/packages/tgui-dev-server/index.js
@@ -0,0 +1,117 @@
+import { createLogger } from 'common/logging.js';
+import fs from 'fs';
+import glob from 'glob';
+import { createRequire } from 'module';
+import os from 'os';
+import path from 'path';
+import util from 'util';
+import webpack from 'webpack';
+import { broadcastMessage, setupLink } from './link/server.js';
+
+const WEBPACK_HMR_ENABLED = process.platform === 'win32'
+ || process.argv.includes('--hot');
+
+const setupServer = async () => {
+ const link = setupLink();
+ await setupWebpack(link);
+};
+
+const getWebpackConfig = async () => {
+ const logger = createLogger('webpack');
+ const require = createRequire(import.meta.url);
+ const createConfig = await require('../tgui/webpack.config.js');
+ const config = createConfig({}, {
+ mode: 'development',
+ // Enable hot module reloading only on Windows.
+ hot: WEBPACK_HMR_ENABLED,
+ });
+ if (!WEBPACK_HMR_ENABLED) {
+ logger.log('hot module reloading is disabled');
+ }
+ return config;
+};
+
+const setupWebpack = async link => {
+ const logger = createLogger('webpack');
+ logger.log('setting up');
+ const config = await getWebpackConfig();
+ const bundleDir = config.output.path;
+ // Instantiate the compiler
+ const compiler = webpack(config);
+ // Clear garbage before compiling
+ compiler.hooks.watchRun.tapPromise('tgui-dev-server', async () => {
+ const files = await resolvePath(bundleDir, './*.hot-update.*');
+ logger.log(`clearing garbage (${files.length} files)`);
+ for (let file of files) {
+ await util.promisify(fs.unlink)(file);
+ }
+ logger.log('compiling');
+ });
+ // Start reloading when it's finished
+ compiler.hooks.done.tap('tgui-dev-server', async stats => {
+ await reloadByondCache(bundleDir);
+ // Notify all clients that update has happened
+ if (WEBPACK_HMR_ENABLED) {
+ broadcastMessage(link, {
+ type: 'hotUpdate',
+ });
+ }
+ });
+ // Start watching
+ logger.log('watching for changes');
+ compiler.watch({}, (err, stats) => {
+ if (err) {
+ logger.error('compilation error', err);
+ return;
+ }
+ logger.log(stats.toString(config.devServer.stats));
+ });
+};
+
+/**
+ * Combines path.resolve with glob patterns.
+ */
+const resolvePath = (...sections) => {
+ return util.promisify(glob)(path.resolve(...sections));
+};
+
+const CACHE_PATTERN = './BYOND/cache/tmp*';
+
+const reloadByondCache = async bundleDir => {
+ const logger = createLogger('reloader');
+ // Find BYOND cache folders
+ const cacheDirs = [
+ // Windows 10
+ ...(await resolvePath(os.homedir(), '*', CACHE_PATTERN)),
+ // Standard Wine setup
+ ...(await resolvePath(os.homedir(),
+ '.wine/drive_c/users/*/*',
+ CACHE_PATTERN)),
+ // Standard Lutris setup
+ ...(await resolvePath(os.homedir(),
+ 'Games/byond/drive_c/users/*/*',
+ CACHE_PATTERN)),
+ ];
+ if (cacheDirs.length === 0) {
+ logger.log('found no cache directories');
+ return;
+ }
+ // Clear garbage
+ for (let cacheDir of cacheDirs) {
+ const garbage = await resolvePath(cacheDir, './*.+(bundle|hot-update).*');
+ for (let file of garbage) {
+ await util.promisify(fs.unlink)(file);
+ }
+ }
+ // Copy assets
+ const assets = await resolvePath(bundleDir, './*.+(bundle|hot-update).*');
+ for (let cacheDir of cacheDirs) {
+ for (let asset of assets) {
+ const destination = path.resolve(cacheDir, path.basename(asset));
+ await util.promisify(fs.copyFile)(asset, destination);
+ }
+ logger.log(`copied ${assets.length} files to '${cacheDir}'`);
+ }
+};
+
+setupServer();
diff --git a/tgui-next/packages/tgui-dev-server/link/client.js b/tgui-next/packages/tgui-dev-server/link/client.js
new file mode 100644
index 00000000000..dfaebba4181
--- /dev/null
+++ b/tgui-next/packages/tgui-dev-server/link/client.js
@@ -0,0 +1,108 @@
+let socket;
+const queue = [];
+const subscribers = [];
+
+const ensureConnection = () => {
+ if (process.env.NODE_ENV !== 'production') {
+ if (!window.WebSocket) {
+ return;
+ }
+ if (!socket || socket.readyState === WebSocket.CLOSED) {
+ socket = new WebSocket('ws://127.0.0.1:3000');
+ socket.onopen = () => {
+ // Empty the message queue
+ while (queue.length !== 0) {
+ const msg = queue.pop();
+ socket.send(msg);
+ }
+ };
+ socket.onmessage = event => {
+ const msg = JSON.parse(event.data);
+ for (let subscriber of subscribers) {
+ subscriber(msg);
+ }
+ };
+ }
+ }
+};
+
+if (process.env.NODE_ENV !== 'production') {
+ window.onunload = () => socket && socket.close();
+}
+
+const subscribe = fn => subscribers.push(fn);
+
+const sendRawMessage = msg => {
+ if (process.env.NODE_ENV !== 'production') {
+ const json = JSON.stringify(msg);
+ // Send message using WebSocket
+ if (window.WebSocket) {
+ ensureConnection();
+ if (socket.readyState === WebSocket.OPEN) {
+ socket.send(json);
+ }
+ else {
+ // Keep only 10 latest messages in the queue
+ if (queue.length > 10) {
+ queue.shift();
+ }
+ queue.push(json);
+ }
+ }
+ // Send message using plain HTTP request.
+ else {
+ const req = new XMLHttpRequest();
+ req.open('POST', 'http://127.0.0.1:3001', true);
+ req.send(json);
+ }
+ }
+};
+
+export const sendLogEntry = (ns, ...args) => {
+ if (process.env.NODE_ENV !== 'production') {
+ try {
+ sendRawMessage({
+ type: 'log',
+ payload: {
+ ns: ns || 'client',
+ args,
+ },
+ });
+ }
+ catch (err) {}
+ }
+};
+
+export const setupHotReloading = () => {
+ if (process.env.NODE_ENV !== 'production'
+ && process.env.WEBPACK_HMR_ENABLED
+ && window.WebSocket) {
+ if (module.hot) {
+ ensureConnection();
+ sendLogEntry(null, 'setting up hot reloading');
+ subscribe(msg => {
+ const { type } = msg;
+ sendLogEntry(null, 'received', type);
+ if (type === 'hotUpdate') {
+ const status = module.hot.status();
+ if (status !== 'idle') {
+ sendLogEntry(null, 'hot reload status:', status);
+ return;
+ }
+ module.hot
+ .check({
+ ignoreUnaccepted: true,
+ ignoreDeclined: true,
+ ignoreErrored: true,
+ })
+ // .then(modules => {
+ // sendLogEntry(null, 'outdated modules', modules);
+ // })
+ .catch(err => {
+ sendLogEntry(null, 'reload error', err);
+ });
+ }
+ });
+ }
+ }
+};
diff --git a/tgui-next/packages/tgui-dev-server/link/server.js b/tgui-next/packages/tgui-dev-server/link/server.js
new file mode 100644
index 00000000000..fae417d1e07
--- /dev/null
+++ b/tgui-next/packages/tgui-dev-server/link/server.js
@@ -0,0 +1,84 @@
+import { createLogger, directLog } from 'common/logging.js';
+import http from 'http';
+import WebSocket from 'ws';
+
+const logger = createLogger('link');
+
+export const setupLink = () => {
+ logger.log('setting up');
+ const wss = setupWebSocketLink();
+ setupSimpleLink();
+ return {
+ wss,
+ };
+};
+
+export const broadcastMessage = (link, msg) => {
+ const { wss } = link;
+ const clients = [...wss.clients];
+ logger.log(`broadcasting ${msg.type} to ${clients.length} clients`);
+ for (let client of clients) {
+ const json = JSON.stringify(msg);
+ client.send(json);
+ }
+};
+
+const handleLinkMessage = msg => {
+ const { type, payload } = msg;
+
+ if (type === 'log') {
+ const { ns, args } = payload;
+ directLog(ns, ...args);
+ return;
+ }
+
+ logger.log('unhandled message', msg);
+};
+
+// WebSocket-based client link
+const setupWebSocketLink = () => {
+ const logger = createLogger('link');
+ const port = 3000;
+ const wss = new WebSocket.Server({ port });
+
+ wss.on('connection', ws => {
+ logger.log('client connected');
+
+ ws.on('message', json => {
+ const msg = JSON.parse(json);
+ handleLinkMessage(msg);
+ });
+
+ ws.on('close', () => {
+ logger.log('client disconnected');
+ });
+ });
+
+ logger.log(`listening on port ${port} (WebSocket)`);
+ return wss;
+};
+
+// One way HTTP-based client link for IE8
+const setupSimpleLink = () => {
+ const logger = createLogger('link');
+ const port = 3001;
+
+ const server = http.createServer((req, res) => {
+ if (req.method === 'POST') {
+ let body = '';
+ req.on('data', chunk => {
+ body += chunk.toString();
+ });
+ req.on('end', () => {
+ const msg = JSON.parse(body);
+ handleLinkMessage(msg);
+ res.end();
+ });
+ return;
+ }
+ res.end();
+ });
+
+ server.listen(port);
+ logger.log(`listening on port ${port} (HTTP)`);
+};
diff --git a/tgui-next/packages/tgui-dev-server/package.json b/tgui-next/packages/tgui-dev-server/package.json
new file mode 100644
index 00000000000..645adadb719
--- /dev/null
+++ b/tgui-next/packages/tgui-dev-server/package.json
@@ -0,0 +1,10 @@
+{
+ "private": true,
+ "name": "tgui-dev-server",
+ "version": "0.1.0",
+ "type": "module",
+ "dependencies": {
+ "glob": "^7.1.4",
+ "ws": "^7.1.2"
+ }
+}
diff --git a/tgui-next/packages/tgui/backend.js b/tgui-next/packages/tgui/backend.js
new file mode 100644
index 00000000000..3098774c581
--- /dev/null
+++ b/tgui-next/packages/tgui/backend.js
@@ -0,0 +1,45 @@
+import { UI_DISABLED, UI_INTERACTIVE } from './constants';
+import { tridentVersion } from './byond';
+
+/**
+ * This file provides a clear separation layer between backend updates
+ * and what state our React app sees.
+ *
+ * Sometimes backend can response without a "data" field, but our final
+ * state will still contain previous "data" because we are merging
+ * the response with already existing state.
+ */
+
+/**
+ * Creates a backend update action.
+ */
+export const backendUpdate = state => ({
+ type: 'backendUpdate',
+ payload: state,
+});
+
+/**
+ * Precisely defines state changes.
+ */
+export const backendReducer = (state, action) => {
+ const { type, payload } = action;
+
+ if (type === 'backendUpdate') {
+ // Calculate our own fields
+ const visible = payload.config.status !== UI_DISABLED;
+ const interactive = payload.config.status === UI_INTERACTIVE;
+ // Override fancy setting for IE8
+ if (tridentVersion <= 4) {
+ payload.config.fancy = 0;
+ }
+ // Merge new payload
+ return {
+ ...state,
+ ...payload,
+ visible,
+ interactive,
+ };
+ }
+
+ return state;
+};
diff --git a/tgui-next/packages/tgui/byond.js b/tgui-next/packages/tgui/byond.js
new file mode 100644
index 00000000000..2b7d3ff7720
--- /dev/null
+++ b/tgui-next/packages/tgui/byond.js
@@ -0,0 +1,84 @@
+import { buildQueryString } from 'common/string';
+
+/**
+ * Version of Trident engine used in Internet Explorer.
+ *
+ * - IE 8 - Trident 4.0
+ * - IE 11 - Trident 7.0
+ *
+ * @return An integer number or 'null' if this is not a trident engine.
+ */
+export const tridentVersion = (() => {
+ const { userAgent } = navigator;
+ const groups = userAgent.match(/Trident\/(\d+).+?;/i);
+ const majorVersion = groups[1];
+ if (!majorVersion) {
+ return null;
+ }
+ return parseInt(majorVersion, 10);
+})();
+
+/**
+ * Helper to generate a BYOND href given 'params' as an object
+ * (with an optional 'url' for eg winset).
+ */
+const href = (url, params = {}) => {
+ return 'byond://' + url + '?' + buildQueryString(params);
+};
+
+export const callByond = (url, params = {}) => {
+ window.location.href = href(url, params);
+};
+
+/**
+ * A high-level abstraction of BYJAX. Makes a call to BYOND and returns
+ * a promise, which (if endpoint has a callback parameter) resolves
+ * with the return value of that call.
+ */
+export const callByondAsync = (url, params = {}) => {
+ // Create a callback array if it doesn't exist yet
+ window.__callbacks__ = window.__callbacks__ || [];
+ // Create a Promise and push its resolve function into callback array
+ const callbackIndex = window.__callbacks__.length;
+ const promise = new Promise(resolve => {
+ // TODO: Fix a potential memory leak
+ window.__callbacks__.push(resolve);
+ });
+ // Call BYOND client
+ window.location.href = href(url, {
+ ...params,
+ callback: `__callbacks__[${callbackIndex}]`,
+ });
+ return promise;
+};
+
+/**
+ * Literally types a command on the client.
+ */
+export const runCommand = command => callByond('winset', { command });
+
+/**
+ * Helper to make a BYOND ui_act() call on the UI 'src' given an 'action'
+ * and optional 'params'.
+ */
+export const act = (src, action, params = {}) => {
+ return callByond('', { src, action, ...params });
+};
+
+/**
+ * Calls 'winget' on window, retrieving value by the 'key'.
+ */
+export const winget = async (win, key) => {
+ const obj = await callByondAsync('winget', {
+ id: win,
+ property: key,
+ });
+ return obj[key];
+};
+
+/**
+ * Calls 'winset' on window, setting 'key' to 'value'.
+ */
+export const winset = (win, key, value) => callByond('winset', {
+ [`${win}.${key}`]: value,
+});
diff --git a/tgui-next/packages/tgui/components/AnimatedNumber.js b/tgui-next/packages/tgui/components/AnimatedNumber.js
new file mode 100644
index 00000000000..884deb38c96
--- /dev/null
+++ b/tgui-next/packages/tgui/components/AnimatedNumber.js
@@ -0,0 +1,69 @@
+import { clamp, toFixed } from 'common/math';
+import { Component } from 'inferno';
+
+const FPS = 20;
+const Q = 0.5;
+
+const isSafeNumber = value => {
+ return typeof value === 'number'
+ && Number.isFinite(value)
+ && !Number.isNaN(value);
+};
+
+export class AnimatedNumber extends Component {
+ constructor(props) {
+ super(props);
+ this.timer = null;
+ this.state = {
+ value: 0,
+ };
+ // Use provided initial state
+ if (isSafeNumber(props.initial)) {
+ this.state.value = props.initial;
+ }
+ // Set initial state with value provided in props
+ else if (isSafeNumber(props.value)) {
+ this.state.value = Number(props.value);
+ }
+ }
+
+ tick() {
+ const { props, state } = this;
+ const currentValue = Number(state.value);
+ const targetValue = Number(props.value);
+ // Avoid poisoning our state with infinities and NaN
+ if (!isSafeNumber(targetValue)) {
+ return;
+ }
+ // Smooth the value using an exponential moving average
+ const value = currentValue * Q + targetValue * (1 - Q);
+ this.setState({ value });
+ }
+
+ componentDidMount() {
+ this.timer = setInterval(() => this.tick(), 1000 / FPS);
+ }
+
+ componentWillUnmount() {
+ clearTimeout(this.timer);
+ }
+
+ render() {
+ const { props, state } = this;
+ const { format } = props;
+ const currentValue = state.value;
+ const targetValue = props.value;
+ // Directly display values which can't be animated
+ if (!isSafeNumber(targetValue)) {
+ return targetValue || null;
+ }
+ // Use custom formatter
+ if (format) {
+ return format(currentValue);
+ }
+ // Fix our animated precision at target value's precision.
+ const fraction = String(targetValue).split('.')[1];
+ const precision = fraction ? fraction.length : 0;
+ return toFixed(currentValue, clamp(precision, 0, 8));
+ }
+}
diff --git a/tgui-next/packages/tgui/components/Box.js b/tgui-next/packages/tgui/components/Box.js
new file mode 100644
index 00000000000..1ee0fc73cf0
--- /dev/null
+++ b/tgui-next/packages/tgui/components/Box.js
@@ -0,0 +1,101 @@
+import { classes, pureComponentHooks } from 'common/react';
+import { createVNode } from 'inferno';
+import { ChildFlags, VNodeFlags } from 'inferno-vnode-flags';
+
+const REM_PX = 12;
+const REM_PER_INTEGER = 0.5;
+
+/**
+ * Coverts our rem-like spacing unit into a CSS unit.
+ */
+export const unit = value => {
+ if (typeof value === 'string') {
+ return value;
+ }
+ if (typeof value === 'number') {
+ return (value * REM_PX * REM_PER_INTEGER) + 'px';
+ }
+};
+
+/**
+ * Nullish coalesce function
+ */
+const firstDefined = (...args) => {
+ return args.find(arg => arg !== undefined && arg !== null);
+};
+
+export const computeBoxProps = props => {
+ const {
+ className,
+ color,
+ width,
+ minWidth,
+ maxWidth,
+ height,
+ minHeight,
+ maxHeight,
+ lineHeight,
+ inline,
+ m, mx, my, mt, mb, ml, mr,
+ opacity,
+ bold,
+ italic,
+ textAlign,
+ position,
+ top,
+ left,
+ right,
+ bottom,
+ ...rest
+ } = props;
+ return {
+ ...rest,
+ className: classes([
+ className,
+ color && 'color-' + color,
+ ]),
+ style: {
+ 'display': inline ? 'inline-block' : undefined,
+ 'margin-top': unit(firstDefined(mt, my, m)),
+ 'margin-bottom': unit(firstDefined(mb, my, m)),
+ 'margin-left': unit(firstDefined(ml, mx, m)),
+ 'margin-right': unit(firstDefined(mr, mx, m)),
+ 'opacity': opacity,
+ 'width': unit(width),
+ 'min-width': unit(minWidth),
+ 'max-width': unit(maxWidth),
+ 'height': unit(height),
+ 'min-height': unit(minHeight),
+ 'max-height': unit(maxHeight),
+ 'line-height': unit(lineHeight),
+ 'font-weight': bold ? 'bold' : undefined,
+ 'font-style': italic ? 'italic' : undefined,
+ 'text-align': textAlign,
+ 'position': position,
+ 'top': unit(top),
+ 'left': unit(left),
+ 'right': unit(right),
+ 'bottom': unit(bottom),
+ ...rest.style,
+ },
+ };
+};
+
+export const Box = props => {
+ const { as = 'div', content, children, ...rest } = props;
+ // Render props
+ if (typeof children === 'function') {
+ return children(computeBoxProps(props));
+ }
+ const { className, ...computedProps } = computeBoxProps(rest);
+ // Render a wrapper element
+ return createVNode(
+ VNodeFlags.HtmlElement,
+ as,
+ className,
+ content || children,
+ ChildFlags.UnknownChildren,
+ computedProps);
+};
+
+Box.defaultHooks = pureComponentHooks;
diff --git a/tgui-next/packages/tgui/components/Button.js b/tgui-next/packages/tgui/components/Button.js
new file mode 100644
index 00000000000..12ca4cd6a5d
--- /dev/null
+++ b/tgui-next/packages/tgui/components/Button.js
@@ -0,0 +1,72 @@
+import { classes, pureComponentHooks } from 'common/react';
+import { Box } from './Box';
+import { Icon } from './Icon';
+import { Tooltip } from './Tooltip';
+
+export const BUTTON_ACTIVATION_KEYCODES = [
+ 13, // Enter
+ 32, // Space
+];
+
+export const Button = props => {
+ const {
+ className,
+ fluid,
+ icon,
+ color,
+ disabled,
+ selected,
+ tooltip,
+ tooltipPosition,
+ content,
+ children,
+ onClick,
+ ...rest
+ } = props;
+ const hasContent = !!(content || children);
+ // NOTE: Lowercase "onclick" and unselectable is used for
+ // compatibility with IE8. Do not change it!
+ return (
+ {
+ if (disabled || !onClick) {
+ return;
+ }
+ onClick(e);
+ }}
+ onKeyPress={e => {
+ const keyCode = window.event ? e.which : e.keyCode;
+ if (BUTTON_ACTIVATION_KEYCODES.includes(keyCode)) {
+ e.preventDefault();
+ onClick(e);
+ }
+ }}
+ {...rest}>
+ {icon && (
+
+ )}
+ {content}
+ {children}
+ {tooltip && (
+
+ )}
+
+ );
+};
+
+Button.defaultHooks = pureComponentHooks;
diff --git a/tgui-next/packages/tgui/components/Button.scss b/tgui-next/packages/tgui/components/Button.scss
new file mode 100644
index 00000000000..62ce78e4d53
--- /dev/null
+++ b/tgui-next/packages/tgui/components/Button.scss
@@ -0,0 +1,95 @@
+@mixin button-color($color) {
+ transition: color, background-color 50ms;
+ background-color: $color;
+
+ // Adapt button color to background luminance to ensure high contast
+ @if luminance($color) > 0.4 {
+ color: rgba(0, 0, 0, 1);
+ }
+ @else {
+ color: rgba(255, 255, 255, 1);
+ }
+
+ &:hover {
+ transition: color, background-color 0ms;
+ }
+
+ &:focus {
+ transition: color, background-color 100ms;
+ }
+
+ &:hover,
+ &:focus {
+ background-color: lighten($color, $button-lighten-hover);
+ @if luminance($color) > 0.4 {
+ color: rgba(0, 0, 0, 1);
+ }
+ @else {
+ color: rgba(255, 255, 255, 1);
+ }
+ }
+}
+
+.Button {
+ position: relative;
+ display: inline-block;
+ line-height: 19px;
+ padding: 0 6px;
+ margin-right: 2px;
+ white-space: nowrap;
+ outline: 0;
+ margin-bottom: 2px;
+ // Disable selection in buttons
+ user-select: none;
+ -ms-user-select: none;
+
+ &:last-child {
+ margin-right: 0;
+ }
+}
+
+.Button--hasContent {
+ // Add a margin to the icon to keep it separate from the text
+ .fa, .fas, .far {
+ margin-right: 6px;
+ }
+}
+
+.Button--fluid {
+ display: block;
+ margin-left: 0;
+ margin-right: 0;
+ // padding: 3px 6px;
+}
+
+@each $color-name, $color-value in $color-map {
+ .Button--color--#{$color-name} {
+ @include button-color($color-value);
+ }
+}
+
+.Button--color--normal {
+ @include button-color($button-color-normal);
+}
+
+.Button--color--caution {
+ @include button-color($button-color-caution);
+}
+
+.Button--color--danger {
+ @include button-color($button-color-danger);
+}
+
+.Button--color--transparent {
+ @include button-color($dark-gray);
+ background-color: rgba($dark-gray, 0);
+ color: rgba(255, 255, 255, 0.5);
+}
+
+.Button--disabled {
+ background-color: $button-color-disabled !important;
+}
+
+.Button--selected {
+ @include button-color($button-color-selected);
+}
diff --git a/tgui-next/packages/tgui/components/Flex.js b/tgui-next/packages/tgui/components/Flex.js
new file mode 100644
index 00000000000..1146d993f69
--- /dev/null
+++ b/tgui-next/packages/tgui/components/Flex.js
@@ -0,0 +1,58 @@
+import { classes, pureComponentHooks } from 'common/react';
+import { Box } from './Box';
+
+export const computeFlexProps = props => {
+ const {
+ className,
+ direction,
+ wrap,
+ align,
+ justify,
+ ...rest
+ } = props;
+ return {
+ className: classes('Flex', className),
+ style: {
+ ...rest.style,
+ 'flex-direction': direction,
+ 'flex-wrap': wrap,
+ 'align-items': align,
+ 'justify-content': justify,
+ },
+ ...rest,
+ };
+};
+
+export const Flex = props => (
+
+);
+
+Flex.defaultHooks = pureComponentHooks;
+
+export const computeFlexItemProps = props => {
+ const {
+ className,
+ grow,
+ order,
+ align,
+ ...rest
+ } = props;
+ return {
+ className: classes('Flex__item', className),
+ style: {
+ ...rest.style,
+ 'flex-grow': grow,
+ 'order': order,
+ 'align-self': align,
+ },
+ ...rest,
+ };
+};
+
+export const FlexItem = props => (
+
+);
+
+FlexItem.defaultHooks = pureComponentHooks;
+
+Flex.Item = FlexItem;
diff --git a/tgui-next/packages/tgui/components/Flex.scss b/tgui-next/packages/tgui/components/Flex.scss
new file mode 100644
index 00000000000..79d4c8b4b6e
--- /dev/null
+++ b/tgui-next/packages/tgui/components/Flex.scss
@@ -0,0 +1,4 @@
+.Flex {
+ display: -ms-flexbox;
+ display: flex;
+}
diff --git a/tgui-next/packages/tgui/components/Icon.js b/tgui-next/packages/tgui/components/Icon.js
new file mode 100644
index 00000000000..6e7188bf188
--- /dev/null
+++ b/tgui-next/packages/tgui/components/Icon.js
@@ -0,0 +1,26 @@
+import { classes, pureComponentHooks } from 'common/react';
+import { Box } from './Box';
+
+const FA_OUTLINE_REGEX = /-o$/;
+
+export const Icon = props => {
+ const { name, size, className, style = {}, ...rest } = props;
+ if (size) {
+ style['font-size'] = (size * 100) + '%';
+ }
+ const faRegular = FA_OUTLINE_REGEX.test(name);
+ const faName = name.replace(FA_OUTLINE_REGEX, '');
+ return (
+
+ );
+};
+
+Icon.defaultHooks = pureComponentHooks;
diff --git a/tgui-next/packages/tgui/components/LabeledList.js b/tgui-next/packages/tgui/components/LabeledList.js
new file mode 100644
index 00000000000..d8db6bb9c16
--- /dev/null
+++ b/tgui-next/packages/tgui/components/LabeledList.js
@@ -0,0 +1,72 @@
+import { classes, pureComponentHooks } from 'common/react';
+import { unit } from './Box';
+
+export const LabeledList = props => {
+ const { children } = props;
+ return (
+