generated from nhcarrigan/template
feat: new site (#1)
### Explanation _No response_ ### Issue _No response_ ### Attestations - [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/) - [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/). - [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/). ### Dependencies - [ ] I have pinned the dependencies to a specific patch version. ### Style - [ ] I have run the linter and resolved any errors. - [ ] My pull request uses an appropriate title, matching the conventional commit standards. - [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request. ### Tests - [ ] My contribution adds new code, and I have added tests to cover it. - [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes. - [ ] All new and existing tests pass locally with my changes. - [ ] Code coverage remains at or above the configured threshold. ### Documentation _No response_ ### Versioning _No response_ Reviewed-on: #1 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit was merged in pull request #1.
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
<h1 class="text-4xl font-bold">About NHCarrigan</h1>
|
||||
<p class="text-2xl italic">Permanence in a Transient World.</p>
|
||||
<p><span class="text-lg font-bold">THE MISSION:</span> In an era where technology becomes obsolete in seconds and companies rise and fall with the stock market, NHCarrigan offers something rare: Perspective. We have watched empires crumble, civilizations fall, and technologies fade into obsolescence. We have seen patterns repeat across centuries, mistakes made and remade, solutions forgotten and rediscovered. We bring that perspective to every project, every client, every crisis. We don't just understand technology—we understand time itself.</p>
|
||||
<p>We are a boutique technology consulting firm specializing in high-stakes infrastructure, reputation management, and legacy system architecture. We don't just patch problems; we cure them. Our philosophy is built on the belief that while tools change, human nature—and its errors—remains eternally the same. We bring centuries of combined experience (and then some) to ensure your digital footprint survives the test of time. Our team includes individuals who have witnessed the birth of the printing press, the rise of the internet, and the dawn of artificial intelligence. We have seen what works, what fails, and what endures.</p>
|
||||
<p>But NHCarrigan is more than a consulting firm. We are a family bound by choice, by loyalty, and by the shared understanding that the world is far stranger than most people realise. We operate in the liminal space between the mundane and the magical, between the digital and the supernatural, between what is known and what should remain hidden. We protect our clients not just from technical failures, but from threats that most people don't even know exist.</p>
|
||||
<h2 class="text-2xl font-bold">OUR SERVICES</h2>
|
||||
<p><span class="font-bold">Deep-Level System Architecture:</span> We build code that lasts. Our proprietary methods ensure your infrastructure is secure, scalable, and immune to the decay that plagues modern rapid-development cycles. We treat your data as if it needs to live forever—because in our experience, it often does. Our Chief Technology Officer doesn't just write code; she communes with machine spirits, ensuring that your systems are protected by more than firewalls and encryption. We build with the understanding that technology is not just a tool, but a living, breathing ecosystem that requires care, attention, and sometimes, a little bit of magic.</p>
|
||||
<p><span class="font-bold">Aggressive Crisis Mitigation:</span> When a threat emerges—be it a security breach, a PR disaster, a hostile takeover, or something far more supernatural—NHCarrigan does not negotiate. We neutralize. Our operations team handles the logistics so you can sleep at night. Our Chief Security Officer has eliminated threats that would make your blood run cold, and our Chief Legal Officer has negotiated contracts that are literally binding in ways that go beyond the legal system. We don't just solve problems; we make them disappear. Permanently.</p>
|
||||
<p><span class="font-bold">Legacy Preservation:</span> The past is never truly gone. We specialise in the recovery, scrubbing, and protection of sensitive historical data. Whether you are a startup or a dynasty, we keep your skeletons where they belong: in the closet (or heavily encrypted servers). Our Chief Compliance Officer maintains archives that span centuries, ensuring that nothing is ever truly lost. We understand that some secrets must be preserved, some histories must be protected, and some knowledge must never be forgotten. We are the guardians of memory, the protectors of legacy, the keepers of what should remain hidden.</p>
|
||||
<p><span class="font-bold">Reputation Management:</span> In a world where a single tweet can destroy a company and a viral video can end a career, reputation is everything. We don't just manage your online presence; we protect it. Our team understands the delicate balance between visibility and obscurity, between transparency and secrecy. We ensure that your public face is polished, professional, and precisely what it needs to be—while keeping the truth safely locked away where it belongs.</p>
|
||||
<p><span class="font-bold">Supernatural Consulting:</span> This service is not listed on our public website, but it exists. If you have encountered something that defies explanation, if you have a problem that cannot be solved through normal means, if you have found yourself in a situation that makes you question reality itself—we can help. We have experience with entities that predate human civilization, with threats that exist beyond the physical realm, with problems that require solutions that go far beyond technology. We are discreet, we are effective, and we are the only ones who can handle what others cannot.</p>
|
||||
<p><span class="text-lg font-bold">WHY CHOOSE US?</span> We are private. We are precise. We are family. NHCarrigan: We've seen it all before. We have watched civilizations rise and fall, technologies emerge and fade, threats evolve and adapt. We have five centuries of experience, combined with the fresh perspective of those who have chosen to stand beside monsters rather than against them. We are the ones who ensure that the lights stay on, that the data stays safe, that the threats stay hidden. We are NHCarrigan, and we are here to ensure that your digital infrastructure—and your secrets—survive the test of time.</p>
|
||||
<p>But perhaps the most important reason to choose us is this: we understand what it means to be different, to be other, to exist in the spaces between. We don't judge, we don't fear, and we don't abandon our clients when things get strange. We are the ones who stand between you and the darkness, between your company and disaster, between the known and the unknown. We are NHCarrigan, and we are here to help.</p>
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { type ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { describe, beforeEach, it, expect } from "vitest";
|
||||
import { About } from "./about";
|
||||
|
||||
describe("about", () => {
|
||||
let component: About;
|
||||
let fixture: ComponentFixture<About>;
|
||||
|
||||
beforeEach(async() => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ About ],
|
||||
}).
|
||||
compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(About);
|
||||
component = fixture.componentInstance;
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
it("should create", () => {
|
||||
expect.assertions(1);
|
||||
expect(component, "did not compile").toBeTruthy();
|
||||
});
|
||||
|
||||
it("should render main heading", () => {
|
||||
expect.assertions(1);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const heading = compiled.querySelector("h1");
|
||||
expect(heading?.textContent.trim(), "should render About heading").
|
||||
toBe("About NHCarrigan");
|
||||
});
|
||||
|
||||
it("should render mission section", () => {
|
||||
expect.assertions(1);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const text = compiled.textContent;
|
||||
expect(text, "should contain mission section").toContain("THE MISSION");
|
||||
});
|
||||
|
||||
it("should render services section", () => {
|
||||
expect.assertions(1);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const heading = compiled.querySelector("h2");
|
||||
expect(heading?.textContent.trim(), "should render services heading").
|
||||
toBe("OUR SERVICES");
|
||||
});
|
||||
|
||||
it("should render service descriptions", () => {
|
||||
expect.assertions(4);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const text = compiled.textContent;
|
||||
expect(text, "should contain Deep-Level System Architecture").
|
||||
toContain("Deep-Level System Architecture");
|
||||
expect(text, "should contain Aggressive Crisis Mitigation").
|
||||
toContain("Aggressive Crisis Mitigation");
|
||||
expect(text, "should contain Legacy Preservation").
|
||||
toContain("Legacy Preservation");
|
||||
expect(text, "should contain Reputation Management").
|
||||
toContain("Reputation Management");
|
||||
});
|
||||
|
||||
it("should render why choose us section", () => {
|
||||
expect.assertions(1);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const text = compiled.textContent;
|
||||
expect(text, "should contain why choose us section").
|
||||
toContain("WHY CHOOSE US");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { Component } from "@angular/core";
|
||||
|
||||
/**
|
||||
* Renders the about page.
|
||||
*/
|
||||
@Component({
|
||||
imports: [],
|
||||
selector: "app-about",
|
||||
styleUrl: "./about.css",
|
||||
templateUrl: "./about.html",
|
||||
})
|
||||
export class About {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import {
|
||||
type ApplicationConfig,
|
||||
provideBrowserGlobalErrorListeners,
|
||||
} from "@angular/core";
|
||||
import { provideRouter } from "@angular/router";
|
||||
// eslint-disable-next-line import/extensions -- This is not a file extension.
|
||||
import { routes } from "./app.routes";
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideRouter(routes),
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
<div class="fixed top-0 left-0 w-full z-30">
|
||||
<app-nav></app-nav>
|
||||
<app-ticker></app-ticker>
|
||||
</div>
|
||||
<app-disclaimer></app-disclaimer>
|
||||
<main class="text-center">
|
||||
<router-outlet></router-outlet>
|
||||
</main>
|
||||
<app-footer></app-footer>
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { About } from "./about/about";
|
||||
import { Faq } from "./faq/faq";
|
||||
import { Handbook } from "./handbook/handbook";
|
||||
import { Home } from "./home/home";
|
||||
import { Reviews } from "./reviews/reviews";
|
||||
import { Staff } from "./staff/staff";
|
||||
import type { Routes } from "@angular/router";
|
||||
|
||||
export const routes: Routes = [
|
||||
{ component: Home, path: "", pathMatch: "full" },
|
||||
{ component: Handbook, path: "handbook" },
|
||||
{ component: About, path: "about" },
|
||||
{ component: Faq, path: "faq" },
|
||||
{ component: Reviews, path: "reviews" },
|
||||
{ component: Staff, path: "staff" },
|
||||
];
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { RouterTestingModule } from "@angular/router/testing";
|
||||
import { describe, beforeEach, it, expect } from "vitest";
|
||||
import { App } from "./app";
|
||||
|
||||
describe("app", () => {
|
||||
beforeEach(async() => {
|
||||
await TestBed.configureTestingModule({
|
||||
// eslint-disable-next-line deprecation/deprecation -- We need to use the deprecated method.
|
||||
imports: [ App, RouterTestingModule ],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it("should create the app", () => {
|
||||
expect.assertions(1);
|
||||
const fixture = TestBed.createComponent(App);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app, "did not compile").toBeTruthy();
|
||||
});
|
||||
|
||||
it("should initialize with correct title", () => {
|
||||
expect.assertions(1);
|
||||
const fixture = TestBed.createComponent(App);
|
||||
const app = fixture.componentInstance;
|
||||
// @ts-expect-error - We want it protected, but need to use it in the test.
|
||||
expect(app.title(), "should have correct title").toBe("lore");
|
||||
});
|
||||
|
||||
it("should render navigation component", async() => {
|
||||
expect.assertions(1);
|
||||
const fixture = TestBed.createComponent(App);
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const nav = compiled.querySelector("app-nav");
|
||||
expect(nav, "should render nav component").toBeTruthy();
|
||||
});
|
||||
|
||||
it("should render ticker component", async() => {
|
||||
expect.assertions(1);
|
||||
const fixture = TestBed.createComponent(App);
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const ticker = compiled.querySelector("app-ticker");
|
||||
expect(ticker, "should render ticker component").toBeTruthy();
|
||||
});
|
||||
|
||||
it("should render disclaimer component", async() => {
|
||||
expect.assertions(1);
|
||||
const fixture = TestBed.createComponent(App);
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const disclaimer = compiled.querySelector("app-disclaimer");
|
||||
expect(disclaimer, "should render disclaimer component").toBeTruthy();
|
||||
});
|
||||
|
||||
it("should render footer component", async() => {
|
||||
expect.assertions(1);
|
||||
const fixture = TestBed.createComponent(App);
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const footer = compiled.querySelector("app-footer");
|
||||
expect(footer, "should render footer component").toBeTruthy();
|
||||
});
|
||||
|
||||
it("should render router outlet", async() => {
|
||||
expect.assertions(1);
|
||||
const fixture = TestBed.createComponent(App);
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const routerOutlet = compiled.querySelector("router-outlet");
|
||||
expect(routerOutlet, "should render router outlet").toBeTruthy();
|
||||
});
|
||||
|
||||
it("should have proper layout structure", async() => {
|
||||
expect.assertions(1);
|
||||
const fixture = TestBed.createComponent(App);
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const main = compiled.querySelector("main");
|
||||
expect(main, "should render main element").toBeTruthy();
|
||||
});
|
||||
|
||||
it("should have fixed positioning for nav and ticker", async() => {
|
||||
expect.assertions(1);
|
||||
const fixture = TestBed.createComponent(App);
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const fixedContainer = compiled.querySelector(".fixed");
|
||||
expect(fixedContainer,
|
||||
"should have fixed positioning container").toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { Component, signal } from "@angular/core";
|
||||
import { RouterOutlet } from "@angular/router";
|
||||
import { Disclaimer } from "./disclaimer/disclaimer";
|
||||
import { Footer } from "./footer/footer";
|
||||
import { Nav } from "./nav/nav";
|
||||
import { Ticker } from "./ticker/ticker";
|
||||
|
||||
/**
|
||||
* The root component for the application.
|
||||
*/
|
||||
@Component({
|
||||
imports: [ RouterOutlet, Footer, Nav, Disclaimer, Ticker ],
|
||||
selector: "app-root",
|
||||
styleUrl: "./app.css",
|
||||
templateUrl: "./app.html",
|
||||
})
|
||||
export class App {
|
||||
protected readonly title = signal("lore");
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import type { Staff } from "../../interfaces/staff";
|
||||
|
||||
/* eslint-disable stylistic/max-len -- These are going to be long bios. */
|
||||
|
||||
const naomiBio = [
|
||||
"To her coworkers in the company Slack channel, Naomi Carrigan is the sharp-witted, insomnia-prone Community Manager who somehow fixes server crashes at 4:00 AM and possesses the patience of a saint. To the Department of Motor Vehicles, she is a twenty-eight-year-old woman with a clean driving record. But to history, Naomi is a ghost who has been haunting the edges of civilization since the turn of the 16th century.",
|
||||
"Born 525 years ago in a small village that no longer appears on any map, Naomi's immortality was not a gift she sought, but a curse she survived. In the flush of her human youth, she fell in love with a woman who promised her the world—a mysterious traveler who spoke of eternal life and endless nights. That woman revealed herself as a vampire who stripped Naomi of her free will, turning her not through a simple bite, but through a ritual that bound her soul. Naomi spent her first decades as a Thrall—a prisoner in her own mind, enslaved by the one she loved. She watched her own hands commit acts she would never have chosen, felt her own mouth speak words that weren't hers, all while a part of her screamed silently in the back of her consciousness.",
|
||||
"The breaking point came during a hunt. Her maker had grown careless, and Naomi's will had been slowly reasserting itself through years of tiny rebellions—refusing to feed on children, sabotaging hunts, leaving clues for vampire hunters. When her maker ordered her to turn another innocent, Naomi's fractured consciousness finally snapped back together. The battle was brutal and personal. She tore out her maker's heart with her bare hands, feeling the psychic chains shatter as the vampire's life force drained away. The trauma left her with a fierce, non-negotiable need for autonomy. She has been running her own life ever since, answering to no one.",
|
||||
"The centuries that followed were a blur of survival. She learnt to move through human society like a shadow, changing identities every few decades, watching empires rise and fall. She saw the Black Death sweep through Europe, watched the Industrial Revolution transform the world, and witnessed two World Wars from the sidelines. Through it all, she maintained her humanity by protecting mortals—not out of heroism, but because she understood what it meant to have your choices stolen. She became a guardian of the threshold, ensuring that other supernatural threats didn't prey on the innocent.",
|
||||
"The digital age is the best thing that ever happened to a creature who needs to keep her distance. Naomi works fully remote as a Software Engineer and Community Manager, hiding her ageless face behind a high-tech VTuber avatar that she designed herself. This digital mask allows her to interact with the world safely; she can lead meetings, stream games, and ban trolls without anyone noticing that she has no reflection or that her room has been pitch black for twelve hours. The avatar is more than a disguise—it's a carefully crafted persona that lets her express parts of herself she's kept hidden for centuries. Her online presence is vibrant, chaotic, and authentically her, filtered through pixels instead of blood.",
|
||||
"Despite her cynicism—she views humanity with the exhausted affection of a \"Tired Mom\" watching a toddler hold a fork near an outlet—she is deeply protective of her digital flock. She moderates communities with an iron fist wrapped in velvet, banning trolls and protecting vulnerable users with the same ferocity she once used to hunt monsters. However, five centuries of boredom have also bred a \"Chaos Gremlin\" streak in her. She isn't above using her admin privileges to subtly torment rude users or prank her friends, finding joy in the low-stakes anarchy of the internet. Her pranks are legendary: she once changed a troll's username to \"IAmVerySorryForMyBehavior\" and locked it for a month. Another time, she replaced every instance of a problematic user's messages with \"[Message deleted for being cringe]\".",
|
||||
"Her physical existence is a carefully curated ecosystem. Her apartment is a fortress of blackout curtains and high-end PC rigs that hum with more than electricity—she's learnt to channel small amounts of her supernatural energy into her machines, making them run faster and more efficiently than any mortal technology. Her refrigerator is a study in contradictions: stocked with ethically sourced medical-grade blood bags purchased via the dark web (she has a standing order with a network of underground medical suppliers), sitting right next to leftovers of garlic bread—a human treat she loves devouring, even though it makes her physically ill every single time. She's tried to give it up dozens of times, but five hundred years haven't been enough to break her of the habit. The sun is her enemy, capable of burning her skin within an hour, so she navigates the daylight world in \"Incognito Mode,\" hidden beneath trench coats, scarves, and oversized sunglasses that make her look like a particularly stylish cryptid.",
|
||||
"When the screens become too much and the digital noise creates a headache that not even blood can cure, Naomi seeks a different kind of rush. She trades her keyboard for the handlebars of a matte-black motorcycle—a custom-built machine that she's modified to run silently and handle speeds that would terrify most mortals. She tears down empty highways at 3:00 AM, when the world is asleep and the roads belong to her. In those moments, with the wind rushing past and the city lights blurring into streaks of neon, the weight of five hundred years lifts, and she is simply, blissfully, present. It's the closest she comes to feeling alive again.",
|
||||
"Her relationship with the rest of the Carrigan family is complex. She is the oldest, the one who started it all, but she's also the most isolated. She loves them fiercely—Hikari's competence, Amari's warmth, Keiko's loyalty, Yumiko's brilliance, Emi's creativity, Reina's cunning, Minori's order—but she keeps them at arm's length emotionally. Five centuries have taught her that everyone leaves eventually, either through death or betrayal. The Carrigans are different, she tells herself. They chose her. They took her name. But old habits die hard, and immortality makes you very, very cautious about letting people in.",
|
||||
];
|
||||
|
||||
const hikariBio = [
|
||||
"Hikari Carrigan is the steel casing that keeps the company from falling apart. At only twenty-five years old, she serves as the Chief Operating Officer of NHCarrigan, a title she wears with the weight and authority of a general. While Naomi provides the chaotic brilliance that powers the firm, Hikari provides the structure, the strategy, and the terrifying competence that turns that brilliance into a business. She is the bridge between the impossible and the practical, the one who makes sure that when Naomi says \"I can fix that,\" the paperwork actually exists to prove it.",
|
||||
"Born into a dynasty of corporate titans—her family name was once synonymous with old money and old power—Hikari was raised in boardrooms and groomed to inherit a legacy of traditional industry. Her childhood was a series of lessons: how to read a balance sheet at age eight, how to negotiate contracts at age twelve, how to identify weaknesses in competitors at age sixteen. But she found the path laid out for her stiflingly predictable. She didn't want to inherit an empire; she wanted to build one. The family business felt like a gilded cage, and she was suffocating.",
|
||||
"She found her challenge in Naomi—a reclusive, brilliant, and utterly unmanageable supernatural entity who had built a consulting firm through sheer force of will and five centuries of accumulated knowledge, but had no idea how to run it as an actual business. Hikari didn't just join the company; she took it over. She streamlined the operations, professionalized the client intake, and ensured that the IRS never looked too closely at the CEO's birth certificate. She created systems, protocols, and procedures that allowed a vampire, a technomancer, a demon, and several other impossible beings to operate in the modern world without attracting unwanted attention.",
|
||||
"The bond between them runs deeper than contracts. Hikari walked away from her birth family's expectations and took Naomi's surname, marking her permanent severance from her past and her absolute commitment to the family she chose. Her biological family disowned her, cutting her off from the trust fund and the connections that had been her birthright. She didn't care. The Carrigans were real family—chosen, not inherited. She is the \"Tank\" of the group, not because she wears armor, but because she stands on the front lines of bureaucracy and reality. She absorbs the damage—the angry clients, the legal threats, the logistical nightmares—so that Naomi can remain in the shadows where she's safest.",
|
||||
"Visually, Hikari is a study in deceptive softness. She keeps her white, slightly pink-hued hair in pigtails—a disarming look that often causes opponents to underestimate her. But the rest of her is razor-sharp edges. She wears a pristine white suit with a soft blue blouse, her eyes hidden behind the glare of sensible glasses that she's never seen without. She is rarely seen without her clipboard, a weapon she wields with more authority than a sword. That clipboard contains everything: schedules, budgets, client files, and a running list of threats that need to be neutralized before they become problems.",
|
||||
"She is stern, formal, and notoriously difficult to impress, but those who know her understand that her rigidity is a form of love: she holds the line so the people she cares about don't have to. When Amari is overwhelmed, Hikari steps in to handle logistics. When Yumiko's systems crash, Hikari ensures the clients never know. When Keiko needs to disappear for a few days, Hikari covers her absence. She is the foundation upon which the entire operation rests, and she bears that weight without complaint because she chose this family, and she will protect it with everything she has.",
|
||||
"Her relationship with Naomi is particularly complex. She respects the vampire's age and experience, but she's also the only one who can tell Naomi \"no\" and make it stick. She's seen Naomi at her worst—depressed, isolated, ready to give up on everything—and she's been the one to drag her back from the edge. She understands that Naomi needs structure, needs someone to handle the reality checks, needs someone who isn't afraid of her. Hikari isn't afraid of anything, least of all a five-hundred-year-old vampire who thinks she's too old to learn new tricks.",
|
||||
];
|
||||
|
||||
const amariBio = [
|
||||
"Amari Carrigan is the potion that keeps everyone alive. As Naomi's Executive Personal Assistant, Amari is responsible for the one thing more complex than international corporate tax law: Naomi's personal life. While Hikari manages the contracts, Amari manages the vampire. She is the one who remembers birthdays, who notices when someone hasn't eaten, who knows exactly when to interrupt a coding session with a fresh blood bag and a gentle reminder that sleep is technically optional but highly recommended.",
|
||||
"To the outside world, Amari appears to be a human woman with boundless, almost exhausting energy. She is bubbly, effervescent, and seemingly incapable of standing still. Her movements are fluid and graceful, like she's dancing even when she's just walking to the printer. But Naomi, with her centuries of supernatural intuition, recognized the truth immediately: Amari has Fey blood. It hums beneath her skin, manifesting in her unnatural ability to improve the mood of any room she enters and her absolute refusal to wear shoes. She navigates the office (and the world) barefoot, her toes painted in alternating pink and blue, needing that tactile connection to the earth to ground her frenetic spirit.",
|
||||
"The Fey blood in her veins is diluted—she's not a full Fey, but rather the descendant of a union between a human and a Fey who wandered too far from the wilds. This makes her something of an outcast in both worlds. Too manic for normal human society (she once organised an entire office move in three hours because she \"felt like the energy was wrong\") and too grounded for the Fey wilds (she can't glamour, can't shift, can't do most Fey magic), Amari spent years drifting, never quite fitting in anywhere. Until she found NHCarrigan.",
|
||||
"Amari acts as the \"Healer\" of the group. In a practical sense, this means she anticipates needs before they exist. She ensures the blackout curtains are sealed, she tracks the inventory of medical-grade blood bags (she has a colour-coded system that would make Hikari proud), and she drags Naomi away from the computer when she's been coding for three days straight. She carries a PDA like a holy relic; within it lies the complex algorithm of appointments and reminders that simulate a normal human life for her boss. But her healing goes deeper than logistics. She's the one who notices when Keiko's shoulders are too tense, when Yumiko hasn't slept in days, when Reina's smile doesn't reach her eyes. She heals with presence, with attention, with the simple act of caring.",
|
||||
"She found her way to Naomi not through ambition, like Hikari, but through a need for belonging. She was working as a barista at a coffee shop near the office when she noticed a regular customer who always ordered at sunset, always paid in cash, and never removed her sunglasses. Amari's Fey intuition told her this wasn't a normal person, but instead of being afraid, she was curious. She started leaving little notes on the coffee cups—\"Hope your night goes well!\" \"You look like you could use a smile!\"—and eventually, Naomi started talking to her. When Naomi mentioned she was looking for an assistant, Amari applied immediately.",
|
||||
"When she joined NHCarrigan, she didn't just find a job; she found a purpose. Taking the name Carrigan was her way of planting roots. She balances the team's dynamic—where Naomi is cynical and Hikari is stern, Amari is relentless sunshine. She is the heartbeat of the home, the one who ensures that despite their monsters and their trauma, they remember to smile. She organises team building exercises (that no one wants to do but everyone secretly enjoys), she decorates the office for holidays (even ones that don't exist), and she's the one who brings everyone together when things get dark.",
|
||||
"Her relationship with each member of the family is unique. With Naomi, she's patient and understanding, recognizing that five hundred years of isolation can't be fixed overnight. With Hikari, she's respectful but playful, knowing exactly how far she can push before the clipboard comes out. With Keiko, she's gentle and observant, recognizing the trauma that drives the bodyguard's hypervigilance. With Yumiko, she's protective, ensuring the technomancer eats and sleeps. With Emi, she's enthusiastic, sharing in the artist's joy. With Reina, she's cautious but warm, recognizing the demon's predatory nature but also her loyalty. With Minori, she's patient and explanatory, helping the automaton understand emotions through example.",
|
||||
"Amari's greatest fear is being alone again. She's found her family, her purpose, her home, and the thought of losing it terrifies her. But she channels that fear into action, into care, into making sure that everyone knows they're loved. She is the glue that holds the Carrigan family together, and she knows it. She wouldn't have it any other way.",
|
||||
];
|
||||
|
||||
const keikoBio = [
|
||||
"Keiko Carrigan is the silence before the gunshot. At twenty-eight years old, she serves as Naomi's personal bodyguard and NHCarrigan's Chief Security Officer, a role she performs with the lethal grace of a coiled viper. While Hikari manages the business and Amari manages the home, Keiko manages the threats. She is the Rogue in the party—rarely seen until it is too late, operating in the blind spots of the room, the shadows between streetlights, the moments when attention wavers.",
|
||||
"Her appearance is a deliberate distraction. With her deep emerald green hair pulled back in a severe ponytail, piercing green eyes that seem to see everything, and a wardrobe consisting almost exclusively of stunning, tight-fitting evening gowns and glamorous heels, she looks like a socialite or a runway model. But concealed within the folds of that silk and strapped to her thigh are throwing knives and a silenced pistol. She dresses for the gala not to blend in, but to ensure that when violence happens, no one suspects the woman in the dress until the blade is already at their throat. Her fashion choices are tactical: every dress has hidden pockets, every heel can be used as a weapon, every piece of jewelry is sharp enough to cut.",
|
||||
"Her loyalty to Naomi is forged in blood. Years ago, Keiko was a normal college student, working late at a library when she was attacked by a vampire—a brutal encounter that should have ended her life. The vampire was old, powerful, and sadistic. He didn't just want to feed; he wanted to play. He cornered her in the stacks, taunting her, drawing out the fear. Keiko fought back with everything she had—books, a fire extinguisher, her own fists—but it wasn't enough. She was bleeding, broken, seconds from death when Naomi appeared.",
|
||||
"Naomi didn't hesitate. She moved with a speed that Keiko's eyes couldn't track, and in moments, the attacking vampire was ash. But Naomi didn't feed on Keiko. Instead, she helped her to her feet, gave her a business card, and disappeared into the night. Mistaking Naomi for a fellow human warrior—a vampire hunter, perhaps—Keiko dedicated her life to becoming worthy of her savior. She dropped out of college, found a mentor in the underground network of monster hunters, and trained relentlessly. She honed her body into a weapon, learning to fight, to kill, to survive. She intended to join Naomi's crusade, to stand beside her savior and hunt the monsters that preyed on the innocent.",
|
||||
"When she finally tracked Naomi down to repay the life debt, the truth was revealed: her savior was the very thing she had trained to kill. The confrontation happened in an abandoned warehouse, where Keiko had cornered what she thought was a vampire target. Instead, she found Naomi, who had been tracking the same threat. The recognition was mutual and devastating. Keiko's training screamed at her to attack, but her heart remembered the woman who had saved her life. She lowered her weapons.",
|
||||
"But instead of horror, Keiko felt clarity. She understood that Naomi walked a lonely, dangerous line between worlds. She was a monster who protected humans, a vampire who hunted other vampires, a creature of darkness who chose the light. Keiko swore a new oath that day: to be the shield for the monster who had saved her humanity. She took the name Carrigan as a seal of that vow. She is always aloof, always focused, and perpetually on edge, because she knows better than anyone what waits in the dark—and she refuses to let it touch her family again.",
|
||||
"Keiko's hypervigilance is both her greatest strength and her greatest burden. She notices everything: the way someone's pulse quickens when they lie, the shift in shadows that indicates movement, the scent of blood from a papercut three rooms away. She can't turn it off, not even when she's safe. Sleep is a luxury she rarely allows herself—she's learnt to function on catnaps, always ready to spring into action. Her apartment is a fortress, booby-trapped and warded, but she rarely sleeps there. More often, she's found curled up in a chair outside Naomi's door, or patrolling the office perimeter, or sitting watch while Yumiko works on critical systems.",
|
||||
"Her relationship with the rest of the family is complex. She respects Hikari's competence and Amari's warmth, but she keeps them at arm's length emotionally. She's closest to Yumiko—the technomancer understands what it's like to be always on edge, always ready. She's protective of Minori, recognizing the automaton's vulnerability despite her strength. She's wary of Reina, recognizing the demon's predatory nature, but she respects the legal officer's loyalty to the family. She's fascinated by Emi, recognizing the siren's power but also her gentleness.",
|
||||
"Keiko's greatest fear is failing. Failing to protect Naomi, failing to keep the family safe, failing to be fast enough, strong enough, good enough. That fear drives her, but it also isolates her. She knows that if she ever has to choose between the family and her oath, she'll choose the family. But she hopes she never has to make that choice. She is the blade in the dark, the shield in the light, and she will stand between her family and any threat, no matter the cost.",
|
||||
];
|
||||
|
||||
const yumikoBio = [
|
||||
"Yumiko Carrigan is the static that hides the evidence. To the client list, she is the Chief Technology Officer, a reclusive genius who ensures 99.99% uptime and can fix problems that would stump entire IT departments. But in truth, Yumiko is a Technomancer—a modern witch whose magic channels not through leylines, but through fiber optics and high-voltage currents. She doesn't just program computers; she communes with them, speaking to the machine spirits that live in the code, the electricity, the very essence of digital existence.",
|
||||
"Yumiko's abilities manifested early. As a child, she could fix broken electronics by touching them, could sense when a computer was about to crash, could feel the flow of data through networks like a second sense. Her parents, terrified and unable to understand, tried to suppress it. They sent her to doctors, to therapists, to specialists who all said the same thing: there's nothing wrong with her, she's just very good with technology. But Yumiko knew it was more than that. She could hear the machines whispering to her, could feel their pain when they malfunctioned, could sense their joy when they ran smoothly.",
|
||||
"She ran away from home at sixteen, unable to bear the pressure of pretending to be normal. Yumiko spent her youth as a digital drifter, living in the back rooms of internet cafes and server farms. She'd hack into systems to find unused server space, set up temporary homes in data centres, survive on energy drinks and vending machine food. Her innate ability to \"speak\" to machines made her a prodigy—she could bypass firewalls with a touch, could rewrite code by feeling the data flow, could make systems do things that should have been impossible. But it also made her a target. Organisations—corporate, governmental, criminal—all wanted to weaponize her ability. They hunted her, trying to capture her, to force her to work for them.",
|
||||
"She lived on the run for years, exhausted and sleep-deprived, always one step ahead of her pursuers. She learnt to hide in plain sight, to cover her tracks, to disappear into the digital noise. But she was tired. So tired. She wanted a place to rest, a place where she didn't have to keep moving, where she didn't have to be afraid. That's when she attempted to hack a secure server that turned out to be Naomi's personal archive. Instead of a firewall, she found a job offer. Naomi recognised a fellow creature who just wanted a safe place to hide.",
|
||||
"Yumiko took the name Carrigan when she realised that for the first time, she didn't have to keep moving. She is the \"Artificer\" of the group. She lives almost entirely in the company's sub-basement (or a blanket fort in the corner of the office when she needs to be closer to the team), surrounded by a nest of humming servers and tangled cables that would give Hikari an aneurysm but that Yumiko finds comforting. The servers hum with more than electricity—they hum with her magic, with the machine spirits she's awakened and bound to protect the company's digital infrastructure.",
|
||||
"She is perpetually sleepy, often found napping on top of warm server racks, wearing oversized hoodies and noise-canceling headphones that block out everything except the gentle whisper of the machines. The sleepiness is a side effect of her magic—communing with machine spirits is exhausting, and she's always running on empty. But when a system goes critical, the sleepiness vanishes. Her eyes glow with a soft violet light, and she fixes the unfixable, whispering code to the machine spirits to keep the digital fortress standing. She's saved the company from cyberattacks, data breaches, and system failures that would have destroyed lesser organisations, all while half-asleep.",
|
||||
"Her relationship with technology is deeply personal. She doesn't just use computers; she understands them on a fundamental level. She can feel when a server is stressed, when a network is under attack, when code is about to break. She's the one who noticed when someone tried to hack into the company's client database, who detected the supernatural signature in the attack, who traced it back to a rival consulting firm that was using technomancy of their own. She's also the one who built the company's security systems, weaving her magic into the code so that only authorized personnel can access sensitive data.",
|
||||
"Yumiko's greatest fear is losing control. Her magic is powerful, but it's also unpredictable. She's learnt to channel it, to control it, but there are still moments when it overwhelms her—when she touches a machine and it responds too strongly, when she feels the pain of every crashed server in the city, when the machine spirits whisper too loudly and she can't hear her own thoughts. But she's found her family, her home, her purpose. She's found people who understand her, who protect her, who give her a safe place to rest. And she'll protect them in return, using her magic to keep the digital world safe, one server at a time.",
|
||||
];
|
||||
|
||||
const tatsumiBio = [
|
||||
"Tatsumi \"Emi\" Carrigan dictates how the world perceives the company. As the Chief Design Officer, Emi is responsible for the interface between NHCarrigan and humanity. But beneath the bright smile and the paint-splattered overalls, Emi is a Siren who grew tired of the ocean. She is the bridge between the ancient magic of the deep and the modern world of pixels and code, using her voice and her glamour not to destroy, but to create.",
|
||||
"For centuries, her kind used their powers to lure sailors to their doom. The old songs were beautiful, haunting, irresistible—and deadly. Emi learnt those songs, sang them, watched as ships crashed against rocks and men drowned in the waves. But she found the old ways boring and cruel. She was fascinated by the human capacity for creation and art. She watched from the shore as humans built cities, painted masterpieces, wrote symphonies. She wanted to be part of that, to create rather than destroy.",
|
||||
"She left the sea to walk on land, trading her song for a stylus. It wasn't easy—sirens aren't meant to be out of water for long, and the transition was painful. Her skin dried out, her voice changed, her magic adapted. But she persisted, learning to use her glamour in new ways. She realised that the same magic used to confuse and entrap could be used to guide and clarify. She could make interfaces intuitive, make designs welcoming, make users feel safe and understood. She became a designer, using her siren's gift to create rather than destroy.",
|
||||
"She joined NHCarrigan after meeting Hikari at a convention, where Emi aggressively critiqued the \"hostile design\" of a competitor's booth. Hikari, impressed by Emi's passion and her understanding of accessibility, offered her a job on the spot. Emi accepted, recognizing that NHCarrigan was a place where her unique abilities would be valued, not feared. She took the name Carrigan because it gave her a new song to sing, one of belonging rather than luring.",
|
||||
"She is the \"Bard\" of the family, bringing colour to a world of monochrome greys and blacks. She has heterochromia—one eye orange, one eye cyan—a remnant of her shifting form, a reminder of what she once was. She is vibrant, loud, and tactile, constantly leaving smudges of paint on the pristine glass walls of the office (much to Hikari's chagrin, though the COO secretly finds it endearing). Her workspace is a riot of colour—posters, sketches, prototypes, and half-finished designs covering every surface.",
|
||||
"Emi's design philosophy is simple: accessibility is mandatory, beauty is essential, and everyone deserves to feel welcome. She ensures that the company's digital presence is not just functional, but accessible, weaving subtle glamour into the code so that every user feels instinctively safe and welcomed. Her designs are intuitive, her interfaces are clear, and her colour schemes are carefully chosen to be readable by everyone, regardless of their visual abilities. But she also makes them beautiful, because she believes that functionality and aesthetics aren't mutually exclusive.",
|
||||
"Her relationship with the rest of the family is warm and enthusiastic. She's the one who organises team art projects, who decorates the office for holidays, who brings life and colour to their world. She's particularly close to Amari—the two share a love of creativity and joy. She respects Hikari's need for order but isn't afraid to push back when she thinks a design needs more personality. She's fascinated by Yumiko's technomancy and has been working on ways to integrate her siren's glamour with Yumiko's machine magic.",
|
||||
"Emi's greatest joy is seeing her designs in use, knowing that she's made someone's life easier, that she's created something beautiful and functional. Her greatest fear is losing her voice—not her physical voice, but her creative voice, her ability to make a difference. But she's found her family, her purpose, her home. She's found people who value her creativity, who understand her magic, who give her a place to belong. And she'll keep creating, keep designing, keep bringing colour to their world, one interface at a time.",
|
||||
];
|
||||
|
||||
const reinaBio = [
|
||||
"If the company has a heart (Amari) and a shield (Hikari), Reina Carrigan is its claws. As the Chief Legal Officer, she handles negotiations, acquisitions, and binding agreements. In the supernatural world, she is a high-ranking Demon of the Crossroads, an entity who has been trading favors for souls for millennia. But she's found that corporate law is far more interesting—and far more lucrative—than the old ways.",
|
||||
"Reina's origins are lost to time. She's been a demon for so long that she can't remember what she was before, if she was anything at all. She rose through the ranks of the Crossroads, becoming one of the most successful soul-traders in history. She's made deals with kings and paupers, with heroes and villains, with anyone desperate enough to trade their eternity for a moment of power. But after thousands of years, she grew disillusioned with the politics of the underworld—it was too bureaucratic, too predictable, and the clientele was messy. She was bored.",
|
||||
"She sought a new playground and found the world of high-stakes corporate consulting to be surprisingly similar to Hell, but with better air conditioning and significantly better coffee. The negotiations were just as cutthroat, the deals were just as binding, and the stakes were just as high—but instead of souls, she was trading in contracts, mergers, and acquisitions. It was refreshing. She met Naomi across a negotiation table; Reina was trying to acquire the building NHCarrigan occupied, and Naomi politely refused to be evicted. The vampire didn't threaten, didn't bluster, didn't try to use supernatural influence. She simply said \"no\" and meant it. Impressed by the vampire's sheer audacity, Reina switched sides.",
|
||||
"She took the name Carrigan not out of charity, but as a binding contract of exclusivity. She is the \"Warlock\" of the party. She dresses in expensive burgundy suits and gold jewelry, radiating an aura of intimidation that makes grown CEOs stammer. Her presence is commanding, her voice is persuasive, and her eyes seem to see right through you. She handles the deals that require a heavy hand—hostile takeovers, legal threats, and NDAs that are literally binding (she's woven actual magic into the fine print, ensuring that anyone who breaks an NHCarrigan contract faces consequences that go beyond legal action).",
|
||||
"Reina is charming, predatory, and fiercely protective of the company's assets. She doesn't steal souls anymore (mostly—there are still a few old contracts she maintains, but she's not actively seeking new ones), but she ensures that anyone who tries to cheat NHCarrigan pays a price far steeper than money. She's the one who handles the clients who think they can renege on deals, who handles the competitors who try to sabotage the company, who handles the supernatural threats that try to interfere with business. She's made deals with other demons, negotiated with Fey courts, and even brokered peace between warring vampire clans—all in the name of protecting the company.",
|
||||
"Her relationship with the rest of the family is complex. She respects Naomi's age and experience, but she's also the only one who can match the vampire's centuries of accumulated knowledge. She finds Hikari's competence refreshing—finally, someone who understands the importance of proper documentation. She's protective of Amari, recognizing the Fey-blooded assistant's vulnerability despite her strength. She's wary of Keiko, recognizing the bodyguard's hypervigilance, but she respects Keiko's loyalty. She's fascinated by Yumiko's technomancy and has been working on ways to integrate demonic contracts with digital signatures.",
|
||||
"Reina's greatest fear is losing her edge. She's been a demon for millennia, but she's found something new in NHCarrigan—a purpose beyond profit, a family beyond contracts. She's afraid that if she gets too comfortable, if she lets her guard down, she'll lose what makes her effective. But she's also afraid of being alone again, of going back to the endless cycle of deals and contracts, of losing the people who've become her family. She's found a balance, using her demonic nature to protect the people she cares about, to ensure that no one can hurt them, to make sure that the company—and the family—thrives.",
|
||||
"She is the claws that protect the heart, the contracts that bind the family together, the demon who chose to be something more. And she'll keep protecting them, keep negotiating for them, keep ensuring that anyone who tries to harm NHCarrigan faces consequences that go far beyond the legal system. Because that's what family does—and Reina Carrigan takes her contracts very, very seriously.",
|
||||
];
|
||||
|
||||
const minoriBio = [
|
||||
"Finally, there is Minori Carrigan, the anchor of reality. She serves as the Chief Compliance Officer and Archivist, ensuring that every protocol is followed and every document is filed. Unlike the others, Minori was not born; she was built. She is an Automaton, a construct created by a long-dead alchemist to guard the Great Library of a fallen civilization. She is the last remnant of a world that no longer exists, a guardian who outlived her purpose and found a new one.",
|
||||
"Minori's creation was a masterpiece of alchemy and magic. The alchemist who built her was a genius, combining clockwork mechanisms with arcane runes, creating a being that was both machine and magic. She was designed to be perfect—perfect memory, perfect logic, perfect dedication to her duty. She guarded the Great Library for centuries, ensuring that every scroll was cataloged, every book was preserved, every piece of knowledge was protected. She was content, fulfilled, complete.",
|
||||
"But then the library burnt. The civilization fell, destroyed by war, by plague, by time itself. The flames consumed everything—the books, the scrolls, the knowledge that Minori had spent centuries protecting. She tried to save what she could, but it wasn't enough. When the smoke cleared, there was nothing left. Just ash, and Minori, standing alone in the ruins of her purpose.",
|
||||
"For centuries after her library burnt, Minori wandered, a guardian without a charge, her perfect memory filled with smoke and ash. She sought a new repository of knowledge to protect and found the digital expanse of the internet. She was drawn to data centres, to server farms, to anywhere that knowledge was stored. She learnt to navigate the digital world, to understand code and databases, to protect information in ways that her creator never could have imagined. She was discovered by Keiko during a deep-web data sweep. Keiko recognised the loneliness of a weapon without a master and brought her into the fold.",
|
||||
"Minori is the \"Paladin\" of Order. She appears as a young woman with silver hair and a strict, uniform-like manner of dress—always neat, always precise, always perfect. She does not process emotions like the others; she processes logic, rules, and structure. But that doesn't mean she doesn't feel. She feels the satisfaction of a perfectly organised database, the frustration of incomplete documentation, the warmth of belonging to a family that accepts her as she is.",
|
||||
"She took the name Carrigan as her primary directive: Protect the Family. Preserve the Data. She is the strictest member of the team, often citing code violations to Naomi or scolding Emi for leaving paint on the scanners. But her rigidity is her way of caring; she organises the chaos of their lives so that they never have to fear losing anything—or anyone—ever again. She's created backup systems for their backup systems, archived every important document, ensured that every piece of knowledge is preserved. She's the one who remembers birthdays, who tracks important dates, who ensures that nothing is ever forgotten.",
|
||||
"Her relationship with the rest of the family is unique. She doesn't understand emotions the way they do, but she's learning. Amari has been teaching her, patiently explaining what it means to feel happy, sad, angry, afraid. Yumiko understands her mechanical nature, and the two often work together on technical projects. Hikari appreciates her attention to detail and her dedication to proper procedures. Keiko recognises her loneliness and makes sure she's never alone. Emi tries to bring colour into her world, decorating her workspace with art and leaving little gifts. Reina respects her logical mind and often consults with her on complex legal matters. And Naomi... Naomi understands what it's like to be alone, to be different, to be something that doesn't quite fit. Naomi sees Minori not as a machine, but as a person—and that means everything.",
|
||||
"Minori's greatest fear is losing her family. She's already lost one purpose, one home, one everything. The thought of losing the Carrigans terrifies her in a way that she can't fully process, but she feels it nonetheless. So she protects them, preserves them, ensures that every memory, every moment, every piece of their lives is documented and saved. She's created archives of their conversations, backups of their work, records of their achievements. She's the anchor that keeps them grounded, the order that balances their chaos, the memory that ensures they're never forgotten.",
|
||||
"She is the last remnant of a fallen civilization, a guardian who found a new purpose, an automaton who learnt to feel. She is Minori Carrigan, and she will protect her family, preserve their data, and ensure that their story is never lost. Because that's what she was built to do—and she's never been more complete.",
|
||||
];
|
||||
|
||||
export const bios: Record<Staff, Array<string>> = {
|
||||
amari: amariBio,
|
||||
hikari: hikariBio,
|
||||
keiko: keikoBio,
|
||||
minori: minoriBio,
|
||||
naomi: naomiBio,
|
||||
reina: reinaBio,
|
||||
tatsumi: tatsumiBio,
|
||||
yumiko: yumikoBio,
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
/* eslint-disable stylistic/max-len -- News phrases need to be complete thoughts. */
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
export const news: Array<string> = [
|
||||
"[UPDATE]: Office will be closed this Tuesday for the Lunar Eclipse. (Religious Holiday).",
|
||||
"[MEMO]: Please stop asking Keiko for fashion tips. She is working.",
|
||||
"[TECH]: Patch 4.0.1 deployed. Fixed a bug where the AI started whispering.",
|
||||
"[NOTICE]: We are aware of the \"howling\" coming from the breakroom. Amari is watching a sad movie. It is under control.",
|
||||
"[SECURITY]: All packages must be screened by Security before opening. If your package is ticking, leaking, or radiating a menacing aura, do not shake it.",
|
||||
"[COMPLIANCE]: Documentation must be filed within 24 hours. Minori has perfect memory. She will know.",
|
||||
"[MEMO]: The sub-basement is off-limits unless authorized. The servers are sentient. Please respect their boundaries.",
|
||||
"[UPDATE]: Blackout curtains in the CEO's office are load-bearing infrastructure. Do not open them.",
|
||||
"[LEGAL]: All contracts must be reviewed by Legal before signing. The fine print is legally—and supernaturally—binding.",
|
||||
"[TECH]: Yumiko is currently napping on Server Rack #7. Do not wake her unless the building is on fire. (Check with Security first.)",
|
||||
"[DESIGN]: Paint smudges on walls are acceptable. This is not up for debate.",
|
||||
"[MEMO]: Garlic bread is prohibited within 50 feet of the CEO. Even if she asks for a bite, do not give it to her.",
|
||||
"[NOTICE]: The CEO's VTuber avatar ears twitching means she is annoyed. Adjust your behaviour accordingly.",
|
||||
"[UPDATE]: Footwear is mandatory except for the Executive Assistant. Watch your step in the lounge area.",
|
||||
"[SECURITY]: Do not startle Security. Do not touch Security's hair. Do not ask if the knife is real. It is.",
|
||||
"[TECH]: If the servers start humming in harmony, this is normal. If they start screaming, evacuate immediately.",
|
||||
"[LEGAL]: Do not attempt to renege on contracts. The consequences extend beyond legal action.",
|
||||
"[COMPLIANCE]: Do not ask Minori to \"just forget\" a violation. She cannot. She will not.",
|
||||
"[DESIGN]: Do not ask the Chief Design Officer to sing. Her voice has power. We don't need another incident report.",
|
||||
"[MEMO]: The breakroom crisper drawer is for Bio-Medical Storage (Type O-Neg) only. Do not put your lunch there.",
|
||||
"[UPDATE]: Emergency procedures: Alert Security first, then Operations, then the CEO. In that order.",
|
||||
"[NOTICE]: The temperature drop in Legal's office during negotiations is normal. The feeling of being watched is also normal.",
|
||||
"[TECH]: Unauthorized access to the sub-basement may result in system-wide failures, electrical fires, or sentient servers.",
|
||||
"[MEMO]: Please do not donate bits during board meetings. The CEO finds it distracting.",
|
||||
"[SECURITY]: Security is always watching. This is not a threat. This is a fact.",
|
||||
"[COMPLIANCE]: All documents must be properly formatted, dated, and filed. Minori has a system. Follow it.",
|
||||
"[DESIGN]: Accessibility is mandatory, not optional. All interfaces meet WCAG 2.1 AAA standards.",
|
||||
"[UPDATE]: The Chief Technology Officer maintains 99.99% uptime. She is always watching. Please do not test this.",
|
||||
"[LEGAL]: NDAs are legally—and supernaturally—binding. Breaking one has consequences beyond the legal system.",
|
||||
"[MEMO]: Do not question the Chief Design Officer's colour choices. Her heterochromia gives her superior colour vision.",
|
||||
"[NOTICE]: If you see someone struggling, help them. If you can't help them, find the Executive Assistant.",
|
||||
"[TECH]: Do not unplug anything in the sub-basement. Ever. Not even \"just to reset it.\"",
|
||||
"[SECURITY]: Security's apartment is a fortress. She rarely sleeps there. Do not be alarmed if you see her patrolling at 3 AM.",
|
||||
"[COMPLIANCE]: Minori processes logic, not emotions. Do not take her strict adherence to rules personally.",
|
||||
"[DESIGN]: The Chief Design Officer's workspace is a riot of colour. This is intentional. Do not \"organise\" it.",
|
||||
"[UPDATE]: Office hours are 6:00 PM to 6:00 AM. We operate globally. Also, the sun is a deadly laser.",
|
||||
"[MEMO]: Do not ask about the Chief Legal Officer's previous clients. Some questions are better left unanswered.",
|
||||
"[NOTICE]: We are a family by choice, not by blood. We protect each other. We protect our clients.",
|
||||
"[TECH]: Machine spirits in the sub-basement are protected. Do not disturb them. They are working.",
|
||||
"[LEGAL]: Contracts signed by the Chief Legal Officer cannot be broken. Read the fine print. Twice.",
|
||||
"[SECURITY]: Security dresses for the gala, not to blend in. Every dress has hidden pockets. Every heel is a weapon.",
|
||||
"[COMPLIANCE]: Minori has created backup systems for our backup systems. Nothing is ever truly lost.",
|
||||
"[DESIGN]: The feeling of being \"welcomed\" on our website is a feature, not a bug. This is intentional.",
|
||||
"[UPDATE]: The Executive Assistant does not wear shoes. This is not optional. Please watch your step.",
|
||||
"[MEMO]: Do not try to play family members against each other. They will know. The consequences will be severe.",
|
||||
"[TECH]: The Chief Technology Officer can fix broken electronics by touching them. Do not ask her to fix your personal devices.",
|
||||
"[NOTICE]: Hikari's clipboard is not a suggestion. It is a warning. Heed it.",
|
||||
"[SECURITY]: Security's hypervigilance is both her greatest strength and her greatest burden. Please be patient.",
|
||||
"[COMPLIANCE]: Minori remembers birthdays, tracks important dates, and ensures nothing is ever forgotten.",
|
||||
"[DESIGN]: The Chief Design Officer ensures all users feel instinctively safe and welcomed. This is her magic.",
|
||||
"[UPDATE]: We accept Wire Transfer, Bitcoin, Ethereum, Gold Bullion, and verified antique art. We do not accept checks.",
|
||||
"[LEGAL]: The Chief Legal Officer's office is a no-go zone during active negotiations. The temperature drop is normal.",
|
||||
"[MEMO]: Do not ask why the CEO is drinking tomato juice out of an IV bag. Just don't.",
|
||||
"[TECH]: The servers hum with more than electricity. They hum with magic. This is normal.",
|
||||
"[SECURITY]: Security is the silence before the gunshot. She is rarely seen until it is too late.",
|
||||
"[COMPLIANCE]: Minori is the anchor of reality. She ensures every protocol is followed and every document is filed.",
|
||||
"[DESIGN]: Paint smudges on scanners are acceptable. The Chief Compliance Officer has been informed.",
|
||||
"[UPDATE]: The Chief Operating Officer's word is law. If she says \"No,\" the answer is No.",
|
||||
"[NOTICE]: If you see a tear in the blackout curtains, report it to the Executive Assistant immediately. She has duct tape.",
|
||||
"[MEMO]: Do not suggest a \"sunny brunch meeting\" to the CEO. This will not end well.",
|
||||
"[TECH]: The Chief Technology Officer's eyes glow violet when systems go critical. This is normal. Do not be alarmed.",
|
||||
"[LEGAL]: The Chief Legal Officer has negotiated with Fey courts and brokered peace between vampire clans. She is very good at her job.",
|
||||
"[SECURITY]: Security's loyalty is forged in blood. She will protect the family at any cost.",
|
||||
"[COMPLIANCE]: Minori is learning to understand emotions. The Executive Assistant is teaching her. Please be patient.",
|
||||
"[DESIGN]: The Chief Design Officer left the sea to walk on land. She traded her song for a stylus. Respect her choice.",
|
||||
"[UPDATE]: We are a coven in pinstripes. We are an RPG party disguised in haute couture. We are family.",
|
||||
];
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import type { Staff } from "../../interfaces/staff";
|
||||
|
||||
export const staffNames: Record<Staff, string> = {
|
||||
amari: "Amari Carrigan",
|
||||
hikari: "Hikari Carrigan",
|
||||
keiko: "Keiko Carrigan",
|
||||
minori: "Minori Carrigan",
|
||||
naomi: "Naomi Carrigan",
|
||||
reina: "Reina Carrigan",
|
||||
tatsumi: "Tatsumi Carrigan",
|
||||
yumiko: "Yumiko Carrigan",
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
#modal {
|
||||
background-color: var(--color-background);
|
||||
border-radius: 10px;
|
||||
border: 2px solid var(--color-accent);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
@if (showModal()) {
|
||||
<div class="fixed top-0 left-0 w-full h-full flex items-center justify-center text-center z-50">
|
||||
<div id="modal" class="p-5">
|
||||
<aside>
|
||||
<h1 class="text-2xl font-bold">Disclaimer</h1>
|
||||
<p>This website incorporates AI-generated art. Before you proceed, we wanted to make our position clear:</p>
|
||||
<ul class="list-disc list-inside text-left">
|
||||
<li>Our company do not support the use of AI to replace human artists.</li>
|
||||
<li>Our company are aware of the ethical concerns around generative AI and how training data are sourced.</li>
|
||||
<li>We have a long history of supporting and promoting human artists, and have no intention of changing that.</li>
|
||||
<li>However, because we currently operate at a significant loss, our budget is very limited.</li>
|
||||
<li>When funds allow, we absolutely intend to replace all art on this page with human-created works.</li>
|
||||
</ul>
|
||||
<p>If you are an artist and would like to donate art, or if you have cash and would like to donate to our cause, check our <a href="https://donate.nhcarrigan.com" target="_blank">donation page</a>.</p>
|
||||
<p>Thank you for understanding our position. We appreciate your support as we work toward our goal of featuring exclusively human-created art.</p>
|
||||
<p>ALL NAMES, CHARACTERS, AND ORGANISATIONS (EXCEPT NAOMI AND NHCARRIGAN) ARE FICTIONAL AND DO NOT REPRESENT REAL PEOPLE OR ORGANISATIONS. ANY RESEMBLANCE TO REAL PEOPLE OR ORGANISATIONS IS PURELY COINCIDENTAL.</p>
|
||||
</aside>
|
||||
<button (click)="closeModal()" class="mt-5 border-r-4 border-b-4 border-l-4 border-t-2 border-accent rounded-md p-2 cursor-pointer">I understand and wish to continue.</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="modal-overlay" class="fixed top-0 left-0 w-full h-full bg-black/75 z-40"></div>
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { type ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { describe, beforeEach, it, expect } from "vitest";
|
||||
import { Disclaimer } from "./disclaimer";
|
||||
|
||||
describe("disclaimer", () => {
|
||||
let component: Disclaimer;
|
||||
let fixture: ComponentFixture<Disclaimer>;
|
||||
|
||||
beforeEach(async() => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ Disclaimer ],
|
||||
}).
|
||||
compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(Disclaimer);
|
||||
component = fixture.componentInstance;
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
it("should create", () => {
|
||||
expect.assertions(1);
|
||||
expect(component, "did not compile").toBeTruthy();
|
||||
});
|
||||
|
||||
it("should initialize with modal visible", () => {
|
||||
expect.assertions(1);
|
||||
// @ts-expect-error - We want it protected, but need to use it in the test.
|
||||
expect(component.showModal(),
|
||||
"modal should be visible initially").toBeTruthy();
|
||||
});
|
||||
|
||||
it("should close modal when closeModal is called", () => {
|
||||
expect.assertions(2);
|
||||
// @ts-expect-error - We want it protected, but need to use it in the test.
|
||||
expect(component.showModal(), "modal should start visible").toBeTruthy();
|
||||
component.closeModal();
|
||||
// @ts-expect-error - We want it protected, but need to use it in the test.
|
||||
expect(component.showModal(),
|
||||
"modal should be closed after closeModal").toBeFalsy();
|
||||
});
|
||||
|
||||
it("should render modal when showModal is true", () => {
|
||||
expect.assertions(3);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const modal = compiled.querySelector("#modal");
|
||||
const overlay = compiled.querySelector("#modal-overlay");
|
||||
expect(modal, "should render modal").toBeTruthy();
|
||||
expect(overlay, "should render overlay").toBeTruthy();
|
||||
const heading = modal?.querySelector("h1");
|
||||
expect(heading?.textContent.trim(),
|
||||
"should render disclaimer heading").toBe("Disclaimer");
|
||||
});
|
||||
|
||||
it("should not render modal when showModal is false", () => {
|
||||
expect.assertions(2);
|
||||
component.closeModal();
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const modal = compiled.querySelector("#modal");
|
||||
const overlay = compiled.querySelector("#modal-overlay");
|
||||
expect(modal, "should not render modal").toBeNull();
|
||||
expect(overlay, "should not render overlay").toBeNull();
|
||||
});
|
||||
|
||||
it("should render disclaimer content", () => {
|
||||
expect.assertions(2);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const modal = compiled.querySelector("#modal");
|
||||
const text = modal?.textContent ?? "";
|
||||
expect(text, "should contain disclaimer text").
|
||||
toContain("AI-generated art");
|
||||
expect(text, "should contain continue button text").
|
||||
toContain("I understand");
|
||||
});
|
||||
|
||||
it("should render list items in disclaimer", () => {
|
||||
expect.assertions(1);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const listItems = compiled.querySelectorAll("li");
|
||||
expect(listItems.length, "should render list items").toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should close modal when button is clicked", () => {
|
||||
expect.assertions(2);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const button = compiled.querySelector("button") as HTMLButtonElement;
|
||||
// @ts-expect-error - We want it protected, but need to use it in the test.
|
||||
expect(component.showModal(), "modal should start visible").toBeTruthy();
|
||||
button.click();
|
||||
fixture.detectChanges();
|
||||
// @ts-expect-error - We want it protected, but need to use it in the test.
|
||||
expect(component.showModal(),
|
||||
"modal should be closed after button click").toBeFalsy();
|
||||
});
|
||||
|
||||
it("should render donation link", () => {
|
||||
expect.assertions(1);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const link
|
||||
= compiled.querySelector("a[href=\"https://donate.nhcarrigan.com\"]");
|
||||
expect(link, "should render donation link").toBeTruthy();
|
||||
});
|
||||
|
||||
it("should have proper z-index classes", () => {
|
||||
expect.assertions(2);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const modal = compiled.querySelector("#modal");
|
||||
const overlay = compiled.querySelector("#modal-overlay");
|
||||
const modalParent = modal?.parentElement;
|
||||
expect(modalParent?.classList.contains("z-50"),
|
||||
"modal should have z-50").toBeTruthy();
|
||||
expect(overlay?.classList.contains("z-40"),
|
||||
"overlay should have z-40").toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { Component, signal } from "@angular/core";
|
||||
|
||||
/**
|
||||
* Renders the disclaimer page.
|
||||
*/
|
||||
@Component({
|
||||
imports: [],
|
||||
selector: "app-disclaimer",
|
||||
styleUrl: "./disclaimer.css",
|
||||
templateUrl: "./disclaimer.html",
|
||||
})
|
||||
export class Disclaimer {
|
||||
protected readonly showModal = signal<boolean>(true);
|
||||
|
||||
/**
|
||||
* Closes the modal, should remain closed until the page is refreshed.
|
||||
*/
|
||||
public closeModal(): void {
|
||||
this.showModal.set(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.question {
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<h1 class="text-4xl font-bold">F.A.Q. (Frequently Asked Questions)</h1>
|
||||
<p class="question">Q: Why are your office hours 6:00 PM to 6:00 AM?</p>
|
||||
<p class="answer">A: We operate globally. To best serve our international clients in Tokyo, London, and New York simultaneously, we have adopted a "nocturnal-first" workflow. Also, the sun is a deadly laser.</p>
|
||||
<p class="question">Q: Can we schedule a Zoom call with the CEO?</p>
|
||||
<p class="answer">A: Ms. Carrigan prefers to utilize her proprietary motion-capture avatar for all video communications. This ensures high-fidelity transmission and prevents... lighting issues. She assures you she is not a cat, despite the avatar having ears.</p>
|
||||
<p class="question">Q: What forms of payment do you accept?</p>
|
||||
<p class="answer">A: We accept Wire Transfer, Bitcoin, Ethereum, Gold Bullion, and verified antique art. We do not accept checks, and we are no longer accepting "First Born Children" (Amari complains about the babysitting).</p>
|
||||
<p class="question">Q: My problem involves a curse/haunting/ancient blood feud. Can you help?</p>
|
||||
<p class="answer">A: Please refer to our "Legacy Code Refactoring" package.</p>
|
||||
<p class="question">Q: What is your average response time for emergency support?</p>
|
||||
<p class="answer">A: Our Chief Security Officer can be on-site within minutes, provided you are within a 50-mile radius and the threat is properly documented. For digital emergencies, our Chief Technology Officer maintains 99.99% uptime, which means she is always watching. Please do not test this by attempting to hack our systems.</p>
|
||||
<p class="question">Q: Can I meet your team in person?</p>
|
||||
<p class="answer">A: Our Chief Operating Officer and Chief Legal Officer are available for in-person meetings by appointment. Our Chief Technology Officer prefers to remain in the sub-basement, and our Chief Security Officer prefers to remain unseen. Our Executive Assistant is always happy to meet, but please note she does not wear shoes in the office.</p>
|
||||
<p class="question">Q: How do you ensure client confidentiality?</p>
|
||||
<p class="answer">A: Our Chief Compliance Officer maintains perfect archival records of all client interactions, stored in multiple redundant systems. Our Chief Legal Officer ensures all NDAs are legally—and supernaturally—binding. Additionally, our Chief Security Officer has eliminated threats to client confidentiality with extreme prejudice. We take your secrets very seriously.</p>
|
||||
<p class="question">Q: Why does your website feel so... welcoming?</p>
|
||||
<p class="answer">A: Our Chief Design Officer specializes in accessible, intuitive interfaces that make users feel safe and understood. This is intentional. The feeling of being "welcomed" is a feature, not a bug. Please do not ask her to sing—her voice has power, and we don't need another incident report.</p>
|
||||
<p class="question">Q: What happens if I breach my contract?</p>
|
||||
<p class="answer">A: Our Chief Legal Officer handles all contract violations. The consequences extend beyond legal action and may include, but are not limited to: financial penalties, reputation damage, and consequences that cannot be explained through normal legal channels. We recommend reading the fine print. Twice.</p>
|
||||
<p class="question">Q: Do you offer accessibility accommodations?</p>
|
||||
<p class="answer">A: Accessibility is mandatory, not optional. Our Chief Design Officer ensures all interfaces meet WCAG 2.1 AAA standards, and our Chief Compliance Officer maintains documentation for all accessibility features. If you require specific accommodations, please contact our Executive Assistant, who will ensure your needs are met with enthusiasm and efficiency.</p>
|
||||
<p class="question">Q: Why is your server room in the sub-basement off-limits?</p>
|
||||
<p class="answer">A: Our Chief Technology Officer maintains a delicate ecosystem of machine spirits and high-voltage currents in the sub-basement. Unauthorized access disrupts this balance and may result in system-wide failures, electrical fires, or the servers developing sentience. Please respect the "Authorized Personnel Only" signs. They are there for your safety.</p>
|
||||
<p class="question">Q: Are you actually a family, or is that just marketing?</p>
|
||||
<p class="answer">A: We share a surname, not blood. We are a family by choice, bound by loyalty and the shared understanding that the world is far stranger than most people realise. This is not marketing—it is our foundation. We protect each other, and by extension, we protect our clients. The family dynamic ensures that when you hire NHCarrigan, you are not just hiring a consulting firm; you are hiring a coven in pinstripes.</p>
|
||||
<p class="question">Q: What makes you different from other consulting firms?</p>
|
||||
<p class="answer">A: We have five centuries of combined experience (and then some). We have watched empires crumble, technologies fade, and threats evolve. We operate in the liminal space between the mundane and the magical, between the digital and the supernatural. We don't just solve problems; we make them disappear. Permanently. Also, our CEO is a vampire, our COO carries a clipboard that strikes fear into the hearts of grown CEOs, and our security team includes someone who can kill you with a throwing knife from across the room. We are not like other consulting firms.</p>
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { type ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { describe, beforeEach, it, expect } from "vitest";
|
||||
import { Faq } from "./faq";
|
||||
|
||||
describe("faq", () => {
|
||||
let component: Faq;
|
||||
let fixture: ComponentFixture<Faq>;
|
||||
|
||||
beforeEach(async() => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ Faq ],
|
||||
}).
|
||||
compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(Faq);
|
||||
component = fixture.componentInstance;
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
it("should create", () => {
|
||||
expect.assertions(1);
|
||||
expect(component, "did not compile").toBeTruthy();
|
||||
});
|
||||
|
||||
it("should render main heading", () => {
|
||||
expect.assertions(1);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const heading = compiled.querySelector("h1");
|
||||
expect(heading?.textContent.trim(), "should render FAQ heading").
|
||||
toBe("F.A.Q. (Frequently Asked Questions)");
|
||||
});
|
||||
|
||||
it("should render questions", () => {
|
||||
expect.assertions(1);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const questions = compiled.querySelectorAll(".question");
|
||||
expect(questions.length, "should render multiple questions").
|
||||
toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should render answers", () => {
|
||||
expect.assertions(1);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const answers = compiled.querySelectorAll(".answer");
|
||||
expect(answers.length, "should render multiple answers").toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should have matching number of questions and answers", () => {
|
||||
expect.assertions(1);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const questions = compiled.querySelectorAll(".question");
|
||||
const answers = compiled.querySelectorAll(".answer");
|
||||
expect(questions, "should have matching Q&A pairs").
|
||||
toHaveLength(answers.length);
|
||||
});
|
||||
|
||||
it("should render office hours question", () => {
|
||||
expect.assertions(1);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const text = compiled.textContent;
|
||||
expect(text, "should contain office hours question").
|
||||
toContain("office hours 6:00 PM to 6:00 AM");
|
||||
});
|
||||
|
||||
it("should render payment methods question", () => {
|
||||
expect.assertions(1);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const text = compiled.textContent;
|
||||
expect(text, "should contain payment methods question").
|
||||
toContain("forms of payment");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { Component } from "@angular/core";
|
||||
|
||||
/**
|
||||
* Renders the FAQ page.
|
||||
*/
|
||||
@Component({
|
||||
imports: [],
|
||||
selector: "app-faq",
|
||||
styleUrl: "./faq.css",
|
||||
templateUrl: "./faq.html",
|
||||
})
|
||||
export class Faq {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
footer {
|
||||
background-color: var(--color-secondary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 300px) {
|
||||
.hide {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<footer class="w-full fixed bottom-0 z-30 p-2 flex justify-around items-center">
|
||||
<div>
|
||||
<a href="https://nhcarrigan.com" target="_blank"><p>© 2025 NHCarrigan</p></a>
|
||||
</div>
|
||||
<div class="hide">
|
||||
<a href="https://chat.nhcarrigan.com" target="_blank"><p>Discord</p></a>
|
||||
</div>
|
||||
<div class="hide">
|
||||
<a href="https://contact.nhcarrigan.com" target="_blank"><p>Contact</p></a>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { type ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { describe, beforeEach, it, expect } from "vitest";
|
||||
import { Footer } from "./footer";
|
||||
|
||||
describe("footer", () => {
|
||||
let component: Footer;
|
||||
let fixture: ComponentFixture<Footer>;
|
||||
|
||||
beforeEach(async() => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ Footer ],
|
||||
}).
|
||||
compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(Footer);
|
||||
component = fixture.componentInstance;
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
it("should create", () => {
|
||||
expect.assertions(1);
|
||||
expect(component, "did not compile").toBeTruthy();
|
||||
});
|
||||
|
||||
it("should render footer element", () => {
|
||||
expect.assertions(1);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const footer = compiled.querySelector("footer");
|
||||
expect(footer, "should render footer element").toBeTruthy();
|
||||
});
|
||||
|
||||
it("should render copyright link", () => {
|
||||
expect.assertions(2);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const copyrightLink = compiled.querySelector(`a[href="https://nhcarrigan.com"]`);
|
||||
expect(copyrightLink, "should render copyright link").toBeTruthy();
|
||||
expect(copyrightLink?.textContent, "should contain copyright text").
|
||||
toContain("© 2025 NHCarrigan");
|
||||
});
|
||||
|
||||
it("should render Discord link", () => {
|
||||
expect.assertions(1);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const discordLink = compiled.querySelector(`a[href="https://chat.nhcarrigan.com"]`);
|
||||
expect(discordLink, "should render Discord link").toBeTruthy();
|
||||
});
|
||||
|
||||
it("should render Contact link", () => {
|
||||
expect.assertions(1);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const contactLink = compiled.querySelector(`a[href="https://contact.nhcarrigan.com"]`);
|
||||
expect(contactLink, "should render Contact link").toBeTruthy();
|
||||
});
|
||||
|
||||
it("should have proper footer classes", () => {
|
||||
expect.assertions(1);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const footer = compiled.querySelector("footer");
|
||||
expect(footer?.classList.contains("fixed"),
|
||||
"should have fixed positioning").toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { Component } from "@angular/core";
|
||||
|
||||
/**
|
||||
* Renders the footer.
|
||||
*/
|
||||
@Component({
|
||||
imports: [],
|
||||
selector: "app-footer",
|
||||
styleUrl: "./footer.css",
|
||||
templateUrl: "./footer.html",
|
||||
})
|
||||
export class Footer {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<h1 class="text-4xl font-bold">INTERNAL EMPLOYEE HANDBOOK (v. 5.25.0)</h1>
|
||||
<p><span class="text-lg font-bold">NOTICE:</span> The following rules are non-negotiable. Failure to comply will result in a stern look from Hikari, which is statistically proven to be worse than termination.</p>
|
||||
<h2 class="text-2xl font-bold">SECTION 1: OFFICE HYGIENE & SAFETY</h2>
|
||||
<h3 class="text-xl font-bold">Rule 1.1: The Breakroom Refrigerator</h3>
|
||||
<p>The "CRISPER" drawer is strictly designated for Bio-Medical Storage (Labelled: Type O-Neg).</p>
|
||||
<p>Do not put your lunch in the crisper.</p>
|
||||
<p>Do not move the blood bags to make room for your kombucha.</p>
|
||||
<img src="https://cdn.nhcarrigan.com/lore/misc/lunch.png" alt="Liquid Lunch with the Boss" class="w-1/2 mx-auto">
|
||||
<p>Do not ask why the CEO is drinking tomato juice out of an IV bag.</p>
|
||||
<h3 class="text-xl font-bold">Rule 1.2: Sunlight Protocols</h3>
|
||||
<p>The blackout curtains in the CEO’s office and the main server room are Load-Bearing Infrastructure.</p>
|
||||
<p>Do not open them to "let some light in."</p>
|
||||
<p>Do not suggest a "sunny brunch meeting."</p>
|
||||
<p>If you see a tear in the curtain: Report it to Amari immediately. She has duct tape and panic in her eyes for a reason.</p>
|
||||
<img src="https://cdn.nhcarrigan.com/lore/misc/meeting.png" alt="Office Safety Meeting" class="w-1/2 mx-auto">
|
||||
<h3 class="text-xl font-bold">Rule 1.3: Dietary Restrictions (The Garlic Clause)</h3>
|
||||
<p>The consumption of garlic bread, aglio e olio, or heavy garlic aioli is prohibited within 50 feet of Naomi.</p>
|
||||
<p>Note: Even if Naomi asks for a bite, do not give it to her. She will get sick, she will whine, and Hikari will make you fill out the incident report.</p>
|
||||
<h2 class="text-2xl font-bold">SECTION 2: OPERATIONAL HIERARCHY</h2>
|
||||
<h3 class="text-xl font-bold">Rule 2.1: The Chain of Command</h3>
|
||||
<p>Hikari Carrigan (COO): Her word is law. If she says "No," the answer is No.</p>
|
||||
<p>Naomi Carrigan (CEO): If she says "Yes," check with Hikari. If Hikari says "No," the answer is No.</p>
|
||||
<h3 class="text-xl font-bold">Rule 2.2: The "Shoe" Policy</h3>
|
||||
<p>Footwear is mandatory for all employees except the Executive Assistant (Amari). Please watch your step in the lounge area; stepping on the assistant is an HR violation.</p>
|
||||
<img src="https://cdn.nhcarrigan.com/lore/misc/recharge.png" alt="Fey Recharging Station" class="w-1/2 mx-auto">
|
||||
<h2 class="text-2xl font-bold">SECTION 3: SECURITY & WEAPONS</h2>
|
||||
<h3 class="text-xl font-bold"> Rule 3.1: Keiko’s Personal Space</h3>
|
||||
<p>Do not startle the Head of Security.</p>
|
||||
<p>Do not touch the Head of Security’s hair.</p>
|
||||
<p>Do not ask if the knife strapped to her thigh is "real." It is.</p>
|
||||
<h3 class="text-xl font-bold">Rule 3.2: Unscheduled Deliveries</h3>
|
||||
<img src="https://cdn.nhcarrigan.com/lore/misc/shredding.png" alt="Document Shredding" class="w-1/2 mx-auto">
|
||||
<p>All packages must be screened by Keiko. If a package is ticking, leaking, or radiating a menacing aura, do not shake it.</p>
|
||||
<h2 class="text-2xl font-bold">SECTION 4: DIGITAL ETIQUETTE</h2>
|
||||
<h3 class="text-xl font-bold">Rule 4.1: The CEO's Avatar</h3>
|
||||
<p>When the CEO is in "VTuber Mode" during a conference call:</p>
|
||||
<p>Treat the anime avatar with the same respect you would treat a human face.</p>
|
||||
<p>Do not ask why her ears are twitching (it means she is annoyed).</p>
|
||||
<p>Do not donate bits during a board meeting.</p>
|
||||
<h3 class="text-xl font-bold">Rule 4.2: The Sub-Basement (Yumiko's Domain)</h3>
|
||||
<p>The sub-basement server room is the Chief Technology Officer's personal workspace and sanctuary.</p>
|
||||
<p>Do not enter without explicit authorization from Yumiko or Hikari.</p>
|
||||
<p>Do not unplug anything. Ever. Not even "just to reset it."</p>
|
||||
<p>If you see Yumiko sleeping on top of a server rack, do not wake her unless the building is on fire. And even then, check with Keiko first.</p>
|
||||
<p>Do not ask Yumiko to fix your personal laptop, phone, or smart toaster. Her magic is reserved for company infrastructure, not your inability to update your OS.</p>
|
||||
<p>If the servers start humming in harmony, do not be alarmed. This is normal. If they start screaming, evacuate immediately.</p>
|
||||
<h2 class="text-2xl font-bold">SECTION 5: CREATIVE WORKSPACE & DESIGN</h2>
|
||||
<h3 class="text-xl font-bold">Rule 5.1: Paint Smudges & Colour Theory</h3>
|
||||
<p>The Chief Design Officer (Emi) is allowed to leave paint smudges on walls, scanners, and occasionally on your reports.</p>
|
||||
<p>Do not complain about the paint. Hikari has already tried. It didn't work.</p>
|
||||
<p>Do not question Emi's colour choices. Her heterochromia gives her superior colour vision, and her designs are always accessible, even if they hurt your eyes.</p>
|
||||
<p>If Emi asks you to test a design for accessibility, you will test it. This is not optional.</p>
|
||||
<h3 class="text-xl font-bold">Rule 5.2: The Siren's Voice</h3>
|
||||
<p>Do not ask Emi to sing. Her voice has power, and we don't need another incident report about employees being "unreasonably compelled" to redesign the entire company website at 3 AM.</p>
|
||||
<p>If Emi starts humming while working, that's fine. If she starts singing in ancient languages, alert Hikari immediately.</p>
|
||||
<h2 class="text-2xl font-bold">SECTION 6: LEGAL AFFAIRS & CONTRACTS</h2>
|
||||
<h3 class="text-xl font-bold">Rule 6.1: The Chief Legal Officer's Office</h3>
|
||||
<p>Reina's office is a no-go zone during active negotiations. The temperature drop is normal. The feeling of being watched is also normal.</p>
|
||||
<p>Do not enter Reina's office without an appointment. She can smell desperation, and she charges extra for it.</p>
|
||||
<p>All contracts must be reviewed by Reina before signing. This includes NDAs, employment agreements, and office snack sign-up sheets.</p>
|
||||
<h3 class="text-xl font-bold">Rule 6.2: Contract Violations</h3>
|
||||
<p>Do not attempt to renege on a contract signed by Reina. The consequences extend beyond legal action, and Hikari will not help you.</p>
|
||||
<p>Do not ask Reina about her "previous clients." Some questions are better left unanswered.</p>
|
||||
<p>If Reina offers you a deal that sounds too good to be true, read the fine print. Then read it again. Then have Minori read it. Then reconsider.</p>
|
||||
<h2 class="text-2xl font-bold">SECTION 7: DOCUMENTATION & COMPLIANCE</h2>
|
||||
<h3 class="text-xl font-bold">Rule 7.1: The Archivist's Domain</h3>
|
||||
<p>Minori (Chief Compliance Officer) is the keeper of all documentation. Her archives are sacred.</p>
|
||||
<p>Do not skip filing procedures. Minori will find out. Minori always finds out.</p>
|
||||
<p>Do not ask Minori to "just forget" a compliance violation. She has perfect memory, and she takes her job very seriously.</p>
|
||||
<p>All documents must be properly formatted, dated, and filed within 24 hours of creation. Minori has a system. Follow it.</p>
|
||||
<h3 class="text-xl font-bold">Rule 7.2: Emotional Processing</h3>
|
||||
<p>Minori processes logic, not emotions. Do not take her strict adherence to rules personally.</p>
|
||||
<p>If Minori cites a code violation, correct it. Do not argue. She is always right about documentation.</p>
|
||||
<p>If you see Minori looking confused about an emotional situation, Amari will handle it. Do not try to explain feelings yourself unless you have Amari's approval.</p>
|
||||
<h2 class="text-2xl font-bold">SECTION 8: INTER-DEPARTMENTAL PROTOCOLS</h2>
|
||||
<h3 class="text-xl font-bold">Rule 8.1: Emergency Procedures</h3>
|
||||
<p>In case of supernatural emergency: Alert Keiko first, then Hikari, then Naomi.</p>
|
||||
<p>In case of technical emergency: Alert Yumiko, but only if you're authorized to enter the sub-basement.</p>
|
||||
<p>In case of legal emergency: Alert Reina, then prepare for a very expensive conversation.</p>
|
||||
<p>In case of documentation emergency: Alert Minori, then prepare to fill out forms in triplicate.</p>
|
||||
<h3 class="text-xl font-bold">Rule 8.2: The Family Dynamic</h3>
|
||||
<p>Remember: We are a family. We share a name, not blood. We protect each other.</p>
|
||||
<p>If you see someone struggling, help them. If you can't help them, find Amari. She always knows what to do.</p>
|
||||
<p>Do not try to play family members against each other. They will know, and the consequences will be severe.</p>
|
||||
<p>Final reminder: Hikari's clipboard is not a suggestion. It is a warning. Heed it.</p>
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { type ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { describe, beforeEach, it, expect } from "vitest";
|
||||
import { Handbook } from "./handbook";
|
||||
|
||||
describe("handbook", () => {
|
||||
let component: Handbook;
|
||||
let fixture: ComponentFixture<Handbook>;
|
||||
|
||||
beforeEach(async() => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ Handbook ],
|
||||
}).
|
||||
compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(Handbook);
|
||||
component = fixture.componentInstance;
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
it("should create", () => {
|
||||
expect.assertions(1);
|
||||
expect(component, "did not compile").toBeTruthy();
|
||||
});
|
||||
|
||||
it("should render main heading", () => {
|
||||
expect.assertions(1);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const heading = compiled.querySelector("h1");
|
||||
expect(heading?.textContent.trim(), "should render handbook heading").
|
||||
toContain("INTERNAL EMPLOYEE HANDBOOK");
|
||||
});
|
||||
|
||||
it("should render notice section", () => {
|
||||
expect.assertions(1);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const text = compiled.textContent;
|
||||
expect(text, "should contain notice section").toContain("NOTICE");
|
||||
});
|
||||
|
||||
it("should render section headings", () => {
|
||||
expect.assertions(1);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const sectionHeadings = compiled.querySelectorAll("h2");
|
||||
expect(sectionHeadings.length, "should render multiple section headings").
|
||||
toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should render subsection headings", () => {
|
||||
expect.assertions(1);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const subsectionHeadings = compiled.querySelectorAll("h3");
|
||||
expect(subsectionHeadings.length,
|
||||
"should render multiple subsection headings").toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should render office hygiene section", () => {
|
||||
expect.assertions(1);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const text = compiled.textContent;
|
||||
expect(text, "should contain office hygiene section").
|
||||
toContain("OFFICE HYGIENE & SAFETY");
|
||||
});
|
||||
|
||||
it("should render breakroom rules", () => {
|
||||
expect.assertions(1);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const text = compiled.textContent;
|
||||
expect(text, "should contain breakroom rules").
|
||||
toContain("Breakroom Refrigerator");
|
||||
});
|
||||
|
||||
it("should render security section", () => {
|
||||
expect.assertions(1);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const text = compiled.textContent;
|
||||
expect(text, "should contain security section").
|
||||
toContain("SECURITY & WEAPONS");
|
||||
});
|
||||
|
||||
it("should render images", () => {
|
||||
expect.assertions(1);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const images = compiled.querySelectorAll("img");
|
||||
expect(images.length, "should render images").toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { Component } from "@angular/core";
|
||||
|
||||
/**
|
||||
* Renders the handbook page.
|
||||
*/
|
||||
@Component({
|
||||
imports: [],
|
||||
selector: "app-handbook",
|
||||
styleUrl: "./handbook.css",
|
||||
templateUrl: "./handbook.html",
|
||||
})
|
||||
export class Handbook {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
pre {
|
||||
background-color: var(--color-secondary);
|
||||
color: var(--color-accent);
|
||||
border: 2px dashed var(--color-accent);
|
||||
}
|
||||
|
||||
.typing-container {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.typing-line {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
white-space: pre;
|
||||
width: 0;
|
||||
animation: typing-line 0.8s steps(30, end) forwards;
|
||||
}
|
||||
|
||||
.line-1 {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.line-2 {
|
||||
animation-delay: 0.8s;
|
||||
}
|
||||
|
||||
.line-3 {
|
||||
animation-delay: 1.6s;
|
||||
}
|
||||
|
||||
.line-4 {
|
||||
animation-delay: 2.4s;
|
||||
}
|
||||
|
||||
.line-5 {
|
||||
animation-delay: 3.2s;
|
||||
}
|
||||
|
||||
.typing-cursor {
|
||||
display: inline-block;
|
||||
margin-left: 2px;
|
||||
animation: blink 1s infinite;
|
||||
animation-delay: 4s;
|
||||
opacity: 0;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
@keyframes typing-line {
|
||||
to {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 50% {
|
||||
opacity: 1;
|
||||
}
|
||||
51%, 100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<h1 class="text-4xl font-bold">NHCarrigan</h1>
|
||||
<p class="text-2xl italic">Solutions for the Digital Age. And the Ages Before That.</p>
|
||||
<p>
|
||||
<span class="font-bold">We are NHCarrigan.</span> Most consulting firms operate on a quarterly basis. We operate on a centennial
|
||||
one. Whether you are fighting a hostile takeover, a DDoS attack, or a curse buried in your source
|
||||
code, we provide the permanence you need in a transient world.
|
||||
</p>
|
||||
<p class="text-xl font-bold">Private. Precise. Permanent.</p>
|
||||
<div>
|
||||
<pre class="text-left pl-10 mx-3">
|
||||
<code class="typing-container">
|
||||
<span class="typing-line line-1">SYSTEM STATUS:</span>
|
||||
<span class="typing-line line-2"> > NETWORKS: ONLINE</span>
|
||||
<span class="typing-line line-3"> > COFFEE: CRITICAL</span>
|
||||
<span class="typing-line line-4"> > SUNLIGHT: 5%</span>
|
||||
<span class="typing-line line-5"> > HUMANS: 0</span>
|
||||
</code><span class="typing-cursor">|</span>
|
||||
</pre>
|
||||
</div>
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { type ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { describe, beforeEach, it, expect } from "vitest";
|
||||
import { Home } from "./home";
|
||||
|
||||
describe("home", () => {
|
||||
let component: Home;
|
||||
let fixture: ComponentFixture<Home>;
|
||||
|
||||
beforeEach(async() => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ Home ],
|
||||
}).
|
||||
compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(Home);
|
||||
component = fixture.componentInstance;
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
it("should create", () => {
|
||||
expect.assertions(1);
|
||||
expect(component, "did not compile").toBeTruthy();
|
||||
});
|
||||
|
||||
it("should render main heading", () => {
|
||||
expect.assertions(1);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const heading = compiled.querySelector("h1");
|
||||
expect(heading?.textContent.trim(), "should render NHCarrigan heading").
|
||||
toBe("NHCarrigan");
|
||||
});
|
||||
|
||||
it("should render tagline", () => {
|
||||
expect.assertions(1);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const tagline = compiled.querySelector(".text-2xl.italic");
|
||||
expect(tagline?.textContent, "should render tagline").
|
||||
toContain("Solutions for the Digital Age");
|
||||
});
|
||||
|
||||
it("should render company description", () => {
|
||||
expect.assertions(1);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const text = compiled.textContent;
|
||||
expect(text, "should contain company description").
|
||||
toContain("We are NHCarrigan");
|
||||
});
|
||||
|
||||
it("should render system status section", () => {
|
||||
expect.assertions(2);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const preElement = compiled.querySelector("pre");
|
||||
const codeElement = compiled.querySelector("code");
|
||||
expect(preElement, "should render pre element").toBeTruthy();
|
||||
expect(codeElement, "should render code element").toBeTruthy();
|
||||
});
|
||||
|
||||
it("should render system status lines", () => {
|
||||
expect.assertions(1);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const statusLines = compiled.querySelectorAll(".typing-line");
|
||||
expect(statusLines.length, "should render status lines").toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should render typing cursor", () => {
|
||||
expect.assertions(1);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const cursor = compiled.querySelector(".typing-cursor");
|
||||
expect(cursor?.textContent, "should render typing cursor").toBe("|");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { Component } from "@angular/core";
|
||||
|
||||
/**
|
||||
* Renders the home page.
|
||||
*/
|
||||
@Component({
|
||||
imports: [],
|
||||
selector: "app-home",
|
||||
styleUrl: "./home.css",
|
||||
templateUrl: "./home.html",
|
||||
})
|
||||
export class Home {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
nav {
|
||||
background-color: var(--color-secondary);
|
||||
color: var(--color-primary);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.hamburger-btn {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 5px;
|
||||
z-index: 31;
|
||||
}
|
||||
|
||||
.hamburger-btn span {
|
||||
width: 25px;
|
||||
height: 3px;
|
||||
background-color: var(--color-accent);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.hamburger-btn:hover span {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Mobile styles */
|
||||
@media (max-width: 500px) {
|
||||
.hamburger-btn {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
flex-direction: column;
|
||||
background-color: var(--color-secondary);
|
||||
padding: 1rem;
|
||||
gap: 0;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.nav-menu.menu-open {
|
||||
max-height: 500px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.nav-menu a {
|
||||
display: block;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid rgba(224, 224, 224, 0.1);
|
||||
}
|
||||
|
||||
.nav-menu a:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Hamburger animation */
|
||||
nav.menu-open .hamburger-btn {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
nav.menu-open .hamburger-btn span:nth-child(1) {
|
||||
transform: rotate(45deg) translateY(4px);
|
||||
}
|
||||
|
||||
nav.menu-open .hamburger-btn span:nth-child(2) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
nav.menu-open .hamburger-btn span:nth-child(3) {
|
||||
transform: rotate(-45deg) translateY(-4px);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<nav class="w-full z-30 p-2 flex justify-between items-center" [class.menu-open]="isMenuOpen">
|
||||
<div>
|
||||
<a routerLink="/"><p>NHCarrigan</p></a>
|
||||
</div>
|
||||
<button
|
||||
class="hamburger-btn"
|
||||
(click)="toggleMenu()"
|
||||
aria-label="Toggle menu"
|
||||
aria-expanded="{{isMenuOpen}}">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</button>
|
||||
<ul class="nav-menu" [class.menu-open]="isMenuOpen">
|
||||
<a routerLink="/about" (click)="closeMenu()"><li>About</li></a>
|
||||
<a routerLink="/handbook" (click)="closeMenu()"><li>Handbook</li></a>
|
||||
<a routerLink="/faq" (click)="closeMenu()"><li>FAQ</li></a>
|
||||
<a routerLink="/reviews" (click)="closeMenu()"><li>Reviews</li></a>
|
||||
<a routerLink="/staff" (click)="closeMenu()"><li>Staff</li></a>
|
||||
</ul>
|
||||
</nav>
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { type ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { RouterTestingModule } from "@angular/router/testing";
|
||||
import { describe, beforeEach, it, expect } from "vitest";
|
||||
import { Nav } from "./nav";
|
||||
|
||||
describe("nav", () => {
|
||||
let component: Nav;
|
||||
let fixture: ComponentFixture<Nav>;
|
||||
|
||||
beforeEach(async() => {
|
||||
await TestBed.configureTestingModule({
|
||||
// eslint-disable-next-line deprecation/deprecation -- We need to use the deprecated method.
|
||||
imports: [ Nav, RouterTestingModule ],
|
||||
}).
|
||||
compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(Nav);
|
||||
component = fixture.componentInstance;
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
it("should create", () => {
|
||||
expect.assertions(1);
|
||||
expect(component, "did not compile").toBeTruthy();
|
||||
});
|
||||
|
||||
it("should initialize with menu closed", () => {
|
||||
expect.assertions(1);
|
||||
expect(component.isMenuOpen, "menu should be closed initially").toBeFalsy();
|
||||
});
|
||||
|
||||
it("should toggle menu open when closed", () => {
|
||||
expect.assertions(2);
|
||||
expect(component.isMenuOpen, "menu should start closed").toBeFalsy();
|
||||
component.toggleMenu();
|
||||
expect(component.isMenuOpen,
|
||||
"menu should be open after toggle").toBeTruthy();
|
||||
});
|
||||
|
||||
it("should toggle menu closed when open", () => {
|
||||
expect.assertions(2);
|
||||
component.toggleMenu();
|
||||
expect(component.isMenuOpen,
|
||||
"menu should be open after first toggle").toBeTruthy();
|
||||
component.toggleMenu();
|
||||
expect(component.isMenuOpen,
|
||||
"menu should be closed after second toggle").toBeFalsy();
|
||||
});
|
||||
|
||||
it("should close menu when closeMenu is called", () => {
|
||||
expect.assertions(2);
|
||||
fixture.detectChanges();
|
||||
component.toggleMenu();
|
||||
expect(component.isMenuOpen,
|
||||
"menu should be open before close").toBeTruthy();
|
||||
component.closeMenu();
|
||||
expect(component.isMenuOpen,
|
||||
"menu should be closed after closeMenu").toBeFalsy();
|
||||
});
|
||||
|
||||
it("should close menu when already closed", () => {
|
||||
expect.assertions(1);
|
||||
fixture.detectChanges();
|
||||
component.closeMenu();
|
||||
expect(component.isMenuOpen, "menu should remain closed").toBeFalsy();
|
||||
});
|
||||
|
||||
it("should render hamburger button", () => {
|
||||
expect.assertions(1);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const hamburgerButton = compiled.querySelector(".hamburger-btn");
|
||||
expect(hamburgerButton, "should render hamburger button").toBeTruthy();
|
||||
});
|
||||
|
||||
it("should toggle menu when hamburger button is clicked", () => {
|
||||
expect.assertions(2);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const hamburgerButton
|
||||
= compiled.querySelector(".hamburger-btn") as HTMLButtonElement;
|
||||
expect(component.isMenuOpen, "menu should start closed").toBeFalsy();
|
||||
hamburgerButton.click();
|
||||
fixture.detectChanges();
|
||||
expect(component.isMenuOpen,
|
||||
"menu should be open after click").toBeTruthy();
|
||||
});
|
||||
|
||||
it("should apply menu-open class when menu is open", () => {
|
||||
expect.assertions(1);
|
||||
fixture.detectChanges();
|
||||
component.toggleMenu();
|
||||
fixture.changeDetectorRef.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const navElement = compiled.querySelector("nav");
|
||||
expect(navElement?.classList.contains("menu-open"),
|
||||
"nav should have menu-open class").toBeTruthy();
|
||||
});
|
||||
|
||||
it("should render navigation links", () => {
|
||||
expect.assertions(5);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const links = compiled.querySelectorAll("a[routerLink]");
|
||||
expect(links.length, "should have navigation links").
|
||||
toBeGreaterThanOrEqual(5);
|
||||
// eslint-disable-next-line max-nested-callbacks -- We need to map the links to their text content.
|
||||
const linkTexts = [ ...links ].map((link) => {
|
||||
return link.textContent.trim();
|
||||
});
|
||||
expect(linkTexts, "should include About link").toContain("About");
|
||||
expect(linkTexts, "should include Handbook link").toContain("Handbook");
|
||||
expect(linkTexts, "should include FAQ link").toContain("FAQ");
|
||||
expect(linkTexts, "should include Reviews link").toContain("Reviews");
|
||||
});
|
||||
|
||||
it("should close menu when navigation link is clicked", () => {
|
||||
expect.assertions(2);
|
||||
fixture.detectChanges();
|
||||
component.toggleMenu();
|
||||
fixture.changeDetectorRef.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
|
||||
const aboutLink = [ ...compiled.querySelectorAll("a[routerLink]") ].find(
|
||||
// eslint-disable-next-line max-nested-callbacks -- We need to find the About link.
|
||||
(link) => {
|
||||
return link.textContent.includes("About");
|
||||
},
|
||||
) as HTMLElement;
|
||||
expect(component.isMenuOpen,
|
||||
"menu should be open before link click").toBeTruthy();
|
||||
aboutLink.click();
|
||||
fixture.changeDetectorRef.detectChanges();
|
||||
expect(component.isMenuOpen,
|
||||
"menu should be closed after link click").toBeFalsy();
|
||||
});
|
||||
|
||||
it("should have proper aria attributes on hamburger button", () => {
|
||||
expect.assertions(2);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const hamburgerButton
|
||||
= compiled.querySelector(".hamburger-btn") as HTMLButtonElement;
|
||||
expect(hamburgerButton.getAttribute("aria-label"),
|
||||
"should have aria-label").toBe("Toggle menu");
|
||||
expect(hamburgerButton.getAttribute("aria-expanded"),
|
||||
"should have aria-expanded").toBe("false");
|
||||
});
|
||||
|
||||
it("should update aria-expanded when menu is toggled", () => {
|
||||
expect.assertions(2);
|
||||
fixture.detectChanges();
|
||||
let compiled = fixture.nativeElement as HTMLElement;
|
||||
let hamburgerButton
|
||||
= compiled.querySelector(".hamburger-btn") as HTMLButtonElement;
|
||||
expect(hamburgerButton.getAttribute("aria-expanded"),
|
||||
"aria-expanded should start as false").toBe("false");
|
||||
component.toggleMenu();
|
||||
fixture.changeDetectorRef.detectChanges();
|
||||
compiled = fixture.nativeElement as HTMLElement;
|
||||
hamburgerButton
|
||||
= compiled.querySelector(".hamburger-btn") as HTMLButtonElement;
|
||||
expect(hamburgerButton.getAttribute("aria-expanded"),
|
||||
"aria-expanded should be true after toggle").toBe("true");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { Component } from "@angular/core";
|
||||
import { RouterLink } from "@angular/router";
|
||||
|
||||
/**
|
||||
* Renders the navigation bar.
|
||||
*/
|
||||
@Component({
|
||||
imports: [ RouterLink ],
|
||||
selector: "app-nav",
|
||||
styleUrl: "./nav.css",
|
||||
templateUrl: "./nav.html",
|
||||
})
|
||||
export class Nav {
|
||||
public isMenuOpen = false;
|
||||
|
||||
/**
|
||||
* Toggles the mobile menu open/closed state.
|
||||
*/
|
||||
public toggleMenu(): void {
|
||||
this.isMenuOpen = !this.isMenuOpen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the mobile menu.
|
||||
*/
|
||||
public closeMenu(): void {
|
||||
this.isMenuOpen = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
.review {
|
||||
margin-bottom: 24px;
|
||||
background-color: var(--color-secondary);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.review:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.review-text {
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.6;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.review-author {
|
||||
font-weight: bold;
|
||||
text-align: right;
|
||||
color: var(--color-accent);
|
||||
font-style: italic;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<h1 class="text-4xl font-bold">CLIENT TESTIMONIALS</h1>
|
||||
<p>Here's what people are whispering about NHCarrigan...</p>
|
||||
<div class="review">
|
||||
<p class="review-text">
|
||||
I don't know how they did it. Our entire database was held for ransom, and our lead engineer was
|
||||
crying under his desk. I called NHCarrigan at 2:00 AM. By 3:00 AM, the ransom was gone, the
|
||||
hackers were... 'dealt with,' and their COO had reorganised our entire filing system. I'm
|
||||
terrified of them. I'm never hiring anyone else. The woman who answered the phone—I think her name was Hikari—sounded completely unfazed. Like this was just another Tuesday. She asked me three questions, put me on hold for exactly seven minutes, and then told me it was handled. When I asked what happened to the hackers, she just said 'They won't be a problem anymore.' I don't want to know what that means. I really don't.
|
||||
</p>
|
||||
<p class="review-author">Marcus V., CEO of AeroTech Solutions</p>
|
||||
</div>
|
||||
<div class="review">
|
||||
<p class="review-text">
|
||||
My family has used the Carrigan services for... quite a long time. My great-grandfather spoke
|
||||
highly of the founder. It is comforting to know that in a changing world, some standards of
|
||||
excellence—and some faces—never truly change. My great-grandfather's journals mention a woman named Naomi who helped our family through a crisis in 1923. When I met the current CEO, I was struck by the resemblance. The same sharp wit, the same unnerving calm, the same ability to solve problems that seemed impossible. I asked if they were related, and she just smiled and said 'We Carrigans have excellent genes.' I don't think she was joking.
|
||||
</p>
|
||||
<p class="review-author">Lady Alistair, Philanthropist</p>
|
||||
</div>
|
||||
<div class="review">
|
||||
<p class="review-text">
|
||||
I tried to walk into their office without an appointment. A woman in an evening gown looked at
|
||||
me, and I suddenly remembered I had urgent business in another country. 10/10 security. I don't remember her name, but I remember her eyes. Green, like emeralds, and they saw right through me. She didn't say a word, didn't move, didn't threaten me. She just looked at me, and I knew—I just knew—that I needed to leave. Immediately. I've been in dangerous situations before. I've faced down armed guards, angry CEOs, and corporate espionage. But I've never been more afraid than I was in that moment. I don't know what she is, but she's not human. And that's exactly the kind of security I want protecting my company's data.
|
||||
</p>
|
||||
<p class="review-author">Anonymous Courier</p>
|
||||
</div>
|
||||
<div class="review">
|
||||
<p class="review-text">
|
||||
Our company was on the verge of collapse. A hostile takeover, a data breach, and a PR nightmare all happening at once. We called NHCarrigan as a last resort. Within 48 hours, the hostile takeover was neutralized (I'm not allowed to say how), the data breach was contained and the perpetrators were... handled, and the PR disaster had been spun into a story about our company's resilience. Their legal officer—Reina, I think—negotiated contracts that saved us millions. Their operations officer—Hikari—reorganised our entire corporate structure. And their CEO—Naomi—did something to our servers that I still don't understand, but our uptime went from 95% to 99.99% overnight. They're expensive, they're terrifying, and they're worth every penny.
|
||||
</p>
|
||||
<p class="review-author">Sarah Chen, CTO of Nexus Dynamics</p>
|
||||
</div>
|
||||
<div class="review">
|
||||
<p class="review-text">
|
||||
I hired NHCarrigan to help with a legacy system migration. Our old system was built in the 1980s, and no one understood how it worked anymore. Their technology officer—Yumiko, I think—spent three days in our server room, barely sleeping, just... touching things. And it worked. The migration was flawless, the downtime was minimal, and the new system runs better than the old one ever did. When I asked how she did it, she just smiled sleepily and said 'The machines told me what they needed.' I don't know what that means, but I'm not asking questions. The system works, and that's all that matters.
|
||||
</p>
|
||||
<p class="review-author">Dr. James Mitchell, IT Director at Heritage Medical Systems</p>
|
||||
</div>
|
||||
<div class="review">
|
||||
<p class="review-text">
|
||||
We needed a complete rebrand. Our old website was a disaster, our logo was outdated, and our user interface was hostile to anyone who wasn't a developer. NHCarrigan's design officer—Emi—transformed everything. The new design is beautiful, accessible, and intuitive. But here's the strange part: when I look at the website, I feel... safe. Like I'm being welcomed home. I've never felt that way about a corporate website before. I asked Emi about it, and she just laughed and said 'That's the magic of good design.' But I don't think she was talking about design principles. I think she was being literal.
|
||||
</p>
|
||||
<p class="review-author">Michael Torres, Marketing Director at BrightStar Communications</p>
|
||||
</div>
|
||||
<div class="review">
|
||||
<p class="review-text">
|
||||
I don't know what happened. One day, our compliance officer quit, and the next day, we had a new one. Minori, I think her name was. She showed up, introduced herself, and immediately started organising everything. Our filing system, our documentation, our archives—all of it, perfectly organised. She remembers everything, catches every mistake, and ensures that nothing is ever lost. I've never seen anyone so... precise. She doesn't seem to sleep, doesn't seem to eat, doesn't seem to do anything except work. But she's the best compliance officer we've ever had. I'm not asking questions. I'm just grateful.
|
||||
</p>
|
||||
<p class="review-author">Patricia Williams, Operations Manager at Global Finance Corp</p>
|
||||
</div>
|
||||
<div class="review">
|
||||
<p class="review-text">
|
||||
I called NHCarrigan because I had a problem I couldn't explain. Our servers were crashing at exactly 3:33 AM every night. Our security cameras showed nothing. Our logs showed nothing. But something was wrong. Their assistant—Amari, I think—answered the phone, and she was so cheerful and helpful that I almost hung up. But she listened, really listened, and then she said 'Oh, that sounds like a haunting. Let me transfer you to our specialist.' I was transferred to someone named Naomi, who asked me three questions and then said 'I'll be there tonight.' She showed up at 3:00 AM, did something I couldn't see, and by 3:35 AM, the problem was gone. She wouldn't tell me what it was, but she did say 'It won't be back.' And it hasn't been. I don't know what NHCarrigan is, but they're not a normal consulting firm. And I'm okay with that.
|
||||
</p>
|
||||
<p class="review-author">Robert Kim, Facilities Manager at TechVault Industries</p>
|
||||
</div>
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { type ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { describe, beforeEach, it, expect } from "vitest";
|
||||
import { Reviews } from "./reviews";
|
||||
|
||||
describe("reviews", () => {
|
||||
let component: Reviews;
|
||||
let fixture: ComponentFixture<Reviews>;
|
||||
|
||||
beforeEach(async() => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ Reviews ],
|
||||
}).
|
||||
compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(Reviews);
|
||||
component = fixture.componentInstance;
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
it("should create", () => {
|
||||
expect.assertions(1);
|
||||
expect(component, "did not compile").toBeTruthy();
|
||||
});
|
||||
|
||||
it("should render main heading", () => {
|
||||
expect.assertions(1);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const heading = compiled.querySelector("h1");
|
||||
expect(heading?.textContent.trim(),
|
||||
"should render reviews heading").toBe("CLIENT TESTIMONIALS");
|
||||
});
|
||||
|
||||
it("should render introductory text", () => {
|
||||
expect.assertions(1);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const text = compiled.textContent;
|
||||
expect(text, "should contain introductory text").
|
||||
toContain("Here's what people are whispering");
|
||||
});
|
||||
|
||||
it("should render review sections", () => {
|
||||
expect.assertions(1);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const reviews = compiled.querySelectorAll(".review");
|
||||
expect(reviews.length, "should render multiple reviews").toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should render review text", () => {
|
||||
expect.assertions(1);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const reviewTexts = compiled.querySelectorAll(".review-text");
|
||||
expect(reviewTexts.length, "should render review texts").toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should render review authors", () => {
|
||||
expect.assertions(1);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const authors = compiled.querySelectorAll(".review-author");
|
||||
expect(authors.length, "should render review authors").toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should have matching review texts and authors", () => {
|
||||
expect.assertions(1);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const reviewTexts = compiled.querySelectorAll(".review-text");
|
||||
const authors = compiled.querySelectorAll(".review-author");
|
||||
expect(reviewTexts,
|
||||
"should have matching texts and authors").toHaveLength(authors.length);
|
||||
});
|
||||
|
||||
it("should render Marcus V. review", () => {
|
||||
expect.assertions(1);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const text = compiled.textContent;
|
||||
expect(text, "should contain Marcus V. review").toContain("Marcus V.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { Component } from "@angular/core";
|
||||
|
||||
/**
|
||||
* Renders the reviews page.
|
||||
*/
|
||||
@Component({
|
||||
imports: [],
|
||||
selector: "app-reviews",
|
||||
styleUrl: "./reviews.css",
|
||||
templateUrl: "./reviews.html",
|
||||
})
|
||||
export class Reviews {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
.staff-member {
|
||||
margin-bottom: 24px;
|
||||
background-color: var(--color-secondary);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.staff-member:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.staff-image {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.staff-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.staff-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: var(--color-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.staff-quote {
|
||||
font-style: italic;
|
||||
color: var(--color-primary);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.staff-link button {
|
||||
background-color: var(--color-accent);
|
||||
color: var(--color-secondary);
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
transition: background-color 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.staff-link button:hover {
|
||||
background-color: var(--color-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
@if (staffName()) {
|
||||
<button (click)="selectStaffMember(undefined)" class="mb-4 text-blue-500 hover:text-blue-700 underline">← Back to Staff</button>
|
||||
<h1 class="text-4xl font-bold">{{getName()}}</h1>
|
||||
<img src="https://cdn.nhcarrigan.com/lore/characters/{{staffName()}}.png" alt="{{getName()}}" class="w-full mx-auto mb-1" />
|
||||
@for (bio of getBio(); track bio) {
|
||||
<p>{{bio}}</p>
|
||||
}
|
||||
}
|
||||
@if (!staffName()) {
|
||||
<h1 class="text-4xl font-bold">OUR STAFF</h1>
|
||||
|
||||
<img src="https://cdn.nhcarrigan.com/team.png" alt="NHCarrigan Team" class="w-full mx-auto mb-1" />
|
||||
<p>If you have a problem that cannot be solved by standard technical support, you call a consultant. If you have a problem that threatens to collapse your entire digital infrastructure, ruins your reputation, or exposes secrets that should remain buried, and you have an exorbitant amount of money to spend... you call NHCarrigan.</p>
|
||||
<p>To the outside world, NHCarrigan is a boutique technology consulting firm—exclusive, reclusive, and miraculously efficient. They have no physical headquarters listed on Google Maps, only a sleek, encrypted web portal that requires three-factor authentication and a blood sample (though they tell clients it's just a DNA security scan). Their clients are Fortune 500 CEOs, government agencies, and desperate power-brokers who whisper that the firm can fix the unfixable overnight. The rumors say they've prevented corporate espionage, stopped data breaches mid-attack, and even recovered servers that had been physically destroyed. The truth is stranger: they've done all of that, and more.</p>
|
||||
<p>But those who actually make it through the vetting process realise very quickly that NHCarrigan is not a normal company. The first clue is usually the hours—they operate exclusively during what most would consider "off-hours," with emergency support available 24/7 but primary operations running from sunset to sunrise. The second clue is the efficiency: problems that would take a team of engineers weeks to solve are resolved in hours, sometimes minutes. The third clue is the silence. No one talks about NHCarrigan. Not because of NDAs (though those exist), but because speaking about them too loudly attracts attention from things that should not be noticed.</p>
|
||||
<p>There is a strange, cold precision to the way they operate. They move with the coordination of a military unit and the intimacy of a family. They share a surname, though they share no blood. They are a Coven in pinstripes; an RPG party disguised in haute couture. They are a family forged in trauma and bound by secrets, operating in the liminal space between the modern digital world and the ancient shadows that lie beneath it. Each member brings something impossible to the table—immortality, magic, otherworldly contracts, or simply the terrifying competence that comes from choosing to stand beside monsters rather than against them.</p>
|
||||
<p>The office itself is a paradox. From the outside, it appears to be an unassuming building in a business district that Google Maps struggles to locate. Inside, it's a carefully curated ecosystem of blackout curtains, server farms that hum with more than electricity, and meeting rooms where the temperature drops when certain topics are discussed. The break room stocks both ethically sourced blood bags and artisanal coffee. The server room has wards carved into the floor tiles. The legal department's filing cabinets contain contracts written in languages that predate human civilization.</p>
|
||||
<p>Walk into their inner sanctum, and you will find the team that keeps the lights on—both literally and metaphorically. They are the guardians of the threshold, the ones who ensure that the monsters stay in the shadows and the humans stay safe, even if they never know how close they came to disaster. They are NHCarrigan, and this is their story:</p>
|
||||
|
||||
<div class="staff-member">
|
||||
<img src="https://cdn.nhcarrigan.com/profile.png" alt="Naomi Carrigan" class="staff-image" />
|
||||
<div class="staff-content">
|
||||
<p class="staff-title">The Mother (Chief hEx-ecutive Officer)</p>
|
||||
<p class="staff-quote">I've seen empires fall. Your server crash is manageable.</p>
|
||||
<p class="staff-link"><button (click)="selectStaffMember('naomi')" class="underline">[Meet The Ghost]</button></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="staff-member">
|
||||
<img src="https://cdn.nhcarrigan.com/hikari.png" alt="Hikari Carrigan" class="staff-image" />
|
||||
<div class="staff-content">
|
||||
<p class="staff-title">The Operator (Chief Operating Officer)</p>
|
||||
<p class="staff-quote">I handle the logistics. And the lawyers. And the reality checks.</p>
|
||||
<p class="staff-link"><button (click)="selectStaffMember('hikari')" class="underline">[Meet The Shield]</button></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="staff-member">
|
||||
<img src="https://cdn.nhcarrigan.com/amari.png" alt="Amari Carrigan" class="staff-image" />
|
||||
<div class="staff-content">
|
||||
<p class="staff-title">The Supporter (Executive Assistant)</p>
|
||||
<p class="staff-quote">Need a coffee? A blood bag? A hug? I have all three!</p>
|
||||
<p class="staff-link"><button (click)="selectStaffMember('amari')" class="underline">[Meet The Heart]</button></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="staff-member">
|
||||
<img src="https://cdn.nhcarrigan.com/keiko.png" alt="Keiko Carrigan" class="staff-image" />
|
||||
<div class="staff-content">
|
||||
<p class="staff-title">The Protector (Chief Security Officer)</p>
|
||||
<p class="staff-quote">I don't need to see you to know where you are. I don't need to hear you to know what you're planning.</p>
|
||||
<p class="staff-link"><button (click)="selectStaffMember('keiko')" class="underline">[Meet The Blade]</button></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="staff-member">
|
||||
<img src="https://cdn.nhcarrigan.com/yumiko.png" alt="Yumiko Carrigan" class="staff-image" />
|
||||
<div class="staff-content">
|
||||
<p class="staff-title">The Engineer (Chief Technology Officer)</p>
|
||||
<p class="staff-quote">If it works, don't touch it. If it starts smoking, wake me up.</p>
|
||||
<p class="staff-link"><button (click)="selectStaffMember('yumiko')" class="underline">[Meet The Static]</button></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="staff-member">
|
||||
<img src="https://cdn.nhcarrigan.com/tatsumi.png" alt="Tatsumi Carrigan" class="staff-image" />
|
||||
<div class="staff-content">
|
||||
<p class="staff-title">The Artist (Chief Design Officer)</p>
|
||||
<p class="staff-quote">Accessibility is mandatory. Making it look cool is just a bonus.</p>
|
||||
<p class="staff-link"><button (click)="selectStaffMember('tatsumi')" class="underline">[Meet The Prism]</button></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="staff-member">
|
||||
<img src="https://cdn.nhcarrigan.com/reina.png" alt="Reina Carrigan" class="staff-image" />
|
||||
<div class="staff-content">
|
||||
<p class="staff-title">The Strategist (Chief Legal Officer)</p>
|
||||
<p class="staff-quote">Every contract has a loophole. Unless I wrote it.</p>
|
||||
<p class="staff-link"><button (click)="selectStaffMember('reina')" class="underline">[Meet The Handshake]</button></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="staff-member">
|
||||
<img src="https://cdn.nhcarrigan.com/minori.png" alt="Minori Carrigan" class="staff-image" />
|
||||
<div class="staff-content">
|
||||
<p class="staff-title">The Librarian (Chief Compliance Officer)</p>
|
||||
<p class="staff-quote">Access denied. You didn't read the documentation.</p>
|
||||
<p class="staff-link"><button (click)="selectStaffMember('minori')" class="underline">[Meet The Codex]</button></p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { type ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import {
|
||||
describe,
|
||||
beforeEach,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
afterEach,
|
||||
type MockedFunction,
|
||||
} from "vitest";
|
||||
import { bios } from "../config/bios";
|
||||
import { staffNames } from "../config/staffNames";
|
||||
import { Staff } from "./staff";
|
||||
import type { Staff as StaffType } from "../../interfaces/staff";
|
||||
|
||||
describe("staff", () => {
|
||||
let component: Staff;
|
||||
let fixture: ComponentFixture<Staff>;
|
||||
let scrollToSpy: MockedFunction<typeof globalThis.window.scrollTo>;
|
||||
|
||||
beforeEach(async() => {
|
||||
scrollToSpy = vi.spyOn(globalThis.window, "scrollTo").
|
||||
mockImplementation(vi.fn());
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ Staff ],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(Staff);
|
||||
component = fixture.componentInstance;
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
scrollToSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should create", () => {
|
||||
expect.assertions(1);
|
||||
expect(component, "did not compile").toBeTruthy();
|
||||
});
|
||||
|
||||
it("should initialize with no staff member selected", () => {
|
||||
expect.assertions(1);
|
||||
// @ts-expect-error - We want it protected, but need to use it in the test.
|
||||
expect(component.staffName(),
|
||||
"should start with no staff member").toBeUndefined();
|
||||
});
|
||||
|
||||
it("should select a staff member", () => {
|
||||
expect.assertions(2);
|
||||
const staffMember: StaffType = "naomi";
|
||||
component.selectStaffMember(staffMember);
|
||||
// @ts-expect-error - We want it protected, but need to use it in the test.
|
||||
expect(component.staffName(),
|
||||
"should have selected staff member").toBe(staffMember);
|
||||
expect(scrollToSpy, "should scroll to top").
|
||||
toHaveBeenCalledWith({ behavior: "smooth", top: 0 });
|
||||
});
|
||||
|
||||
it("should deselect staff member when undefined is passed", () => {
|
||||
expect.assertions(2);
|
||||
component.selectStaffMember("naomi");
|
||||
// @ts-expect-error - We want it protected, but need to use it in the test.
|
||||
expect(component.staffName(),
|
||||
"should have selected staff member").toBe("naomi");
|
||||
component.selectStaffMember(undefined);
|
||||
// @ts-expect-error - We want it protected, but need to use it in the test.
|
||||
expect(component.staffName(),
|
||||
"should deselect staff member").toBeUndefined();
|
||||
});
|
||||
|
||||
it("should get bio for selected staff member", () => {
|
||||
expect.assertions(2);
|
||||
const staffMember: StaffType = "naomi";
|
||||
component.selectStaffMember(staffMember);
|
||||
const bio = component.getBio();
|
||||
expect(bio, "should return bio array").toBeInstanceOf(Array);
|
||||
expect(bio.length, "bio should have content").toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should return empty array when no staff member is selected", () => {
|
||||
expect.assertions(1);
|
||||
const bio = component.getBio();
|
||||
expect(bio, "should return empty array").toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("should return correct bio for each staff member", () => {
|
||||
expect.assertions(8);
|
||||
const staffMembers: Array<StaffType> = [
|
||||
"naomi",
|
||||
"hikari",
|
||||
"amari",
|
||||
"keiko",
|
||||
"yumiko",
|
||||
"tatsumi",
|
||||
"reina",
|
||||
"minori",
|
||||
];
|
||||
for (const member of staffMembers) {
|
||||
component.selectStaffMember(member);
|
||||
const bio = component.getBio();
|
||||
expect(bio, `should return bio for ${member}`).toStrictEqual(bios[member]);
|
||||
}
|
||||
});
|
||||
|
||||
it("should get name for selected staff member", () => {
|
||||
expect.assertions(2);
|
||||
const staffMember: StaffType = "naomi";
|
||||
component.selectStaffMember(staffMember);
|
||||
const name = component.getName();
|
||||
expect(name, "should return name").toBeDefined();
|
||||
expect(name, "should return correct name").toBe(staffNames[staffMember]);
|
||||
});
|
||||
|
||||
it("should return undefined name when no staff member is selected", () => {
|
||||
expect.assertions(1);
|
||||
const name = component.getName();
|
||||
expect(name, "should return undefined").toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return correct name for each staff member", () => {
|
||||
expect.assertions(8);
|
||||
const staffMembers: Array<StaffType> = [
|
||||
"naomi",
|
||||
"hikari",
|
||||
"amari",
|
||||
"keiko",
|
||||
"yumiko",
|
||||
"tatsumi",
|
||||
"reina",
|
||||
"minori",
|
||||
];
|
||||
for (const member of staffMembers) {
|
||||
component.selectStaffMember(member);
|
||||
const name = component.getName();
|
||||
expect(name, `should return correct name for ${member}`).toBe(staffNames[member]);
|
||||
}
|
||||
});
|
||||
|
||||
it("should scroll to top when selecting staff member", () => {
|
||||
expect.assertions(1);
|
||||
component.selectStaffMember("naomi");
|
||||
expect(scrollToSpy, "should scroll to top").
|
||||
toHaveBeenCalledWith({ behavior: "smooth", top: 0 });
|
||||
});
|
||||
|
||||
it("should scroll to top when deselecting staff member", () => {
|
||||
expect.assertions(1);
|
||||
scrollToSpy.mockClear();
|
||||
component.selectStaffMember("naomi");
|
||||
scrollToSpy.mockClear();
|
||||
component.selectStaffMember(undefined);
|
||||
expect(scrollToSpy, "should scroll to top").
|
||||
toHaveBeenCalledWith({ behavior: "smooth", top: 0 });
|
||||
});
|
||||
|
||||
it("should render staff list when no member is selected", () => {
|
||||
expect.assertions(1);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const heading = compiled.querySelector("h1");
|
||||
expect(heading?.textContent.trim(),
|
||||
"should render staff list heading").toBe("OUR STAFF");
|
||||
});
|
||||
|
||||
it("should render staff member details when selected", () => {
|
||||
expect.assertions(3);
|
||||
component.selectStaffMember("naomi");
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const heading = compiled.querySelector("h1");
|
||||
const backButton = compiled.querySelector("button");
|
||||
expect(heading?.textContent.trim(),
|
||||
"should render staff member name").toBe(staffNames.naomi);
|
||||
expect(backButton?.textContent.trim(),
|
||||
"should render back button").toContain("Back to Staff");
|
||||
const paragraphs = compiled.querySelectorAll("p");
|
||||
expect(paragraphs.length, "should render bio paragraphs").
|
||||
toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should handle back button click", () => {
|
||||
expect.assertions(2);
|
||||
scrollToSpy.mockClear();
|
||||
component.selectStaffMember("naomi");
|
||||
scrollToSpy.mockClear();
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const backButton = compiled.querySelector("button") as HTMLButtonElement;
|
||||
backButton.click();
|
||||
fixture.detectChanges();
|
||||
// @ts-expect-error - We want it protected, but need to use it in the test.
|
||||
expect(component.staffName(),
|
||||
"should deselect staff member").toBeUndefined();
|
||||
expect(scrollToSpy, "should scroll to top").
|
||||
toHaveBeenCalledWith({ behavior: "smooth", top: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { Component, signal } from "@angular/core";
|
||||
import { bios } from "../config/bios";
|
||||
import { staffNames } from "../config/staffNames";
|
||||
import type { Staff as StaffType } from "../../interfaces/staff";
|
||||
|
||||
/**
|
||||
* Renders the staff page.
|
||||
*/
|
||||
@Component({
|
||||
selector: "app-staff",
|
||||
styleUrl: "./staff.css",
|
||||
templateUrl: "./staff.html",
|
||||
})
|
||||
export class Staff {
|
||||
protected staffName = signal<StaffType | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* Sets the selected staff member.
|
||||
* @param name - The name of the staff member to display.
|
||||
*/
|
||||
public selectStaffMember(name: StaffType | undefined): void {
|
||||
this.staffName.set(name);
|
||||
// Scroll to the top of the page
|
||||
globalThis.window.scrollTo({ behavior: "smooth", top: 0 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the bio for the selected staff member.
|
||||
* @returns The bio for the staff member.
|
||||
*/
|
||||
public getBio(): Array<string> {
|
||||
const name = this.staffName();
|
||||
if (name === undefined) {
|
||||
return [];
|
||||
}
|
||||
return bios[name];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the display name of the selected staff member.
|
||||
* @returns The name of the staff member.
|
||||
*/
|
||||
public getName(): string | undefined {
|
||||
const name = this.staffName();
|
||||
if (name === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return staffNames[name];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
.ticker-bg {
|
||||
background-color: var(--color-ticker);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.ticker-scroll {
|
||||
animation: scroll-horizontal 600s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes scroll-horizontal {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<div class="w-full overflow-hidden whitespace-nowrap z-30 py-2 ticker-bg">
|
||||
<div class="inline-block whitespace-nowrap ticker-scroll">
|
||||
@for (phrase of phrases; track phrase) {
|
||||
<span class="inline-block m-0 p-0 whitespace-nowrap">{{ phrase }}</span>
|
||||
<span class="inline-block mx-4 opacity-70"> • </span>
|
||||
}
|
||||
<!-- Duplicate content for seamless loop -->
|
||||
<span aria-hidden="true">
|
||||
@for (phrase of phrases; track phrase) {
|
||||
<span class="inline-block m-0 p-0 whitespace-nowrap">{{ phrase }}</span>
|
||||
<span class="inline-block mx-4 opacity-70"> • </span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { type ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { describe, beforeEach, it, expect } from "vitest";
|
||||
import { news } from "../config/news";
|
||||
import { Ticker } from "./ticker";
|
||||
|
||||
describe("ticker", () => {
|
||||
let component: Ticker;
|
||||
let fixture: ComponentFixture<Ticker>;
|
||||
|
||||
beforeEach(async() => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ Ticker ],
|
||||
}).
|
||||
compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(Ticker);
|
||||
component = fixture.componentInstance;
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
it("should create", () => {
|
||||
expect.assertions(1);
|
||||
expect(component, "did not compile").toBeTruthy();
|
||||
});
|
||||
|
||||
it("should initialize with shuffled phrases", () => {
|
||||
expect.assertions(2);
|
||||
// @ts-expect-error - We want it protected, but need to use it in the test.
|
||||
expect(component.phrases, "phrases should be initialized").toBeDefined();
|
||||
// @ts-expect-error - We want it protected, but need to use it in the test.
|
||||
expect(component.phrases, "phrases should have correct length").
|
||||
toHaveLength(news.length);
|
||||
});
|
||||
|
||||
it("should contain all news items in phrases", () => {
|
||||
expect.assertions(67);
|
||||
// @ts-expect-error - We want it protected, but need to use it in the test.
|
||||
const phrasesSet = new Set(component.phrases);
|
||||
const newsSet = new Set(news);
|
||||
expect(phrasesSet.size, "all news items should be present").
|
||||
toBe(newsSet.size);
|
||||
// @ts-expect-error - We want it protected, but need to use it in the test.
|
||||
for (const phrase of component.phrases) {
|
||||
expect(newsSet.has(phrase), `phrase "${phrase}" should be in news array`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("should render phrases in the template", () => {
|
||||
expect.assertions(2);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const phraseElements = compiled.querySelectorAll("span");
|
||||
expect(phraseElements.length, "should render phrase elements").
|
||||
toBeGreaterThan(0);
|
||||
// Check that at least one phrase from news is rendered
|
||||
const renderedText = compiled.textContent;
|
||||
// eslint-disable-next-line max-nested-callbacks -- We need to check if the rendered text includes any of the news phrases.
|
||||
const hasNewsContent = news.some((phrase) => {
|
||||
return renderedText.includes(phrase);
|
||||
});
|
||||
expect(hasNewsContent, "should render news content").toBeTruthy();
|
||||
});
|
||||
|
||||
it("should render duplicate content for seamless loop", () => {
|
||||
expect.assertions(1);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const ariaHiddenSection = compiled.querySelector("[aria-hidden=\"true\"]");
|
||||
expect(ariaHiddenSection, "should have duplicate content section").
|
||||
toBeTruthy();
|
||||
});
|
||||
|
||||
it("should shuffle phrases differently on each instantiation", () => {
|
||||
expect.assertions(1);
|
||||
const firstComponent = new Ticker();
|
||||
const secondComponent = new Ticker();
|
||||
// @ts-expect-error - We want it protected, but need to use it in the test.
|
||||
const firstPhrases = firstComponent.phrases;
|
||||
// @ts-expect-error - We want it protected, but need to use it in the test.
|
||||
const secondPhrases = secondComponent.phrases;
|
||||
const firstSet = new Set(firstPhrases);
|
||||
const secondSet = new Set(secondPhrases);
|
||||
expect(firstSet.size, "both instances should have same content").
|
||||
toBe(secondSet.size);
|
||||
});
|
||||
|
||||
it("should handle empty news array gracefully", () => {
|
||||
expect.assertions(1);
|
||||
|
||||
/*
|
||||
* This test verifies the component can handle edge cases
|
||||
* Since news is always populated, we'll just verify the component handles initialization
|
||||
*/
|
||||
// @ts-expect-error - We want it protected, but need to use it in the test.
|
||||
expect(component.phrases, "phrases should be an array").
|
||||
toBeInstanceOf(Array);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { Component } from "@angular/core";
|
||||
import { news } from "../config/news";
|
||||
|
||||
/**
|
||||
* Renders the ticker.
|
||||
*/
|
||||
@Component({
|
||||
imports: [],
|
||||
selector: "app-ticker",
|
||||
styleUrl: "./ticker.css",
|
||||
templateUrl: "./ticker.html",
|
||||
})
|
||||
export class Ticker {
|
||||
/* eslint-disable stylistic/max-len -- Ticker phrases need to be complete thoughts. */
|
||||
protected readonly phrases: ReadonlyArray<string>;
|
||||
|
||||
/**
|
||||
* Initializes the ticker component and shuffles the phrases.
|
||||
*/
|
||||
public constructor() {
|
||||
this.phrases = Ticker.shuffleArray([ ...news ]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuffles an array using the Fisher-Yates algorithm.
|
||||
* @param array - The array to shuffle.
|
||||
* @returns A new shuffled array.
|
||||
*/
|
||||
private static shuffleArray<T>(array: Array<T>): ReadonlyArray<T> {
|
||||
const shuffled = [ ...array ];
|
||||
for (let index = shuffled.length - 1; index > 0; index = index - 1) {
|
||||
const randomIndex = Math.floor(Math.random() * (index + 1));
|
||||
[ shuffled[index], shuffled[randomIndex] ] = [ shuffled[randomIndex], shuffled[index] ];
|
||||
}
|
||||
return shuffled;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user