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,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];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user