Migrate from rules_nodejs to rules_js

This patch is huge because I can't easily break it into smaller
pieces. This is largely because a few things are changing with the
migration.

Firstly, we're upgrading the angular libraries and our version of
typescript. This is actually not too disruptive. It required some
changes in the top-level `package.json` file.

Secondly, the new rules have this concept of copying everything into
the `bazel-bin` directory and executing out of there. This makes the
various tools like node and angular happy, but means that a few file
paths are changing. For example, the `common.css` file can't have the
same path in the source tree as it does in the `bazel-bin` tree, so I
moved it to a `common` directory inside the `bazel-bin` tree. You can
read more about this here:
https://docs.aspect.build/rules/aspect_rules_js#running-nodejs-programs

Thirdly, I couldn't find a simple way to support Protractor in
rules_js. Protractor is the end-to-end testing framework we use for
the scouting application. Since protractor is getting deprecated and
won't receive any more updates, it's time to move to something else.
We settled on Cypress because it appears to be popular and should make
debugging easier for the students. For example, it takes screenshots
of the browser when an assertion fails. It would have been ideal to
switch to Cypress before this migration, but I couldn't find a simple
way to make that work with rules_nodejs. In other words, this
migration patch is huge in part because we are also switching testing
frameworks at the same time. I wanted to split it out, but it was more
difficult than I would have liked.

Fourthly, I also needed to migrate the flatbuffer rules. This I think
is relatively low impact, but it again means that this patch is bigger
than I wanted it to be.

Signed-off-by: Philipp Schrader <philipp.schrader@gmail.com>
Change-Id: I6674874f985952f2e3ef40274da0a2fb9e5631a7
diff --git a/scouting/BUILD b/scouting/BUILD
index ae121e6..1d6ac5d 100644
--- a/scouting/BUILD
+++ b/scouting/BUILD
@@ -1,5 +1,5 @@
+load("@aspect_rules_cypress//cypress:defs.bzl", "cypress_module_test")
 load("//tools/build_rules:apache.bzl", "apache_wrapper")
-load("//tools/build_rules:js.bzl", "protractor_ts_test")
 
 sh_binary(
     name = "scouting",
@@ -16,13 +16,23 @@
     ],
 )
 
-protractor_ts_test(
+cypress_module_test(
     name = "scouting_test",
-    srcs = [
-        ":scouting_test.ts",
+    args = [
+        "run",
+        "--config-file=cypress.config.js",
+        "--browser=../../chrome_linux/chrome",
     ],
-    on_prepare = ":scouting_test.protractor.on-prepare.js",
-    server = "//scouting/testing:scouting_test_servers",
+    browsers = ["@chrome_linux//:all"],
+    copy_data_to_bin = False,
+    cypress = "//:node_modules/cypress",
+    data = [
+        "cypress.config.js",
+        "scouting_test.cy.js",
+        "//scouting/testing:scouting_test_servers",
+        "@xvfb_amd64//:wrapped_bin/Xvfb",
+    ],
+    runner = "cypress_runner.js",
 )
 
 apache_wrapper(
diff --git a/scouting/cypress.config.js b/scouting/cypress.config.js
new file mode 100644
index 0000000..4eb1a82
--- /dev/null
+++ b/scouting/cypress.config.js
@@ -0,0 +1,22 @@
+const {defineConfig} = require('cypress');
+
+module.exports = defineConfig({
+  e2e: {
+    specPattern: ['*.cy.js'],
+    supportFile: false,
+    setupNodeEvents(on, config) {
+      on('before:browser:launch', (browser = {}, launchOptions) => {
+        launchOptions.args.push('--disable-gpu-shader-disk-cache');
+      });
+
+      // Lets users print to the console:
+      //    cy.task('log', 'message here');
+      on('task', {
+        log(message) {
+          console.log(message);
+          return null;
+        },
+      });
+    },
+  },
+});
diff --git a/scouting/cypress_runner.js b/scouting/cypress_runner.js
new file mode 100644
index 0000000..6c63adb
--- /dev/null
+++ b/scouting/cypress_runner.js
@@ -0,0 +1,86 @@
+const child_process = require('child_process');
+const process = require('process');
+
+const cypress = require('cypress');
+
+// Set up the xvfb binary.
+process.env[
+  'PATH'
+] = `${process.env.RUNFILES_DIR}/xvfb_amd64/wrapped_bin:${process.env.PATH}`;
+
+// Start the web server, database, and fake TBA server.
+// We use file descriptor 3 ('pipe') for the test server to let us know when
+// everything has started up.
+console.log('Starting server.');
+let servers = child_process.spawn(
+  'testing/scouting_test_servers',
+  ['--port=8000', '--notify_fd=3'],
+  {
+    stdio: ['inherit', 'inherit', 'inherit', 'pipe'],
+  }
+);
+
+// Wait for the server to finish starting up.
+const serverStartup = new Promise((resolve, reject) => {
+  let cumulativeData = '';
+  servers.stdio[3].on('data', async (data) => {
+    console.log('Got data: ' + data);
+    cumulativeData += data;
+    if (cumulativeData.includes('READY')) {
+      console.log('Everything is ready!');
+      resolve();
+    }
+  });
+
+  servers.on('error', (err) => {
+    console.log(`Failed to start scouting_test_servers: ${err}`);
+    reject();
+  });
+
+  servers.on('close', (code, signal) => {
+    console.log(`scouting_test_servers closed: ${code} (${signal})`);
+    reject();
+  });
+
+  servers.on('exit', (code, signal) => {
+    console.log(`scouting_test_servers exited: ${code} (${signal})`);
+    reject();
+  });
+});
+
+// Wait for the server to shut down.
+const serverShutdown = new Promise((resolve) => {
+  servers.on('exit', () => {
+    resolve();
+  });
+});
+
+// Wait for the server to be ready, run the tests, then shut down the server.
+(async () => {
+  await serverStartup;
+  const result = await cypress.run({
+    headless: true,
+    config: {
+      baseUrl: 'http://localhost:8000',
+      screenshotsFolder:
+        process.env.TEST_UNDECLARED_OUTPUTS_DIR + '/screenshots',
+      video: false,
+      videosFolder: process.env.TEST_UNDECLARED_OUTPUTS_DIR + '/videos',
+    },
+  });
+  await servers.kill();
+  await serverShutdown;
+
+  exitCode = 0;
+  if (result.status == 'failed') {
+    exitCode = 1;
+    console.log('-'.repeat(50));
+    console.log('Test FAILED: ' + result.message);
+    console.log('-'.repeat(50));
+  } else if (result.totalFailed > 0) {
+    // When the "before" hook fails, we don't get a "failed" mesage for some
+    // reason. In that case, we just have to exit with an error.
+    exitCode = 1;
+  }
+  process.exit(exitCode);
+})();
diff --git a/scouting/scouting.sh b/scouting/scouting.sh
index 30e2989..f1d7123 100755
--- a/scouting/scouting.sh
+++ b/scouting/scouting.sh
@@ -17,5 +17,5 @@
 
 exec \
     "${RUNFILES_DIR}"/org_frc971/scouting/webserver/webserver_/webserver \
-    -directory "${RUNFILES_DIR}"/org_frc971/scouting/www/ \
+    -directory "${RUNFILES_DIR}"/org_frc971/scouting/www/static_files/ \
     "$@"
diff --git a/scouting/scouting_test.cy.js b/scouting/scouting_test.cy.js
new file mode 100644
index 0000000..e27fb54
--- /dev/null
+++ b/scouting/scouting_test.cy.js
@@ -0,0 +1,346 @@
+/// <reference types="cypress" />
+
+function disableAlerts() {
+  cy.get('#block_alerts').check({force: true}).should('be.checked');
+}
+
+function switchToTab(tabName) {
+  cy.contains('.nav-link', tabName).click();
+}
+
+function headerShouldBe(text) {
+  cy.get('.header').should('have.text', text);
+}
+
+function clickButton(buttonName) {
+  cy.contains('button', buttonName).click();
+}
+
+function setInputTo(fieldSelector, value) {
+  cy.get(fieldSelector).type('{selectAll}' + value);
+}
+
+// Moves the nth slider left or right. A positive "adjustBy" value moves the
+// slider to the right. A negative value moves the slider to the left.
+//
+//   negative/left <--- 0 ---> positive/right
+function adjustNthSliderBy(n, adjustBy) {
+  let element = cy.get('input[type=range]').eq(n);
+  element.scrollIntoView();
+  element.invoke('val').then((currentValue) => {
+    // We need to query for the slider here again because `invoke('val')` above
+    // somehow invalidates further calls to `val`.
+    cy.get('input[type=range]')
+      .eq(n)
+      .invoke('val', currentValue + adjustBy)
+      .trigger('change');
+  });
+}
+
+// Asserts that the field on the "Submit and Review" screen has a specific
+// value.
+function expectReviewFieldToBe(fieldName, expectedValue) {
+  expectNthReviewFieldToBe(fieldName, 0, expectedValue);
+}
+
+// Asserts that the n'th instance of a field on the "Submit and Review"
+// screen has a specific value.
+function expectNthReviewFieldToBe(fieldName, n, expectedValue) {
+  getNthReviewField(fieldName, n).should(
+    'have.text',
+    `${fieldName}: ${expectedValue}`
+  );
+}
+
+function getNthReviewField(fieldName, n) {
+  let element = cy.get('li').filter(`:contains("${fieldName}: ")`).eq(n);
+  element.scrollIntoView();
+  return element;
+}
+
+before(() => {
+  cy.visit('/');
+  disableAlerts();
+  cy.title().should('eq', 'FRC971 Scouting Application');
+
+  // Import the match list before running any tests. Ideally this should be
+  // run in beforeEach(), but it's not worth doing that at this time. Our
+  // tests are basic enough not to require this.
+  switchToTab('Import Match List');
+  headerShouldBe('Import Match List');
+  setInputTo('#year', '2016');
+  setInputTo('#event_code', 'nytr');
+  clickButton('Import');
+
+  cy.get('.progress_message').contains('Successfully imported match list.');
+});
+
+beforeEach(() => {
+  cy.visit('/');
+  disableAlerts();
+});
+
+describe('Scouting app tests', () => {
+  it('should: show matches in chronological order.', () => {
+    headerShouldBe('Matches');
+    cy.get('.badge').eq(0).contains('Quals Match 1');
+    cy.get('.badge').eq(1).contains('Quals Match 2');
+    cy.get('.badge').eq(2).contains('Quals Match 3');
+    cy.get('.badge').eq(9).contains('Quals Match 10');
+    cy.get('.badge').eq(72).contains('Quarter Final 1 Match 1');
+    cy.get('.badge').eq(73).contains('Quarter Final 2 Match 1');
+    cy.get('.badge').eq(74).contains('Quarter Final 3 Match 1');
+    cy.get('.badge').eq(75).contains('Quarter Final 4 Match 1');
+    cy.get('.badge').eq(76).contains('Quarter Final 1 Match 2');
+    cy.get('.badge').eq(82).contains('Semi Final 1 Match 1');
+    cy.get('.badge').eq(83).contains('Semi Final 2 Match 1');
+    cy.get('.badge').eq(84).contains('Semi Final 1 Match 2');
+    cy.get('.badge').eq(85).contains('Semi Final 2 Match 2');
+    cy.get('.badge').eq(89).contains('Final 1 Match 3');
+  });
+
+  it('should: prefill the match information.', () => {
+    headerShouldBe('Matches');
+
+    // On the 87th row of matches (index 86) click on the second team
+    // (index 1) which resolves to team 5254 in semi final 2 match 3.
+    cy.get('button.match-item')
+      .eq(86 * 6 + 1)
+      .click();
+
+    headerShouldBe('Team Selection');
+    cy.get('#match_number').should('have.value', '3');
+    cy.get('#team_number').should('have.value', '5254');
+    cy.get('#set_number').should('have.value', '2');
+    cy.get('#comp_level').should('have.value', '3: sf');
+  });
+
+  it('should: error on unknown match.', () => {
+    switchToTab('Data Entry');
+    headerShouldBe('Team Selection');
+
+    // Pick a match that doesn't exist in the 2016nytr match list.
+    setInputTo('#match_number', '3');
+    setInputTo('#team_number', '971');
+
+    // Click Next until we get to the submit screen.
+    for (let i = 0; i < 5; i++) {
+      clickButton('Next');
+    }
+    headerShouldBe('Review and Submit');
+
+    // Attempt to submit and validate the error.
+    clickButton('Submit');
+    cy.get('.error_message').contains(
+      'Failed to find team 971 in match 3 in the schedule.'
+    );
+  });
+
+  // Make sure that each page on the Entry tab has both "Next" and "Back"
+  // buttons. The only screens exempted from this are the first page and the
+  // last page.
+  it('should: have forwards and backwards buttons.', () => {
+    switchToTab('Data Entry');
+
+    const expectedOrder = [
+      'Team Selection',
+      'Auto',
+      'TeleOp',
+      'Climb',
+      'Other',
+      'Review and Submit',
+    ];
+
+    // Go forward through the screens.
+    for (let i = 0; i < expectedOrder.length; i++) {
+      headerShouldBe(expectedOrder[i]);
+      if (i != expectedOrder.length - 1) {
+        clickButton('Next');
+      }
+    }
+
+    // Go backwards through the screens.
+    for (let i = 0; i < expectedOrder.length; i++) {
+      headerShouldBe(expectedOrder[expectedOrder.length - i - 1]);
+      if (i != expectedOrder.length - 1) {
+        clickButton('Back');
+      }
+    }
+  });
+
+  it('should: review and submit correct data.', () => {
+    switchToTab('Data Entry');
+
+    // Submit scouting data for a random team that attended 2016nytr.
+    headerShouldBe('Team Selection');
+    setInputTo('#match_number', '2');
+    setInputTo('#team_number', '5254');
+    setInputTo('#set_number', '42');
+    cy.get('#comp_level').select('Semi Finals');
+    clickButton('Next');
+
+    headerShouldBe('Auto');
+    cy.get('#quadrant3').check();
+    clickButton('Next');
+
+    headerShouldBe('TeleOp');
+    clickButton('Next');
+
+    headerShouldBe('Climb');
+    cy.get('#high').check();
+    clickButton('Next');
+
+    headerShouldBe('Other');
+    adjustNthSliderBy(0, 3);
+    adjustNthSliderBy(1, 1);
+    cy.get('#no_show').check();
+    cy.get('#mechanically_broke').check();
+    setInputTo('#comment', 'A very useful comment here.');
+    clickButton('Next');
+
+    headerShouldBe('Review and Submit');
+    cy.get('.error_message').should('have.text', '');
+
+    // Validate Team Selection.
+    expectReviewFieldToBe('Match number', '2');
+    expectReviewFieldToBe('Team number', '5254');
+    expectReviewFieldToBe('SetNumber', '42');
+    expectReviewFieldToBe('Comp Level', 'Semi Finals');
+
+    // Validate Auto.
+    expectNthReviewFieldToBe('Upper Shots Made', 0, '0');
+    expectNthReviewFieldToBe('Lower Shots Made', 0, '0');
+    expectNthReviewFieldToBe('Missed Shots', 0, '0');
+    expectReviewFieldToBe('Quadrant', '3');
+
+    // Validate TeleOp.
+    expectNthReviewFieldToBe('Upper Shots Made', 1, '0');
+    expectNthReviewFieldToBe('Lower Shots Made', 1, '0');
+    expectNthReviewFieldToBe('Missed Shots', 1, '0');
+
+    // Validate Climb.
+    expectReviewFieldToBe('Climb Level', 'High');
+
+    // Validate Other.
+    expectReviewFieldToBe('Defense Played On Rating', '3');
+    expectReviewFieldToBe('Defense Played Rating', '1');
+    expectReviewFieldToBe('No show', 'true');
+    expectReviewFieldToBe('Never moved', 'false');
+    expectReviewFieldToBe('Battery died', 'false');
+    expectReviewFieldToBe('Broke (mechanically)', 'true');
+    expectReviewFieldToBe('Comments', 'A very useful comment here.');
+
+    clickButton('Submit');
+    headerShouldBe('Success');
+  });
+
+  it('should: load all images successfully.', () => {
+    switchToTab('Data Entry');
+
+    // Get to the Auto display with the field pictures.
+    headerShouldBe('Team Selection');
+    clickButton('Next');
+    headerShouldBe('Auto');
+
+    // We expect 2 fully loaded images for each of the orientations.
+    // 2 images for the original orientation and 2 images for the flipped orientation.
+    for (let i = 0; i < 2; i++) {
+      cy.get('img').should(($imgs) => {
+        for (const $img of $imgs) {
+          expect($img.naturalWidth).to.be.greaterThan(0);
+        }
+      });
+      clickButton('Flip');
+    }
+  });
+
+  it('should: submit note scouting for multiple teams', () => {
+    // Navigate to Notes Page.
+    switchToTab('Notes');
+    headerShouldBe('Notes');
+
+    // Add first team.
+    setInputTo('#team_number_notes', '1234');
+    clickButton('Select');
+
+    // Add note and select keyword for first team.
+    cy.get('#team-key-1').should('have.text', '1234');
+    setInputTo('#text-input-1', 'Good Driving');
+    cy.get('#good_driving_0').click();
+
+    // Navigate to add team selection and add another team.
+    clickButton('Add team');
+    setInputTo('#team_number_notes', '1235');
+    clickButton('Select');
+
+    // Add note and select keyword for second team.
+    cy.get('#team-key-2').should('have.text', '1235');
+    setInputTo('#text-input-2', 'Bad Driving');
+    cy.get('#bad_driving_1').click();
+
+    // Submit Notes.
+    clickButton('Submit');
+    cy.get('#team_number_label').should('have.text', ' Team Number ');
+  });
+
+  it('should: switch note text boxes with keyboard shortcuts', () => {
+    // Navigate to Notes Page.
+    switchToTab('Notes');
+    headerShouldBe('Notes');
+
+    // Add first team.
+    setInputTo('#team_number_notes', '1234');
+    clickButton('Select');
+
+    // Add second team.
+    clickButton('Add team');
+    setInputTo('#team_number_notes', '1235');
+    clickButton('Select');
+
+    // Add third team.
+    clickButton('Add team');
+    setInputTo('#team_number_notes', '1236');
+    clickButton('Select');
+
+    for (let i = 1; i <= 3; i++) {
+      // Press Control + i
+      cy.get('body').type(`{ctrl}${i}`);
+
+      // Expect text input to be focused.
+      cy.focused().then(($element) => {
+        expect($element).to.have.id(`text-input-${i}`);
+      });
+    }
+  });
+
+  it('should: submit driver ranking', () => {
+    // Navigate to Driver Ranking Page.
+    switchToTab('Driver Ranking');
+    headerShouldBe('Driver Ranking');
+
+    // Input match and team numbers.
+    setInputTo('#match_number_selection', '11');
+    setInputTo('#team_input_0', '123');
+    setInputTo('#team_input_1', '456');
+    setInputTo('#team_input_2', '789');
+    clickButton('Select');
+
+    // Verify match and team key input.
+    cy.get('#match_number_heading').should('have.text', 'Match #11');
+    cy.get('#team_key_label_0').should('have.text', ' 123 ');
+    cy.get('#team_key_label_1').should('have.text', ' 456 ');
+    cy.get('#team_key_label_2').should('have.text', ' 789 ');
+
+    // Rank teams.
+    cy.get('#up_button_2').click();
+    cy.get('#down_button_0').click();
+
+    // Verify ranking change.
+    cy.get('#team_key_label_0').should('have.text', ' 789 ');
+    cy.get('#team_key_label_1').should('have.text', ' 123 ');
+    cy.get('#team_key_label_2').should('have.text', ' 456 ');
+
+    // Submit.
+    clickButton('Submit');
+  });
+});
diff --git a/scouting/scouting_test.protractor.on-prepare.js b/scouting/scouting_test.protractor.on-prepare.js
deleted file mode 100644
index a1f7267..0000000
--- a/scouting/scouting_test.protractor.on-prepare.js
+++ /dev/null
@@ -1,22 +0,0 @@
-// The function exported from this file is used by the protractor_web_test_suite.
-// It is passed to the `onPrepare` configuration setting in protractor and executed
-// before running tests.
-//
-// If the function returns a promise, as it does here, protractor will wait
-// for the promise to resolve before running tests.
-
-const protractorUtils = require('@bazel/protractor/protractor-utils');
-const protractor = require('protractor');
-
-module.exports = function (config) {
-  // In this example, `@bazel/protractor/protractor-utils` is used to run
-  // the server. protractorUtils.runServer() runs the server on a randomly
-  // selected port (given a port flag to pass to the server as an argument).
-  // The port used is returned in serverSpec and the protractor serverUrl
-  // is the configured.
-  return protractorUtils
-    .runServer(config.workspace, config.server, '--port', [])
-    .then((serverSpec) => {
-      protractor.browser.baseUrl = `http://localhost:${serverSpec.port}`;
-    });
-};
diff --git a/scouting/scouting_test.ts b/scouting/scouting_test.ts
deleted file mode 100644
index cbeffc1..0000000
--- a/scouting/scouting_test.ts
+++ /dev/null
@@ -1,427 +0,0 @@
-import {browser, by, element, protractor} from 'protractor';
-
-const EC = protractor.ExpectedConditions;
-
-// Loads the page (or reloads it) and deals with the "Are you sure you want to
-// leave this page" popup.
-async function loadPage() {
-  await disableAlerts();
-  await browser.navigate().refresh();
-  expect(await browser.getTitle()).toEqual('FRC971 Scouting Application');
-  await disableAlerts();
-}
-
-// Disables alert popups. They are extremely tedious to deal with in
-// Protractor since they're not angular elements. We achieve this by checking
-// an invisible checkbox that's off-screen.
-async function disableAlerts() {
-  await browser.executeAsyncScript(function (callback) {
-    let block_alerts = document.getElementById(
-      'block_alerts'
-    ) as HTMLInputElement;
-    block_alerts.checked = true;
-    callback();
-  });
-}
-// Returns the contents of the header that displays the "Auto", "TeleOp", and
-// "Climb" labels etc.
-function getHeadingText() {
-  return element(by.css('.header')).getText();
-}
-
-// Returns the currently displayed progress message on the screen. This only
-// exists on screens where the web page interacts with the web server.
-function getProgressMessage() {
-  return element(by.css('.progress_message')).getText();
-}
-
-// Returns the currently displayed error message on the screen. This only
-// exists on screens where the web page interacts with the web server.
-function getErrorMessage() {
-  return element(by.css('.error_message')).getText();
-}
-
-// Returns the currently displayed error message on the screen. This only
-// exists on screens where the web page interacts with the web server.
-function getValueOfInputById(id: string) {
-  return element(by.id(id)).getAttribute('value');
-}
-
-// Asserts that the field on the "Submit and Review" screen has a specific
-// value.
-function expectReviewFieldToBe(fieldName: string, expectedValue: string) {
-  return expectNthReviewFieldToBe(fieldName, 0, expectedValue);
-}
-
-// Asserts that the n'th instance of a field on the "Submit and Review"
-// screen has a specific value.
-async function expectNthReviewFieldToBe(
-  fieldName: string,
-  n: number,
-  expectedValue: string
-) {
-  expect(
-    await element
-      .all(by.cssContainingText('li', `${fieldName}:`))
-      .get(n)
-      .getText()
-  ).toEqual(`${fieldName}: ${expectedValue}`);
-}
-
-// Sets a text field to the specified value.
-function setTextboxByIdTo(id: string, value: string) {
-  // Just sending "value" to the input fields is insufficient. We need to
-  // overwrite the text that is there. If we didn't hit CTRL-A to select all
-  // the text, we'd be appending to whatever is there already.
-  return element(by.id(id)).sendKeys(
-    protractor.Key.CONTROL,
-    'a',
-    protractor.Key.NULL,
-    value
-  );
-}
-
-// Moves the nth slider left or right. A positive "adjustBy" value moves the
-// slider to the right. A negative value moves the slider to the left.
-//
-//   negative/left <--- 0 ---> positive/right
-async function adjustNthSliderBy(n: number, adjustBy: number) {
-  const slider = element.all(by.css('input[type=range]')).get(n);
-  const key =
-    adjustBy > 0 ? protractor.Key.ARROW_RIGHT : protractor.Key.ARROW_LEFT;
-  for (let i = 0; i < Math.abs(adjustBy); i++) {
-    await slider.sendKeys(key);
-  }
-}
-
-function getNthMatchLabel(n: number) {
-  return element.all(by.css('.badge')).get(n).getText();
-}
-
-describe('The scouting web page', () => {
-  beforeAll(async () => {
-    await browser.get(browser.baseUrl);
-    expect(await browser.getTitle()).toEqual('FRC971 Scouting Application');
-    await disableAlerts();
-
-    // Import the match list before running any tests. Ideally this should be
-    // run in beforeEach(), but it's not worth doing that at this time. Our
-    // tests are basic enough not to require this.
-    await element(
-      by.cssContainingText('.nav-link', 'Import Match List')
-    ).click();
-    expect(await getHeadingText()).toEqual('Import Match List');
-    await setTextboxByIdTo('year', '2016');
-    await setTextboxByIdTo('event_code', 'nytr');
-    await element(by.buttonText('Import')).click();
-
-    await browser.wait(
-      EC.textToBePresentInElement(
-        element(by.css('.progress_message')),
-        'Successfully imported match list.'
-      )
-    );
-  });
-
-  it('should: show matches in chronological order.', async () => {
-    await loadPage();
-
-    expect(await getNthMatchLabel(0)).toEqual('Quals Match 1');
-    expect(await getNthMatchLabel(1)).toEqual('Quals Match 2');
-    expect(await getNthMatchLabel(2)).toEqual('Quals Match 3');
-    expect(await getNthMatchLabel(9)).toEqual('Quals Match 10');
-    expect(await getNthMatchLabel(72)).toEqual('Quarter Final 1 Match 1');
-    expect(await getNthMatchLabel(73)).toEqual('Quarter Final 2 Match 1');
-    expect(await getNthMatchLabel(74)).toEqual('Quarter Final 3 Match 1');
-    expect(await getNthMatchLabel(75)).toEqual('Quarter Final 4 Match 1');
-    expect(await getNthMatchLabel(76)).toEqual('Quarter Final 1 Match 2');
-    expect(await getNthMatchLabel(82)).toEqual('Semi Final 1 Match 1');
-    expect(await getNthMatchLabel(83)).toEqual('Semi Final 2 Match 1');
-    expect(await getNthMatchLabel(84)).toEqual('Semi Final 1 Match 2');
-    expect(await getNthMatchLabel(85)).toEqual('Semi Final 2 Match 2');
-    expect(await getNthMatchLabel(89)).toEqual('Final 1 Match 3');
-  });
-
-  it('should: prefill the match information.', async () => {
-    await loadPage();
-
-    expect(await getHeadingText()).toEqual('Matches');
-
-    // On the 87th row of matches (index 86) click on the second team
-    // (index 1) which resolves to team 5254 in semi final 2 match 3.
-    await element
-      .all(by.css('button.match-item'))
-      .get(86 * 6 + 1)
-      .click();
-
-    expect(await getHeadingText()).toEqual('Team Selection');
-    expect(await getValueOfInputById('match_number')).toEqual('3');
-    expect(await getValueOfInputById('team_number')).toEqual('5254');
-    expect(await getValueOfInputById('set_number')).toEqual('2');
-    expect(await getValueOfInputById('comp_level')).toEqual('3: sf');
-  });
-
-  it('should: error on unknown match.', async () => {
-    await loadPage();
-
-    await element(by.cssContainingText('.nav-link', 'Data Entry')).click();
-
-    // Pick a match that doesn't exist in the 2016nytr match list.
-    await setTextboxByIdTo('match_number', '3');
-    await setTextboxByIdTo('team_number', '971');
-
-    // Click Next until we get to the submit screen.
-    for (let i = 0; i < 5; i++) {
-      await element(by.buttonText('Next')).click();
-    }
-    expect(await getHeadingText()).toEqual('Review and Submit');
-
-    // Attempt to submit and validate the error.
-    await element(by.buttonText('Submit')).click();
-    expect(await getErrorMessage()).toContain(
-      'Failed to find team 971 in match 3 in the schedule.'
-    );
-  });
-
-  // Make sure that each page on the Entry tab has both "Next" and "Back"
-  // buttons. The only screens exempted from this are the first page and the
-  // last page.
-  it('should: have forwards and backwards buttons.', async () => {
-    await loadPage();
-
-    await element(by.cssContainingText('.nav-link', 'Data Entry')).click();
-
-    const expectedOrder = [
-      'Team Selection',
-      'Auto',
-      'TeleOp',
-      'Climb',
-      'Other',
-      'Review and Submit',
-    ];
-
-    // Go forward through the screens.
-    for (let i = 0; i < expectedOrder.length; i++) {
-      expect(await getHeadingText()).toEqual(expectedOrder[i]);
-      if (i != expectedOrder.length - 1) {
-        await element(by.buttonText('Next')).click();
-      }
-    }
-
-    // Go backwards through the screens.
-    for (let i = 0; i < expectedOrder.length; i++) {
-      expect(await getHeadingText()).toEqual(
-        expectedOrder[expectedOrder.length - i - 1]
-      );
-      if (i != expectedOrder.length - 1) {
-        await element(by.buttonText('Back')).click();
-      }
-    }
-  });
-
-  it('should: review and submit correct data.', async () => {
-    await loadPage();
-
-    await element(by.cssContainingText('.nav-link', 'Data Entry')).click();
-
-    // Submit scouting data for a random team that attended 2016nytr.
-    expect(await getHeadingText()).toEqual('Team Selection');
-    await setTextboxByIdTo('match_number', '2');
-    await setTextboxByIdTo('team_number', '5254');
-    await setTextboxByIdTo('set_number', '42');
-    await element(by.cssContainingText('option', 'Semi Finals')).click();
-    await element(by.buttonText('Next')).click();
-
-    expect(await getHeadingText()).toEqual('Auto');
-    await element(by.id('quadrant3')).click();
-    await element(by.buttonText('Next')).click();
-
-    expect(await getHeadingText()).toEqual('TeleOp');
-    await element(by.buttonText('Next')).click();
-
-    expect(await getHeadingText()).toEqual('Climb');
-    await element(by.id('high')).click();
-    await element(by.buttonText('Next')).click();
-
-    expect(await getHeadingText()).toEqual('Other');
-    await adjustNthSliderBy(0, 3);
-    await adjustNthSliderBy(1, 1);
-    await element(by.id('no_show')).click();
-    await element(by.id('mechanically_broke')).click();
-    await setTextboxByIdTo('comment', 'A very useful comment here.');
-    await element(by.buttonText('Next')).click();
-
-    expect(await getHeadingText()).toEqual('Review and Submit');
-    expect(await getErrorMessage()).toEqual('');
-
-    // Validate Team Selection.
-    await expectReviewFieldToBe('Match number', '2');
-    await expectReviewFieldToBe('Team number', '5254');
-    await expectReviewFieldToBe('SetNumber', '42');
-    await expectReviewFieldToBe('Comp Level', 'Semi Finals');
-
-    // Validate Auto.
-    await expectNthReviewFieldToBe('Upper Shots Made', 0, '0');
-    await expectNthReviewFieldToBe('Lower Shots Made', 0, '0');
-    await expectNthReviewFieldToBe('Missed Shots', 0, '0');
-    await expectReviewFieldToBe('Quadrant', '3');
-
-    // Validate TeleOp.
-    await expectNthReviewFieldToBe('Upper Shots Made', 1, '0');
-    await expectNthReviewFieldToBe('Lower Shots Made', 1, '0');
-    await expectNthReviewFieldToBe('Missed Shots', 1, '0');
-
-    // Validate Climb.
-    await expectReviewFieldToBe('Climb Level', 'High');
-
-    // Validate Other.
-    await expectReviewFieldToBe('Defense Played On Rating', '3');
-    await expectReviewFieldToBe('Defense Played Rating', '1');
-    await expectReviewFieldToBe('No show', 'true');
-    await expectReviewFieldToBe('Never moved', 'false');
-    await expectReviewFieldToBe('Battery died', 'false');
-    await expectReviewFieldToBe('Broke (mechanically)', 'true');
-    await expectReviewFieldToBe('Comments', 'A very useful comment here.');
-
-    await element(by.buttonText('Submit')).click();
-    await browser.wait(
-      EC.textToBePresentInElement(element(by.css('.header')), 'Success')
-    );
-
-    // TODO(phil): Make sure the data made its way to the database correctly.
-  });
-
-  it('should: load all images successfully.', async () => {
-    await loadPage();
-
-    await element(by.cssContainingText('.nav-link', 'Data Entry')).click();
-
-    // Get to the Auto display with the field pictures.
-    expect(await getHeadingText()).toEqual('Team Selection');
-    await element(by.buttonText('Next')).click();
-    expect(await getHeadingText()).toEqual('Auto');
-
-    // We expect 2 fully loaded images for each of the orientations.
-    // 2 images for the original orientation and 2 images for the flipped orientation.
-    for (let i = 0; i < 2; i++) {
-      browser
-        .executeAsyncScript(function (callback) {
-          let images = document.getElementsByTagName('img');
-          let numLoaded = 0;
-          for (let i = 0; i < images.length; i += 1) {
-            if (images[i].naturalWidth > 0) {
-              numLoaded += 1;
-            }
-          }
-          callback(numLoaded);
-        })
-        .then(function (numLoaded) {
-          expect(numLoaded).toBe(2);
-        });
-
-      await element(by.buttonText('Flip')).click();
-    }
-  });
-
-  it('should: submit note scouting for multiple teams', async () => {
-    // Navigate to Notes Page.
-    await loadPage();
-    await element(by.cssContainingText('.nav-link', 'Notes')).click();
-    expect(await getHeadingText()).toEqual('Notes');
-
-    // Add first team.
-    await setTextboxByIdTo('team_number_notes', '1234');
-    await element(by.buttonText('Select')).click();
-
-    // Add note and select keyword for first team.
-    expect(await element(by.id('team-key-1')).getText()).toEqual('1234');
-    await element(by.id('text-input-1')).sendKeys('Good Driving');
-    await element(by.id('good_driving_0')).click();
-
-    // Navigate to add team selection and add another team.
-    await element(by.id('add-team-button')).click();
-    await setTextboxByIdTo('team_number_notes', '1235');
-    await element(by.buttonText('Select')).click();
-
-    // Add note and select keyword for second team.
-    expect(await element(by.id('team-key-2')).getText()).toEqual('1235');
-    await element(by.id('text-input-2')).sendKeys('Bad Driving');
-    await element(by.id('bad_driving_1')).click();
-
-    // Submit Notes.
-    await element(by.buttonText('Submit')).click();
-    expect(await element(by.id('team_number_label')).getText()).toEqual(
-      'Team Number'
-    );
-  });
-
-  it('should: switch note text boxes with keyboard shortcuts', async () => {
-    // Navigate to Notes Page.
-    await loadPage();
-    await element(by.cssContainingText('.nav-link', 'Notes')).click();
-    expect(await getHeadingText()).toEqual('Notes');
-
-    // Add first team.
-    await setTextboxByIdTo('team_number_notes', '1234');
-    await element(by.buttonText('Select')).click();
-
-    // Add second team.
-    await element(by.id('add-team-button')).click();
-    await setTextboxByIdTo('team_number_notes', '1235');
-    await element(by.buttonText('Select')).click();
-
-    // Add third team.
-    await element(by.id('add-team-button')).click();
-    await setTextboxByIdTo('team_number_notes', '1236');
-    await element(by.buttonText('Select')).click();
-
-    for (let i = 1; i <= 3; i++) {
-      // Press Control + i
-      // Keyup Control for future actions.
-      browser
-        .actions()
-        .keyDown(protractor.Key.CONTROL)
-        .sendKeys(i.toString())
-        .keyUp(protractor.Key.CONTROL)
-        .perform();
-
-      // Expect text input to be focused.
-      expect(
-        await browser.driver.switchTo().activeElement().getAttribute('id')
-      ).toEqual('text-input-' + i);
-    }
-  });
-  it('should: submit driver ranking', async () => {
-    // Navigate to Driver Ranking Page.
-    await loadPage();
-    await element(by.cssContainingText('.nav-link', 'Driver Ranking')).click();
-    expect(await getHeadingText()).toEqual('Driver Ranking');
-
-    // Input match and team numbers.
-    await setTextboxByIdTo('match_number_selection', '11');
-    await setTextboxByIdTo('team_input_0', '123');
-    await setTextboxByIdTo('team_input_1', '456');
-    await setTextboxByIdTo('team_input_2', '789');
-    await element(by.id('select_button')).click();
-
-    // Verify match and team key input.
-    expect(await element(by.id('match_number_heading')).getText()).toEqual(
-      'Match #11'
-    );
-    expect(await element(by.id('team_key_label_0')).getText()).toEqual('123');
-    expect(await element(by.id('team_key_label_1')).getText()).toEqual('456');
-    expect(await element(by.id('team_key_label_2')).getText()).toEqual('789');
-
-    // Rank teams.
-    await element(by.id('up_button_2')).click();
-    await element(by.id('down_button_0')).click();
-
-    // Verify ranking change.
-    expect(await element(by.id('team_key_label_0')).getText()).toEqual('789');
-    expect(await element(by.id('team_key_label_1')).getText()).toEqual('123');
-    expect(await element(by.id('team_key_label_2')).getText()).toEqual('456');
-
-    // Submit.
-    await element(by.id('submit_button')).click();
-  });
-});
diff --git a/scouting/www/BUILD b/scouting/www/BUILD
index ee0659b..cbcecc3 100644
--- a/scouting/www/BUILD
+++ b/scouting/www/BUILD
@@ -1,134 +1,47 @@
-load("@npm//@bazel/typescript:index.bzl", "ts_library")
-load("//tools/build_rules:js.bzl", "rollup_bundle")
-load("@npm//@babel/cli:index.bzl", "babel")
+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(":defs.bzl", "assemble_static_files")
 
-ts_library(
+npm_link_all_packages(name = "node_modules")
+
+ng_application(
     name = "app",
-    srcs = glob([
-        "*.ts",
-    ]),
-    angular_assets = glob([
-        "*.ng.html",
-        "*.css",
-    ]),
-    compiler = "//tools:tsc_wrapped_with_angular",
-    target_compatible_with = ["@platforms//cpu:x86_64"],
-    use_angular_plugin = True,
-    visibility = ["//visibility:public"],
+    extra_srcs = [
+        "app/common.css",
+    ],
+    html_assets = [
+        "favicon.ico",
+    ],
     deps = [
-        "//scouting/www/driver_ranking",
-        "//scouting/www/entry",
-        "//scouting/www/import_match_list",
-        "//scouting/www/match_list",
-        "//scouting/www/notes",
-        "//scouting/www/shift_schedule",
-        "//scouting/www/view",
-        "@npm//@angular/animations",
-        "@npm//@angular/common",
-        "@npm//@angular/core",
-        "@npm//@angular/platform-browser",
+        "//:node_modules/@angular/animations",
+        "//scouting/www/driver_ranking:_lib",
+        "//scouting/www/entry:_lib",
+        "//scouting/www/import_match_list:_lib",
+        "//scouting/www/match_list:_lib",
+        "//scouting/www/notes:_lib",
+        "//scouting/www/shift_schedule:_lib",
+        "//scouting/www/view:_lib",
     ],
 )
 
-rollup_bundle(
-    name = "main_bundle",
-    entry_point = "main.ts",
-    deps = [
-        "app",
-    ],
-)
-
-babel(
-    name = "main_bundle_compiled",
-    args = [
-        "$(execpath :main_bundle.min.js)",
-        "--no-babelrc",
-        "--source-maps",
-        "--minified",
-        "--no-comments",
-        "--plugins=@angular/compiler-cli/linker/babel",
-        "--out-dir",
-        "$(@D)",
-    ],
-    data = [
-        ":main_bundle.min.js",
-        "@npm//@angular/compiler-cli",
-    ],
-    output_dir = True,
-)
-
-# The babel() rule above puts everything into a directory without telling bazel
-# what's in the directory. That makes it annoying to work with from other
-# rules. This genrule() here copies the one file in the directory we care about
-# so that other rules have an easier time using the file.
-genrule(
-    name = "main_bundle_file",
-    srcs = [":main_bundle_compiled"],
-    outs = ["main_bundle_file.js"],
-    cmd = "cp $(location :main_bundle_compiled)/main_bundle.min.js $(OUTS)",
-)
-
-py_binary(
-    name = "index_html_generator",
-    srcs = ["index_html_generator.py"],
-)
-
-genrule(
-    name = "generate_index_html",
-    srcs = [
-        "index.template.html",
-        "main_bundle_file.js",
-    ],
-    outs = ["index.html"],
-    cmd = " ".join([
-        "$(location :index_html_generator)",
-        "--template $(location index.template.html)",
-        "--bundle $(location main_bundle_file.js)",
-        "--output $(location index.html)",
-    ]),
-    tools = [
-        ":index_html_generator",
-    ],
-)
-
-# Create a copy of zone.js here so that we can have a predictable path to
-# source it from on the webserver.
-genrule(
-    name = "zonejs_copy",
-    srcs = [
-        "@npm//:node_modules/zone.js/dist/zone.min.js",
-    ],
-    outs = [
-        "npm/node_modules/zone.js/dist/zone.min.js",
-    ],
-    cmd = "cp $(SRCS) $(OUTS)",
-)
-
-genrule(
-    name = "field_pictures_copy",
-    srcs = ["//third_party/y2022/field:pictures"],
-    outs = [
-        "pictures/field/balls.jpeg",
-        "pictures/field/quadrants.jpeg",
-        "pictures/field/reversed_quadrants.jpeg",
-        "pictures/field/reversed_balls.jpeg",
-    ],
-    cmd = "cp $(SRCS) $(@D)/pictures/field/",
-)
-
-filegroup(
+assemble_static_files(
     name = "static_files",
-    srcs = [
-        "index.html",
-        ":field_pictures_copy",
-        ":main_bundle_file.js",
-        ":zonejs_copy",
+    app_files = ":app",
+    pictures = [
+        "//third_party/y2022/field:pictures",
     ],
+    replace_prefixes = {
+        "prod": "",
+        "dev": "",
+        "third_party/y2022": "pictures",
+    },
     visibility = ["//visibility:public"],
 )
 
-filegroup(
-    name = "common_css",
-    srcs = ["common.css"],
+copy_file(
+    name = "app_common_css",
+    src = "common.css",
+    out = "app/common.css",
     visibility = ["//scouting/www:__subpackages__"],
 )
diff --git a/scouting/www/app/app.module.ts b/scouting/www/app/app.module.ts
new file mode 100644
index 0000000..ead8b37
--- /dev/null
+++ b/scouting/www/app/app.module.ts
@@ -0,0 +1,30 @@
+import {NgModule} from '@angular/core';
+import {BrowserModule} from '@angular/platform-browser';
+import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
+
+import {App} from './app';
+import {EntryModule} from '../entry';
+import {ImportMatchListModule} from '../import_match_list';
+import {MatchListModule} from '../match_list';
+import {NotesModule} from '../notes';
+import {ShiftScheduleModule} from '../shift_schedule';
+import {ViewModule} from '../view';
+import {DriverRankingModule} from '../driver_ranking';
+
+@NgModule({
+  declarations: [App],
+  imports: [
+    BrowserModule,
+    BrowserAnimationsModule,
+    EntryModule,
+    NotesModule,
+    ImportMatchListModule,
+    MatchListModule,
+    ShiftScheduleModule,
+    DriverRankingModule,
+    ViewModule,
+  ],
+  exports: [App],
+  bootstrap: [App],
+})
+export class AppModule {}
diff --git a/scouting/www/app.ng.html b/scouting/www/app/app.ng.html
similarity index 100%
rename from scouting/www/app.ng.html
rename to scouting/www/app/app.ng.html
diff --git a/scouting/www/app.ts b/scouting/www/app/app.ts
similarity index 97%
rename from scouting/www/app.ts
rename to scouting/www/app/app.ts
index b26f815..7e81d84 100644
--- a/scouting/www/app.ts
+++ b/scouting/www/app/app.ts
@@ -22,7 +22,7 @@
 @Component({
   selector: 'my-app',
   templateUrl: './app.ng.html',
-  styleUrls: ['./common.css'],
+  styleUrls: ['../app/common.css'],
 })
 export class App {
   selectedTeamInMatch: TeamInMatch = {
diff --git a/scouting/www/app_module.ts b/scouting/www/app_module.ts
deleted file mode 100644
index 04d72b3..0000000
--- a/scouting/www/app_module.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import {NgModule} from '@angular/core';
-import {BrowserModule} from '@angular/platform-browser';
-import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
-
-import {App} from './app';
-import {EntryModule} from './entry/entry.module';
-import {ImportMatchListModule} from './import_match_list/import_match_list.module';
-import {MatchListModule} from './match_list/match_list.module';
-import {NotesModule} from './notes/notes.module';
-import {ShiftScheduleModule} from './shift_schedule/shift_schedule.module';
-import {ViewModule} from './view/view.module';
-import {DriverRankingModule} from './driver_ranking/driver_ranking.module';
-
-@NgModule({
-  declarations: [App],
-  imports: [
-    BrowserModule,
-    BrowserAnimationsModule,
-    EntryModule,
-    NotesModule,
-    ImportMatchListModule,
-    MatchListModule,
-    ShiftScheduleModule,
-    DriverRankingModule,
-    ViewModule,
-  ],
-  exports: [App],
-  bootstrap: [App],
-})
-export class AppModule {}
diff --git a/scouting/www/counter_button/BUILD b/scouting/www/counter_button/BUILD
index 1dbcdfc..d081f9d 100644
--- a/scouting/www/counter_button/BUILD
+++ b/scouting/www/counter_button/BUILD
@@ -1,21 +1,8 @@
-load("@npm//@bazel/typescript:index.bzl", "ts_library")
+load("@npm//:defs.bzl", "npm_link_all_packages")
+load("//tools/build_rules:js.bzl", "ng_pkg")
 
-ts_library(
+npm_link_all_packages(name = "node_modules")
+
+ng_pkg(
     name = "counter_button",
-    srcs = [
-        "counter_button.component.ts",
-        "counter_button.module.ts",
-    ],
-    angular_assets = [
-        "counter_button.component.css",
-        "counter_button.ng.html",
-    ],
-    compiler = "//tools:tsc_wrapped_with_angular",
-    target_compatible_with = ["@platforms//cpu:x86_64"],
-    use_angular_plugin = True,
-    visibility = ["//visibility:public"],
-    deps = [
-        "@npm//@angular/common",
-        "@npm//@angular/core",
-    ],
 )
diff --git a/scouting/www/defs.bzl b/scouting/www/defs.bzl
new file mode 100644
index 0000000..828f30a
--- /dev/null
+++ b/scouting/www/defs.bzl
@@ -0,0 +1,36 @@
+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.pictures + 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,
+        ),
+        "pictures": attr.label_list(
+            mandatory = True,
+        ),
+        "replace_prefixes": attr.string_dict(
+            mandatory = True,
+        ),
+    },
+    toolchains = ["@aspect_bazel_lib//lib:copy_to_directory_toolchain_type"],
+)
diff --git a/scouting/www/driver_ranking/BUILD b/scouting/www/driver_ranking/BUILD
index 10b6f99..a934ffe 100644
--- a/scouting/www/driver_ranking/BUILD
+++ b/scouting/www/driver_ranking/BUILD
@@ -1,26 +1,17 @@
-load("@npm//@bazel/typescript:index.bzl", "ts_library")
+load("@npm//:defs.bzl", "npm_link_all_packages")
+load("//tools/build_rules:js.bzl", "ng_pkg")
 
-ts_library(
+npm_link_all_packages(name = "node_modules")
+
+ng_pkg(
     name = "driver_ranking",
-    srcs = [
-        "driver_ranking.component.ts",
-        "driver_ranking.module.ts",
+    extra_srcs = [
+        "//scouting/www:app_common_css",
     ],
-    angular_assets = [
-        "driver_ranking.component.css",
-        "driver_ranking.ng.html",
-        "//scouting/www:common_css",
-    ],
-    compiler = "//tools:tsc_wrapped_with_angular",
-    target_compatible_with = ["@platforms//cpu:x86_64"],
-    use_angular_plugin = True,
-    visibility = ["//visibility:public"],
     deps = [
+        ":node_modules/@angular/forms",
         "//scouting/webserver/requests/messages:error_response_ts_fbs",
         "//scouting/webserver/requests/messages:submit_driver_ranking_ts_fbs",
         "@com_github_google_flatbuffers//ts:flatbuffers_ts",
-        "@npm//@angular/common",
-        "@npm//@angular/core",
-        "@npm//@angular/forms",
     ],
 )
diff --git a/scouting/www/driver_ranking/driver_ranking.component.ts b/scouting/www/driver_ranking/driver_ranking.component.ts
index b251938..f61d901 100644
--- a/scouting/www/driver_ranking/driver_ranking.component.ts
+++ b/scouting/www/driver_ranking/driver_ranking.component.ts
@@ -12,7 +12,7 @@
 @Component({
   selector: 'app-driver-ranking',
   templateUrl: './driver_ranking.ng.html',
-  styleUrls: ['../common.css', './driver_ranking.component.css'],
+  styleUrls: ['../app/common.css', './driver_ranking.component.css'],
 })
 export class DriverRankingComponent {
   section: Section = 'TeamSelection';
diff --git a/scouting/www/driver_ranking/package.json b/scouting/www/driver_ranking/package.json
index 06472cf..83dacf4 100644
--- a/scouting/www/driver_ranking/package.json
+++ b/scouting/www/driver_ranking/package.json
@@ -2,6 +2,6 @@
     "name": "@org_frc971/scouting/www/driver_ranking",
     "private": true,
     "dependencies": {
-        "@angular/forms": "15.0.1"
+        "@angular/forms": "15.1.5"
     }
 }
diff --git a/scouting/www/entry/BUILD b/scouting/www/entry/BUILD
index 17eed23..37b7fe6 100644
--- a/scouting/www/entry/BUILD
+++ b/scouting/www/entry/BUILD
@@ -1,28 +1,19 @@
-load("@npm//@bazel/typescript:index.bzl", "ts_library")
+load("@npm//:defs.bzl", "npm_link_all_packages")
+load("//tools/build_rules:js.bzl", "ng_pkg")
 
-ts_library(
+npm_link_all_packages(name = "node_modules")
+
+ng_pkg(
     name = "entry",
-    srcs = [
-        "entry.component.ts",
-        "entry.module.ts",
+    extra_srcs = [
+        "//scouting/www:app_common_css",
     ],
-    angular_assets = [
-        "entry.component.css",
-        "entry.ng.html",
-        "//scouting/www:common_css",
-    ],
-    compiler = "//tools:tsc_wrapped_with_angular",
-    target_compatible_with = ["@platforms//cpu:x86_64"],
-    use_angular_plugin = True,
-    visibility = ["//visibility:public"],
     deps = [
+        ":node_modules/@angular/forms",
         "//scouting/webserver/requests/messages:error_response_ts_fbs",
         "//scouting/webserver/requests/messages:submit_data_scouting_response_ts_fbs",
         "//scouting/webserver/requests/messages:submit_data_scouting_ts_fbs",
-        "//scouting/www/counter_button",
+        "//scouting/www/counter_button:_lib",
         "@com_github_google_flatbuffers//ts:flatbuffers_ts",
-        "@npm//@angular/common",
-        "@npm//@angular/core",
-        "@npm//@angular/forms",
     ],
 )
diff --git a/scouting/www/entry/entry.component.ts b/scouting/www/entry/entry.component.ts
index 01c9fff..6209669 100644
--- a/scouting/www/entry/entry.component.ts
+++ b/scouting/www/entry/entry.component.ts
@@ -58,7 +58,7 @@
 @Component({
   selector: 'app-entry',
   templateUrl: './entry.ng.html',
-  styleUrls: ['../common.css', './entry.component.css'],
+  styleUrls: ['../app/common.css', './entry.component.css'],
 })
 export class EntryComponent {
   // Re-export the type here so that we can use it in the `[value]` attribute
diff --git a/scouting/www/entry/entry.module.ts b/scouting/www/entry/entry.module.ts
index 3322c01..a2aa7bb 100644
--- a/scouting/www/entry/entry.module.ts
+++ b/scouting/www/entry/entry.module.ts
@@ -2,7 +2,7 @@
 import {CommonModule} from '@angular/common';
 import {FormsModule} from '@angular/forms';
 
-import {CounterButtonModule} from '../counter_button/counter_button.module';
+import {CounterButtonModule} from '@org_frc971/scouting/www/counter_button';
 import {EntryComponent} from './entry.component';
 
 import {ClimbLevel} from '../../webserver/requests/messages/submit_data_scouting_generated';
diff --git a/scouting/www/entry/package.json b/scouting/www/entry/package.json
index 5ecf57a..4c05778 100644
--- a/scouting/www/entry/package.json
+++ b/scouting/www/entry/package.json
@@ -3,6 +3,6 @@
     "private": true,
     "dependencies": {
         "@org_frc971/scouting/www/counter_button": "workspace:*",
-        "@angular/forms": "15.0.1"
+        "@angular/forms": "15.1.5"
     }
 }
diff --git a/scouting/www/favicon.ico b/scouting/www/favicon.ico
new file mode 100644
index 0000000..2bf69ea
--- /dev/null
+++ b/scouting/www/favicon.ico
Binary files differ
diff --git a/scouting/www/import_match_list/BUILD b/scouting/www/import_match_list/BUILD
index 9e40794..bc1d5d5 100644
--- a/scouting/www/import_match_list/BUILD
+++ b/scouting/www/import_match_list/BUILD
@@ -1,27 +1,18 @@
-load("@npm//@bazel/typescript:index.bzl", "ts_library")
+load("@npm//:defs.bzl", "npm_link_all_packages")
+load("//tools/build_rules:js.bzl", "ng_pkg")
 
-ts_library(
+npm_link_all_packages(name = "node_modules")
+
+ng_pkg(
     name = "import_match_list",
-    srcs = [
-        "import_match_list.component.ts",
-        "import_match_list.module.ts",
+    extra_srcs = [
+        "//scouting/www:app_common_css",
     ],
-    angular_assets = [
-        "import_match_list.component.css",
-        "import_match_list.ng.html",
-        "//scouting/www:common_css",
-    ],
-    compiler = "//tools:tsc_wrapped_with_angular",
-    target_compatible_with = ["@platforms//cpu:x86_64"],
-    use_angular_plugin = True,
-    visibility = ["//visibility:public"],
     deps = [
+        ":node_modules/@angular/forms",
         "//scouting/webserver/requests/messages:error_response_ts_fbs",
         "//scouting/webserver/requests/messages:refresh_match_list_response_ts_fbs",
         "//scouting/webserver/requests/messages:refresh_match_list_ts_fbs",
         "@com_github_google_flatbuffers//ts:flatbuffers_ts",
-        "@npm//@angular/common",
-        "@npm//@angular/core",
-        "@npm//@angular/forms",
     ],
 )
diff --git a/scouting/www/import_match_list/import_match_list.component.ts b/scouting/www/import_match_list/import_match_list.component.ts
index 0e292ae..526a636 100644
--- a/scouting/www/import_match_list/import_match_list.component.ts
+++ b/scouting/www/import_match_list/import_match_list.component.ts
@@ -8,7 +8,7 @@
 @Component({
   selector: 'app-import-match-list',
   templateUrl: './import_match_list.ng.html',
-  styleUrls: ['../common.css', './import_match_list.component.css'],
+  styleUrls: ['../app/common.css', './import_match_list.component.css'],
 })
 export class ImportMatchListComponent {
   year: number = new Date().getFullYear();
diff --git a/scouting/www/import_match_list/package.json b/scouting/www/import_match_list/package.json
index d80b0dc..05aa790 100644
--- a/scouting/www/import_match_list/package.json
+++ b/scouting/www/import_match_list/package.json
@@ -2,6 +2,6 @@
     "name": "@org_frc971/scouting/www/import_match_list",
     "private": true,
     "dependencies": {
-        "@angular/forms": "15.0.1"
+        "@angular/forms": "15.1.5"
     }
 }
diff --git a/scouting/www/index.template.html b/scouting/www/index.html
similarity index 74%
rename from scouting/www/index.template.html
rename to scouting/www/index.html
index 8fd0d7c..208141a 100644
--- a/scouting/www/index.template.html
+++ b/scouting/www/index.html
@@ -1,10 +1,9 @@
 <!DOCTYPE html>
-<html>
+<html lang="en">
   <head>
+    <meta charset="utf-8" />
     <title>FRC971 Scouting Application</title>
     <meta name="viewport" content="width=device-width, initial-scale=1" />
-    <base href="/" />
-    <script src="./npm/node_modules/zone.js/dist/zone.min.js"></script>
     <link
       href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css"
       rel="stylesheet"
@@ -19,7 +18,5 @@
   </head>
   <body>
     <my-app></my-app>
-    <!-- The path here is auto-generated to be /sha256/<checksum>/main_bundle_file.js. -->
-    <script src="{MAIN_BUNDLE_FILE}"></script>
   </body>
 </html>
diff --git a/scouting/www/index_html_generator.py b/scouting/www/index_html_generator.py
deleted file mode 100644
index bc0e63d..0000000
--- a/scouting/www/index_html_generator.py
+++ /dev/null
@@ -1,30 +0,0 @@
-"""Generates index.html with the right checksum for main_bundle_file.js filled in."""
-
-import argparse
-import hashlib
-import sys
-from pathlib import Path
-
-
-def compute_sha256(filepath):
-    return hashlib.sha256(filepath.read_bytes()).hexdigest()
-
-
-def main(argv):
-    parser = argparse.ArgumentParser()
-    parser.add_argument("--template", type=str)
-    parser.add_argument("--bundle", type=str)
-    parser.add_argument("--output", type=str)
-    args = parser.parse_args(argv[1:])
-
-    template = Path(args.template).read_text()
-    bundle_path = Path(args.bundle)
-    bundle_sha256 = compute_sha256(bundle_path)
-
-    output = template.format(
-        MAIN_BUNDLE_FILE=f"/sha256/{bundle_sha256}/{bundle_path.name}", )
-    Path(args.output).write_text(output)
-
-
-if __name__ == "__main__":
-    sys.exit(main(sys.argv))
diff --git a/scouting/www/main.ts b/scouting/www/main.ts
index e1a4ab5..1f8eb00 100644
--- a/scouting/www/main.ts
+++ b/scouting/www/main.ts
@@ -1,4 +1,4 @@
 import {platformBrowser} from '@angular/platform-browser';
-import {AppModule} from './app_module';
+import {AppModule} from './app/app.module';
 
 platformBrowser().bootstrapModule(AppModule);
diff --git a/scouting/www/match_list/BUILD b/scouting/www/match_list/BUILD
index 10c0a22..c713dda 100644
--- a/scouting/www/match_list/BUILD
+++ b/scouting/www/match_list/BUILD
@@ -1,28 +1,19 @@
-load("@npm//@bazel/typescript:index.bzl", "ts_library")
+load("@npm//:defs.bzl", "npm_link_all_packages")
+load("//tools/build_rules:js.bzl", "ng_pkg")
 
-ts_library(
+npm_link_all_packages(name = "node_modules")
+
+ng_pkg(
     name = "match_list",
-    srcs = [
-        "match_list.component.ts",
-        "match_list.module.ts",
+    extra_srcs = [
+        "//scouting/www:app_common_css",
     ],
-    angular_assets = [
-        "match_list.component.css",
-        "match_list.ng.html",
-        "//scouting/www:common_css",
-    ],
-    compiler = "//tools:tsc_wrapped_with_angular",
-    target_compatible_with = ["@platforms//cpu:x86_64"],
-    use_angular_plugin = True,
-    visibility = ["//visibility:public"],
     deps = [
+        ":node_modules/@angular/forms",
         "//scouting/webserver/requests/messages:error_response_ts_fbs",
         "//scouting/webserver/requests/messages:request_all_matches_response_ts_fbs",
         "//scouting/webserver/requests/messages:request_all_matches_ts_fbs",
-        "//scouting/www/rpc",
+        "//scouting/www/rpc:_lib",
         "@com_github_google_flatbuffers//ts:flatbuffers_ts",
-        "@npm//@angular/common",
-        "@npm//@angular/core",
-        "@npm//@angular/forms",
     ],
 )
diff --git a/scouting/www/match_list/match_list.component.ts b/scouting/www/match_list/match_list.component.ts
index c50cffd..eb5284f 100644
--- a/scouting/www/match_list/match_list.component.ts
+++ b/scouting/www/match_list/match_list.component.ts
@@ -7,7 +7,7 @@
   RequestAllMatchesResponse,
 } from '../../webserver/requests/messages/request_all_matches_response_generated';
 
-import {MatchListRequestor} from '../rpc/match_list_requestor';
+import {MatchListRequestor} from '@org_frc971/scouting/www/rpc';
 
 type TeamInMatch = {
   teamNumber: number;
@@ -19,7 +19,7 @@
 @Component({
   selector: 'app-match-list',
   templateUrl: './match_list.ng.html',
-  styleUrls: ['../common.css', './match_list.component.css'],
+  styleUrls: ['../app/common.css', './match_list.component.css'],
 })
 export class MatchListComponent implements OnInit {
   @Output() selectedTeamEvent = new EventEmitter<TeamInMatch>();
diff --git a/scouting/www/match_list/package.json b/scouting/www/match_list/package.json
index 3a02094..284e77b 100644
--- a/scouting/www/match_list/package.json
+++ b/scouting/www/match_list/package.json
@@ -3,6 +3,6 @@
     "private": true,
     "dependencies": {
         "@org_frc971/scouting/www/rpc": "workspace:*",
-        "@angular/forms": "15.0.1"
+        "@angular/forms": "15.1.5"
     }
 }
diff --git a/scouting/www/notes/BUILD b/scouting/www/notes/BUILD
index 39a97ef..64e4ae7 100644
--- a/scouting/www/notes/BUILD
+++ b/scouting/www/notes/BUILD
@@ -1,29 +1,20 @@
-load("@npm//@bazel/typescript:index.bzl", "ts_library")
+load("@npm//:defs.bzl", "npm_link_all_packages")
+load("//tools/build_rules:js.bzl", "ng_pkg")
 
-ts_library(
+npm_link_all_packages(name = "node_modules")
+
+ng_pkg(
     name = "notes",
-    srcs = [
-        "notes.component.ts",
-        "notes.module.ts",
+    extra_srcs = [
+        "//scouting/www:app_common_css",
     ],
-    angular_assets = [
-        "notes.component.css",
-        "notes.ng.html",
-        "//scouting/www:common_css",
-    ],
-    compiler = "//tools:tsc_wrapped_with_angular",
-    target_compatible_with = ["@platforms//cpu:x86_64"],
-    use_angular_plugin = True,
-    visibility = ["//visibility:public"],
     deps = [
+        ":node_modules/@angular/forms",
         "//scouting/webserver/requests/messages:error_response_ts_fbs",
         "//scouting/webserver/requests/messages:request_notes_for_team_response_ts_fbs",
         "//scouting/webserver/requests/messages:request_notes_for_team_ts_fbs",
         "//scouting/webserver/requests/messages:submit_notes_response_ts_fbs",
         "//scouting/webserver/requests/messages:submit_notes_ts_fbs",
         "@com_github_google_flatbuffers//ts:flatbuffers_ts",
-        "@npm//@angular/common",
-        "@npm//@angular/core",
-        "@npm//@angular/forms",
     ],
 )
diff --git a/scouting/www/notes/notes.component.ts b/scouting/www/notes/notes.component.ts
index 177ad44..62b4990 100644
--- a/scouting/www/notes/notes.component.ts
+++ b/scouting/www/notes/notes.component.ts
@@ -64,7 +64,7 @@
 @Component({
   selector: 'frc971-notes',
   templateUrl: './notes.ng.html',
-  styleUrls: ['../common.css', './notes.component.css'],
+  styleUrls: ['../app/common.css', './notes.component.css'],
 })
 export class Notes {
   // Re-export KEYWORD_CHECKBOX_LABELS so that we can
diff --git a/scouting/www/notes/package.json b/scouting/www/notes/package.json
index 8cbeb94..c5c6afe 100644
--- a/scouting/www/notes/package.json
+++ b/scouting/www/notes/package.json
@@ -2,6 +2,6 @@
     "name": "@org_frc971/scouting/www/notes",
     "private": true,
     "dependencies": {
-        "@angular/forms": "15.0.1"
+        "@angular/forms": "15.1.5"
     }
 }
diff --git a/scouting/www/polyfills.ts b/scouting/www/polyfills.ts
new file mode 100644
index 0000000..e4555ed
--- /dev/null
+++ b/scouting/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/scouting/www/rpc/BUILD b/scouting/www/rpc/BUILD
index 954b3fb..427b493 100644
--- a/scouting/www/rpc/BUILD
+++ b/scouting/www/rpc/BUILD
@@ -1,15 +1,14 @@
-load("@npm//@bazel/typescript:index.bzl", "ts_library")
+load("@npm//:defs.bzl", "npm_link_all_packages")
+load("//tools/build_rules:js.bzl", "ng_pkg")
 
-ts_library(
+npm_link_all_packages(name = "node_modules")
+
+ng_pkg(
     name = "rpc",
-    srcs = [
-        "match_list_requestor.ts",
-        "view_data_requestor.ts",
+    extra_srcs = [
+        "public-api.ts",
     ],
-    compiler = "//tools:tsc_wrapped_with_angular",
-    target_compatible_with = ["@platforms//cpu:x86_64"],
-    use_angular_plugin = True,
-    visibility = ["//visibility:public"],
+    generate_public_api = False,
     deps = [
         "//scouting/webserver/requests/messages:error_response_ts_fbs",
         "//scouting/webserver/requests/messages:request_all_driver_rankings_response_ts_fbs",
@@ -21,6 +20,5 @@
         "//scouting/webserver/requests/messages:request_data_scouting_response_ts_fbs",
         "//scouting/webserver/requests/messages:request_data_scouting_ts_fbs",
         "@com_github_google_flatbuffers//ts:flatbuffers_ts",
-        "@npm//@angular/core",
     ],
 )
diff --git a/scouting/www/rpc/public-api.ts b/scouting/www/rpc/public-api.ts
new file mode 100644
index 0000000..fe61453
--- /dev/null
+++ b/scouting/www/rpc/public-api.ts
@@ -0,0 +1,2 @@
+export * from './match_list_requestor';
+export * from './view_data_requestor';
diff --git a/scouting/www/shift_schedule/BUILD b/scouting/www/shift_schedule/BUILD
index 8fe99e4..3afb557 100644
--- a/scouting/www/shift_schedule/BUILD
+++ b/scouting/www/shift_schedule/BUILD
@@ -1,27 +1,18 @@
-load("@npm//@bazel/typescript:index.bzl", "ts_library")
+load("@npm//:defs.bzl", "npm_link_all_packages")
+load("//tools/build_rules:js.bzl", "ng_pkg")
 
-ts_library(
+npm_link_all_packages(name = "node_modules")
+
+ng_pkg(
     name = "shift_schedule",
-    srcs = [
-        "shift_schedule.component.ts",
-        "shift_schedule.module.ts",
+    extra_srcs = [
+        "//scouting/www:app_common_css",
     ],
-    angular_assets = [
-        "shift_schedule.component.css",
-        "shift_schedule.ng.html",
-        "//scouting/www:common_css",
-    ],
-    compiler = "//tools:tsc_wrapped_with_angular",
-    target_compatible_with = ["@platforms//cpu:x86_64"],
-    use_angular_plugin = True,
-    visibility = ["//visibility:public"],
     deps = [
+        ":node_modules/@angular/forms",
         "//scouting/webserver/requests/messages:error_response_ts_fbs",
         "//scouting/webserver/requests/messages:request_all_matches_response_ts_fbs",
         "//scouting/webserver/requests/messages:request_all_matches_ts_fbs",
         "@com_github_google_flatbuffers//ts:flatbuffers_ts",
-        "@npm//@angular/common",
-        "@npm//@angular/core",
-        "@npm//@angular/forms",
     ],
 )
diff --git a/scouting/www/shift_schedule/package.json b/scouting/www/shift_schedule/package.json
index 1d71623..1270235 100644
--- a/scouting/www/shift_schedule/package.json
+++ b/scouting/www/shift_schedule/package.json
@@ -2,6 +2,6 @@
     "name": "@org_frc971/scouting/www/shift_schedule",
     "private": true,
     "dependencies": {
-        "@angular/forms": "15.0.1"
+        "@angular/forms": "15.1.5"
     }
 }
diff --git a/scouting/www/shift_schedule/shift_schedule.component.ts b/scouting/www/shift_schedule/shift_schedule.component.ts
index 074eb97..de0b2e1 100644
--- a/scouting/www/shift_schedule/shift_schedule.component.ts
+++ b/scouting/www/shift_schedule/shift_schedule.component.ts
@@ -5,7 +5,7 @@
 @Component({
   selector: 'shift-schedule',
   templateUrl: './shift_schedule.ng.html',
-  styleUrls: ['../common.css', './shift_schedule.component.css'],
+  styleUrls: ['../app/common.css', './shift_schedule.component.css'],
 })
 export class ShiftsComponent {
   progressMessage: string = '';
diff --git a/scouting/www/view/BUILD b/scouting/www/view/BUILD
index b9b6030..168feb6 100644
--- a/scouting/www/view/BUILD
+++ b/scouting/www/view/BUILD
@@ -1,21 +1,15 @@
-load("@npm//@bazel/typescript:index.bzl", "ts_library")
+load("@npm//:defs.bzl", "npm_link_all_packages")
+load("//tools/build_rules:js.bzl", "ng_pkg")
 
-ts_library(
+npm_link_all_packages(name = "node_modules")
+
+ng_pkg(
     name = "view",
-    srcs = [
-        "view.component.ts",
-        "view.module.ts",
+    extra_srcs = [
+        "//scouting/www:app_common_css",
     ],
-    angular_assets = [
-        "view.component.css",
-        "view.ng.html",
-        "//scouting/www:common_css",
-    ],
-    compiler = "//tools:tsc_wrapped_with_angular",
-    target_compatible_with = ["@platforms//cpu:x86_64"],
-    use_angular_plugin = True,
-    visibility = ["//visibility:public"],
     deps = [
+        ":node_modules/@angular/forms",
         "//scouting/webserver/requests/messages:error_response_ts_fbs",
         "//scouting/webserver/requests/messages:request_all_driver_rankings_response_ts_fbs",
         "//scouting/webserver/requests/messages:request_all_driver_rankings_ts_fbs",
@@ -23,10 +17,7 @@
         "//scouting/webserver/requests/messages:request_all_notes_ts_fbs",
         "//scouting/webserver/requests/messages:request_data_scouting_response_ts_fbs",
         "//scouting/webserver/requests/messages:request_data_scouting_ts_fbs",
-        "//scouting/www/rpc",
+        "//scouting/www/rpc:_lib",
         "@com_github_google_flatbuffers//ts:flatbuffers_ts",
-        "@npm//@angular/common",
-        "@npm//@angular/core",
-        "@npm//@angular/forms",
     ],
 )
diff --git a/scouting/www/view/package.json b/scouting/www/view/package.json
new file mode 100644
index 0000000..ef94a11
--- /dev/null
+++ b/scouting/www/view/package.json
@@ -0,0 +1,7 @@
+{
+    "name": "@org_frc971/scouting/www/view",
+    "private": true,
+    "dependencies": {
+      "@angular/forms": "15.1.5"
+    }
+}
diff --git a/scouting/www/view/view.component.ts b/scouting/www/view/view.component.ts
index 593b0f4..c877faf 100644
--- a/scouting/www/view/view.component.ts
+++ b/scouting/www/view/view.component.ts
@@ -12,7 +12,7 @@
   RequestAllNotesResponse,
 } from '../../webserver/requests/messages/request_all_notes_response_generated';
 
-import {ViewDataRequestor} from '../rpc/view_data_requestor';
+import {ViewDataRequestor} from '../rpc';
 
 type Source = 'Notes' | 'Stats' | 'DriverRanking';
 
@@ -28,7 +28,7 @@
 @Component({
   selector: 'app-view',
   templateUrl: './view.ng.html',
-  styleUrls: ['../common.css', './view.component.css'],
+  styleUrls: ['../app/common.css', './view.component.css'],
 })
 export class ViewComponent {
   constructor(private readonly viewDataRequestor: ViewDataRequestor) {}