Add a basic, empty spline UI based on Angular

The page doesn't do anything yet, but should let people build up a
more complex UI.

This is effectively a super stripped down version of the scouting app.

Signed-off-by: Philipp Schrader <philipp.schrader@gmail.com>
Change-Id: Ia97a3670439f4c3f208f8110282645aa0e3862f1
diff --git a/.bazelignore b/.bazelignore
index 7fa8e45..cce9d62 100644
--- a/.bazelignore
+++ b/.bazelignore
@@ -1,5 +1,6 @@
 external
 node_modules
+frc971/control_loops/swerve/spline_ui/www/node_modules
 scouting/webserver/requests/messages/node_modules
 scouting/www/node_modules
 scouting/www/driver_ranking/node_modules
diff --git a/WORKSPACE b/WORKSPACE
index 6be969d..ad59314 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -944,6 +944,7 @@
     name = "npm",
     data = [
         "//aos/analysis/foxglove_extension:package.json",
+        "//control_loops/swerve/spline_ui/www:package.json",
         "@//:package.json",
         "@//:pnpm-workspace.yaml",
         "@//scouting/webserver/requests/messages:package.json",
diff --git a/frc971/control_loops/swerve/spline_ui/www/BUILD b/frc971/control_loops/swerve/spline_ui/www/BUILD
new file mode 100644
index 0000000..1ecd17c
--- /dev/null
+++ b/frc971/control_loops/swerve/spline_ui/www/BUILD
@@ -0,0 +1,46 @@
+load("@aspect_bazel_lib//lib:copy_file.bzl", "copy_file")
+load("@npm//:defs.bzl", "npm_link_all_packages")
+load("//tools/build_rules:js.bzl", "ng_application")
+load("//tools/build_rules/js:static.bzl", "assemble_static_files")
+
+npm_link_all_packages(name = "node_modules")
+
+ng_application(
+    name = "app",
+    assets = [
+        # Explicitly blank to avoid the default.
+    ],
+    extra_srcs = [
+        "app/common.css",
+    ],
+    html_assets = [
+        "favicon.ico",
+    ],
+    deps = [
+        ":node_modules",
+    ],
+)
+
+assemble_static_files(
+    name = "static_files",
+    srcs = [
+        "//third_party/y2024/field:pictures",
+    ],
+    app_files = ":app",
+    replace_prefixes = {
+        "prod": "",
+        "dev": "",
+        "third_party/y2024": "pictures",
+    },
+    tags = [
+        "no-remote-cache",
+    ],
+    visibility = ["//visibility:public"],
+)
+
+copy_file(
+    name = "app_common_css",
+    src = "common.css",
+    out = "app/common.css",
+    visibility = [":__subpackages__"],
+)
diff --git a/frc971/control_loops/swerve/spline_ui/www/README.md b/frc971/control_loops/swerve/spline_ui/www/README.md
new file mode 100644
index 0000000..17960c7
--- /dev/null
+++ b/frc971/control_loops/swerve/spline_ui/www/README.md
@@ -0,0 +1,10 @@
+Scouting App Frontend
+================================================================================
+
+Run using:
+```console
+$ bazel build //frc971/control_loops/swerve/spline_ui/www:all
+$ (cd bazel-bin/frc971/control_loops/swerve/spline_ui/www/static_files/ && python3 -m http.server)
+```
+
+Then you can navigate to <http://localhost:8000> to look at the web page.
diff --git a/frc971/control_loops/swerve/spline_ui/www/app/app.module.ts b/frc971/control_loops/swerve/spline_ui/www/app/app.module.ts
new file mode 100644
index 0000000..6948041
--- /dev/null
+++ b/frc971/control_loops/swerve/spline_ui/www/app/app.module.ts
@@ -0,0 +1,16 @@
+import {NgModule} from '@angular/core';
+import {BrowserModule} from '@angular/platform-browser';
+import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
+
+import {App} from './app';
+
+@NgModule({
+  declarations: [App],
+  imports: [
+    BrowserModule,
+    BrowserAnimationsModule,
+  ],
+  exports: [App],
+  bootstrap: [App],
+})
+export class AppModule {}
diff --git a/frc971/control_loops/swerve/spline_ui/www/app/app.ng.html b/frc971/control_loops/swerve/spline_ui/www/app/app.ng.html
new file mode 100644
index 0000000..6e43eac
--- /dev/null
+++ b/frc971/control_loops/swerve/spline_ui/www/app/app.ng.html
@@ -0,0 +1 @@
+<h1>Spline UI</h1>
diff --git a/frc971/control_loops/swerve/spline_ui/www/app/app.ts b/frc971/control_loops/swerve/spline_ui/www/app/app.ts
new file mode 100644
index 0000000..dc0cb7c
--- /dev/null
+++ b/frc971/control_loops/swerve/spline_ui/www/app/app.ts
@@ -0,0 +1,9 @@
+import {Component} from '@angular/core';
+
+@Component({
+  selector: 'spline-ui-app',
+  templateUrl: './app.ng.html',
+  styleUrls: ['../app/common.css'],
+})
+export class App {
+}
diff --git a/frc971/control_loops/swerve/spline_ui/www/common.css b/frc971/control_loops/swerve/spline_ui/www/common.css
new file mode 100644
index 0000000..cf58bba
--- /dev/null
+++ b/frc971/control_loops/swerve/spline_ui/www/common.css
@@ -0,0 +1 @@
+/* This CSS is shared between all spline UI pages. */
diff --git a/frc971/control_loops/swerve/spline_ui/www/favicon.ico b/frc971/control_loops/swerve/spline_ui/www/favicon.ico
new file mode 100644
index 0000000..2bf69ea
--- /dev/null
+++ b/frc971/control_loops/swerve/spline_ui/www/favicon.ico
Binary files differ
diff --git a/frc971/control_loops/swerve/spline_ui/www/index.html b/frc971/control_loops/swerve/spline_ui/www/index.html
new file mode 100644
index 0000000..c36936d
--- /dev/null
+++ b/frc971/control_loops/swerve/spline_ui/www/index.html
@@ -0,0 +1,30 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8" />
+    <title>FRC971 Spline UI</title>
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <meta name="theme-color" content="#000000" />
+    <link
+      href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
+      rel="stylesheet"
+      integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
+      crossorigin="anonymous"
+    />
+    <link
+      rel="stylesheet"
+      href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
+    />
+    <script
+      src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
+      integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
+      crossorigin="anonymous"
+    ></script>
+  </head>
+  <body>
+    <spline-ui-app></spline-ui-app>
+    <noscript>
+      Please enable JavaScript to continue using this application.
+    </noscript>
+  </body>
+</html>
diff --git a/frc971/control_loops/swerve/spline_ui/www/main.ts b/frc971/control_loops/swerve/spline_ui/www/main.ts
new file mode 100644
index 0000000..1f8eb00
--- /dev/null
+++ b/frc971/control_loops/swerve/spline_ui/www/main.ts
@@ -0,0 +1,4 @@
+import {platformBrowser} from '@angular/platform-browser';
+import {AppModule} from './app/app.module';
+
+platformBrowser().bootstrapModule(AppModule);
diff --git a/frc971/control_loops/swerve/spline_ui/www/package.json b/frc971/control_loops/swerve/spline_ui/www/package.json
new file mode 100644
index 0000000..54a7f09
--- /dev/null
+++ b/frc971/control_loops/swerve/spline_ui/www/package.json
@@ -0,0 +1,6 @@
+{
+    "private": true,
+    "dependencies": {
+        "@angular/animations": "v16-lts"
+    }
+}
diff --git a/frc971/control_loops/swerve/spline_ui/www/polyfills.ts b/frc971/control_loops/swerve/spline_ui/www/polyfills.ts
new file mode 100644
index 0000000..e4555ed
--- /dev/null
+++ b/frc971/control_loops/swerve/spline_ui/www/polyfills.ts
@@ -0,0 +1,52 @@
+/**
+ * This file includes polyfills needed by Angular and is loaded before the app.
+ * You can add your own extra polyfills to this file.
+ *
+ * This file is divided into 2 sections:
+ *   1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.
+ *   2. Application imports. Files imported after ZoneJS that should be loaded before your main
+ *      file.
+ *
+ * The current setup is for so-called "evergreen" browsers; the last versions of browsers that
+ * automatically update themselves. This includes recent versions of Safari, Chrome (including
+ * Opera), Edge on the desktop, and iOS and Chrome on mobile.
+ *
+ * Learn more in https://angular.io/guide/browser-support
+ */
+
+/***************************************************************************************************
+ * BROWSER POLYFILLS
+ */
+
+/**
+ * By default, zone.js will patch all possible macroTask and DomEvents
+ * user can disable parts of macroTask/DomEvents patch by setting following flags
+ * because those flags need to be set before `zone.js` being loaded, and webpack
+ * will put import in the top of bundle, so user need to create a separate file
+ * in this directory (for example: zone-flags.ts), and put the following flags
+ * into that file, and then add the following code before importing zone.js.
+ * import './zone-flags';
+ *
+ * The flags allowed in zone-flags.ts are listed here.
+ *
+ * The following flags will work for all browsers.
+ *
+ * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame
+ * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick
+ * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames
+ *
+ *  in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js
+ *  with the following flag, it will bypass `zone.js` patch for IE/Edge
+ *
+ *  (window as any).__Zone_enable_cross_context_check = true;
+ *
+ */
+
+/***************************************************************************************************
+ * Zone JS is required by default for Angular itself.
+ */
+import 'zone.js'; // Included with Angular CLI.
+
+/***************************************************************************************************
+ * APPLICATION IMPORTS
+ */
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d8164ab..c8e5425 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -170,6 +170,12 @@
         specifier: ^5
         version: 5.1.6
 
+  frc971/control_loops/swerve/spline_ui/www:
+    dependencies:
+      '@angular/animations':
+        specifier: v16-lts
+        version: 16.2.12(@angular/core@16.2.12)
+
   scouting/webserver/requests/messages: {}
 
   scouting/www:
@@ -390,7 +396,7 @@
       '@angular/core': 16.2.12
     dependencies:
       '@angular/core': 16.2.12(rxjs@7.5.7)(zone.js@0.13.3)
-      tslib: 2.6.0
+      tslib: 2.6.2
 
   /@angular/cli@16.2.12:
     resolution: {integrity: sha512-Pcbiraoqdw4rR2Ey5Ooy0ESLS1Ffbjkb6sPfinKRkHmAvyqsmlvkfbB/qK8GrzDSFSWvAKMMXRw9l8nbjvQEXg==}
@@ -2200,7 +2206,6 @@
       function-bind: 1.1.2
       get-intrinsic: 1.2.4
       set-function-length: 1.2.2
-    dev: false
 
   /callsites@3.1.0:
     resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
@@ -2639,7 +2644,6 @@
       es-define-property: 1.0.0
       es-errors: 1.3.0
       gopd: 1.0.1
-    dev: false
 
   /define-lazy-prop@2.0.0:
     resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==}
@@ -2878,12 +2882,10 @@
     engines: {node: '>= 0.4'}
     dependencies:
       get-intrinsic: 1.2.4
-    dev: false
 
   /es-errors@1.3.0:
     resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
     engines: {node: '>= 0.4'}
-    dev: false
 
   /es-iterator-helpers@1.0.19:
     resolution: {integrity: sha512-zoMwbCcH5hwUkKJkT8kDIBZSz9I6mVG//+lDCinLCGov4+r7NIy0ld8o03M0cJxl2spVf6ESYVS6/gpIfq1FFw==}
@@ -3567,7 +3569,6 @@
       has-proto: 1.0.1
       has-symbols: 1.0.3
       hasown: 2.0.1
-    dev: false
 
   /get-stream@5.2.0:
     resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==}
@@ -3708,7 +3709,6 @@
     resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==}
     dependencies:
       get-intrinsic: 1.2.4
-    dev: false
 
   /graceful-fs@4.2.11:
     resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
@@ -3734,7 +3734,6 @@
     resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==}
     dependencies:
       es-define-property: 1.0.0
-    dev: false
 
   /has-proto@1.0.1:
     resolution: {integrity: sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==}
@@ -4977,7 +4976,6 @@
 
   /object-inspect@1.13.1:
     resolution: {integrity: sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==}
-    dev: false
 
   /object-keys@1.1.1:
     resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
@@ -5385,7 +5383,7 @@
     resolution: {integrity: sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==}
     engines: {node: '>=0.6'}
     dependencies:
-      side-channel: 1.0.4
+      side-channel: 1.0.6
 
   /querystringify@2.2.0:
     resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==}
@@ -5652,7 +5650,7 @@
   /rxjs@7.8.1:
     resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==}
     dependencies:
-      tslib: 2.6.0
+      tslib: 2.6.2
     dev: true
 
   /safe-array-concat@1.1.2:
@@ -5756,7 +5754,6 @@
       get-intrinsic: 1.2.4
       gopd: 1.0.1
       has-property-descriptors: 1.0.2
-    dev: false
 
   /set-function-name@2.0.2:
     resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==}
@@ -5799,7 +5796,6 @@
       es-errors: 1.3.0
       get-intrinsic: 1.2.4
       object-inspect: 1.13.1
-    dev: false
 
   /signal-exit@3.0.7:
     resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
@@ -6293,7 +6289,6 @@
 
   /tslib@2.6.2:
     resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==}
-    dev: false
 
   /tsutils@3.21.0(typescript@5.1.6):
     resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index f18844b..6675e2f 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,4 +1,5 @@
 packages:
+- frc971/control_loops/swerve/spline_ui/www
 - scouting/webserver/requests/messages
 - scouting/www
 - scouting/www/*
diff --git a/scouting/www/BUILD b/scouting/www/BUILD
index c73f93a..2a34fdf 100644
--- a/scouting/www/BUILD
+++ b/scouting/www/BUILD
@@ -3,7 +3,8 @@
 load("@npm//:@angular/service-worker/package_json.bzl", angular_service_worker = "bin")
 load("@npm//:defs.bzl", "npm_link_all_packages")
 load("//tools/build_rules:js.bzl", "ng_application")
-load(":defs.bzl", "assemble_service_worker_files", "assemble_static_files")
+load("//tools/build_rules/js:static.bzl", "assemble_static_files")
+load(":defs.bzl", "assemble_service_worker_files")
 
 npm_link_all_packages(name = "node_modules")
 
diff --git a/scouting/www/defs.bzl b/scouting/www/defs.bzl
index 86095d7..a8bc775 100644
--- a/scouting/www/defs.bzl
+++ b/scouting/www/defs.bzl
@@ -1,41 +1,3 @@
-load("@aspect_bazel_lib//lib:copy_to_directory.bzl", "copy_to_directory_bin_action")
-
-def _assemble_static_files_impl(ctx):
-    out_dir = ctx.actions.declare_directory(ctx.label.name)
-
-    copy_to_directory_bin = ctx.toolchains["@aspect_bazel_lib//lib:copy_to_directory_toolchain_type"].copy_to_directory_info.bin
-
-    copy_to_directory_bin_action(
-        ctx,
-        dst = out_dir,
-        name = ctx.label.name,
-        copy_to_directory_bin = copy_to_directory_bin,
-        files = ctx.files.srcs + ctx.attr.app_files.files.to_list(),
-        replace_prefixes = ctx.attr.replace_prefixes,
-    )
-
-    return [DefaultInfo(
-        files = depset([out_dir]),
-        runfiles = ctx.runfiles([out_dir]),
-    )]
-
-assemble_static_files = rule(
-    implementation = _assemble_static_files_impl,
-    attrs = {
-        "app_files": attr.label(
-            mandatory = True,
-        ),
-        "srcs": attr.label_list(
-            mandatory = True,
-            allow_files = True,
-        ),
-        "replace_prefixes": attr.string_dict(
-            mandatory = True,
-        ),
-    },
-    toolchains = ["@aspect_bazel_lib//lib:copy_to_directory_toolchain_type"],
-)
-
 def _assemble_service_worker_files_impl(ctx):
     args = ctx.actions.args()
     args.add_all(ctx.attr._package.files, before_each = "--input_dir", expand_directories = False)
diff --git a/scouting/www/test/authorize/BUILD b/scouting/www/test/authorize/BUILD
index af8bc70..e982298 100644
--- a/scouting/www/test/authorize/BUILD
+++ b/scouting/www/test/authorize/BUILD
@@ -1,8 +1,8 @@
 load("@aspect_rules_js//js:defs.bzl", "js_test")
 load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
 load("@npm//:defs.bzl", "npm_link_all_packages")
-load("//scouting/www:defs.bzl", "assemble_static_files")
 load("//tools/build_rules:js.bzl", "ng_application")
+load("//tools/build_rules/js:static.bzl", "assemble_static_files")
 
 npm_link_all_packages(name = "node_modules")
 
diff --git a/tools/build_rules/js.bzl b/tools/build_rules/js.bzl
index d7096a9..a95d5f2 100644
--- a/tools/build_rules/js.bzl
+++ b/tools/build_rules/js.bzl
@@ -84,8 +84,8 @@
       assets: assets to include in the file bundle
       visibility: visibility of the primary targets ({name}, 'test', 'serve')
     """
-    assets = assets if assets else native.glob(["assets/**/*"])
-    html_assets = html_assets if html_assets else []
+    assets = assets if assets != None else native.glob(["assets/**/*"])
+    html_assets = html_assets or []
 
     test_spec_srcs = native.glob(["app/**/*.spec.ts"])
 
diff --git a/tools/build_rules/js/static.bzl b/tools/build_rules/js/static.bzl
new file mode 100644
index 0000000..a2fe2ee
--- /dev/null
+++ b/tools/build_rules/js/static.bzl
@@ -0,0 +1,37 @@
+load("@aspect_bazel_lib//lib:copy_to_directory.bzl", "copy_to_directory_bin_action")
+
+def _assemble_static_files_impl(ctx):
+    out_dir = ctx.actions.declare_directory(ctx.label.name)
+
+    copy_to_directory_bin = ctx.toolchains["@aspect_bazel_lib//lib:copy_to_directory_toolchain_type"].copy_to_directory_info.bin
+
+    copy_to_directory_bin_action(
+        ctx,
+        dst = out_dir,
+        name = ctx.label.name,
+        copy_to_directory_bin = copy_to_directory_bin,
+        files = ctx.files.srcs + ctx.attr.app_files.files.to_list(),
+        replace_prefixes = ctx.attr.replace_prefixes,
+    )
+
+    return [DefaultInfo(
+        files = depset([out_dir]),
+        runfiles = ctx.runfiles([out_dir]),
+    )]
+
+assemble_static_files = rule(
+    implementation = _assemble_static_files_impl,
+    attrs = {
+        "app_files": attr.label(
+            mandatory = True,
+        ),
+        "srcs": attr.label_list(
+            mandatory = True,
+            allow_files = True,
+        ),
+        "replace_prefixes": attr.string_dict(
+            mandatory = True,
+        ),
+    },
+    toolchains = ["@aspect_bazel_lib//lib:copy_to_directory_toolchain_type"],
+)