Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 38 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ signups into the `waitlist` table.

## Card production

Two decks, one per series. Approved portrait art lives under
Three decks, one per series. Approved portrait art lives under
`assets/portraits/<set>/` — see [assets/portraits/README.md](assets/portraits/README.md).

**Series One — Open Source Legends** (`data/roster.locked.json`, complete):
Expand Down Expand Up @@ -78,35 +78,55 @@ Chromium — set `CHROME_PATH` if it is not on the usual paths.
`dist/hacking/` is gitignored, so the full-resolution faces are local only.
Archive them before wiping the directory if you want print masters.

**Series Three — Security Professionals** (`src/data/security.ts`, roster only):

50 hand-curated cards for the defensive side of the field. Copy and sources are
written; no art pipeline exists yet, so the set renders as a data-only roster at
`/security-professionals` and its cards have no `front`/`back` and no card pages.
When art starts, copy `scripts/hacking-legends.mjs` — the data module already
carries every field that pipeline reads.

Nobody appears in more than one series; `src/data/roster.ts` holds the shared card
vocabulary that Series Two and Three both render through.

## Project layout

```
src/
app/
page.tsx # landing page
cards/ # the full set gallery
collect/ # physical packs · NFT mint · print-your-own
contribute/ # how to nominate / add a legend
globals.css # design tokens + utilities
layout.tsx # header/footer shell + metadata
page.tsx # landing page
cards/ # Series One gallery + card pages
hacking-legends/ # Series Two roster + card pages
security-professionals/ # Series Three roster
collect/ # physical packs · NFT mint · print-your-own
contribute/ # how to nominate / add a legend
globals.css # design tokens + utilities
layout.tsx # header/footer shell + metadata
components/
LegendCard.tsx # the trading-card component (matches the print design)
Header / Footer / WaitlistForm
CardFlip.tsx # Series One flip card
RosterCard.tsx # shared roster tile for Series Two and Three
CardDetail.tsx # the card page body
Header / Footer / WaitlistForm / AdUnit
data/
legends.ts # the card data — one typed record per legend
site.ts # site config (name, links, license)
roster.ts # shared card vocabulary (rarity, status, sources)
cards.ts # Series One — generated from the locked roster
hacking.ts # Series Two — hand-curated
security.ts # Series Three — hand-curated
site.ts # site config (name, links, license)
public/
crest.svg # the Open Source Legends crest
cards/ # optional portrait art: /cards/<slug>.jpg
docs/ # print proofs / design references
crest.svg # the Open Source Legends crest
cards/ # rendered card faces, one directory per series
docs/ # print proofs / design references
```

## Add a legend

Append a typed record to `src/data/legends.ts` (see the spec on the
`/contribute` page) and, optionally, drop portrait art at
`public/cards/<slug>.jpg`. Cards with no portrait render a monogram fallback.
Then open a pull request.
Append a typed record to the data module for the series — `src/data/hacking.ts`
for Series Two, `src/data/security.ts` for Series Three (see the spec on the
`/contribute` page). Every entry needs at least one public source for the claims
in its scouting report, and no name may appear in more than one series. Art is
generated later by the publish step, so leave `front`/`back` off. Then open a
pull request.

## License

Expand Down
13 changes: 9 additions & 4 deletions src/app/contribute/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,15 @@ export default function ContributePage() {
<span className="kicker">The card spec</span>
<h2 className={styles.h2}>One legend, one record</h2>
<p className={styles.specSub}>
Series Two is the set currently taking nominations. Its roster is
hand-curated in <span className="mono">src/data/hacking.ts</span>, and that
file is the source of truth — the pipeline reads it directly, so there is no
second copy to keep in sync.
Series Two and Series Three are both taking nominations. Each roster is
hand-curated in one file — <span className="mono">src/data/hacking.ts</span>{' '}
for Hacking Legends, <span className="mono">src/data/security.ts</span> for
Security Professionals — and that file is the source of truth: the pipeline
reads it directly, so there is no second copy to keep in sync. Series Three
swaps the <span className="mono">social</span> and{' '}
<span className="mono">notoriety</span> bars for{' '}
<span className="mono">defense</span> and{' '}
<span className="mono">research</span>, and no name may appear in two sets.
</p>
</div>
<pre className={styles.code}>
Expand Down
2 changes: 1 addition & 1 deletion src/app/hacking-legends/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ export default function HackingLegendsPage() {

<div className={styles.grid}>
{hackers.map((h) => (
<RosterCard key={h.slug} hacker={h} />
<RosterCard key={h.slug} entry={h} basePath="/hacking-legends" />
))}
</div>
</div>
Expand Down
24 changes: 24 additions & 0 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import CardFlip from '@/components/CardFlip';
import AdUnit from '@/components/AdUnit';
import { cards, featured } from '@/data/cards';
import { hackers, illustratedCount, lockedCount } from '@/data/hacking';
import { pros, totalPlanned as proTotal } from '@/data/security';
import { site } from '@/data/site';
import styles from './page.module.css';

Expand Down Expand Up @@ -103,6 +104,29 @@ export default function Home() {
</div>
</section>

{/* Series Three teaser */}
<section className="section">
<div className="container">
<div className={styles.bandInner}>
<div>
<span className="kicker">Series Three · In progress</span>
<h2 className={styles.h2}>Security Professionals</h2>
<p>
And the half that had to clean up: cryptographers, firewall builders,
incident responders, tool authors and the security chiefs who answered for
the breach. {pros.length} of {proTotal} names drafted, art not started, and
the cuts are still up for argument.
</p>
</div>
<div className={styles.bandActions}>
<Link href="/security-professionals" className="btn-primary">
See the roster →
</Link>
</div>
</div>
</div>
</section>

{/* In-content ad */}
<section className="section">
<div className="container">
Expand Down
248 changes: 248 additions & 0 deletions src/app/security-professionals/page.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
/* Hero */
.hero {
padding: 8rem 0 5rem;
background:
radial-gradient(70rem 34rem at 20% -20%, rgba(37, 194, 110, 0.10), transparent 65%),
transparent;
}

.kicker {
display: inline-flex;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
}

.wip {
font-size: 1.05rem;
letter-spacing: 0.16em;
color: var(--rare);
border: 1px solid color-mix(in srgb, var(--rare) 45%, var(--border));
border-radius: 999px;
padding: 0.3rem 0.9rem;
}

.title {
font-size: clamp(3.6rem, 6.5vw, 7rem);
font-weight: 800;
letter-spacing: -0.035em;
margin-bottom: 2rem;
}

.lede {
font-size: 1.8rem;
color: var(--text-muted);
max-width: 68rem;
margin-bottom: 1.8rem;
}

.warning {
font-size: 1.4rem;
color: var(--text-dim);
max-width: 68rem;
border-left: 2px solid var(--crest);
padding-left: 1.6rem;
margin-bottom: 3rem;
}

.actions {
display: flex;
flex-wrap: wrap;
gap: 1.2rem;
}

/* Progress strip */
.progress {
border-top: 1px solid var(--border-soft);
border-bottom: 1px solid var(--border-soft);
background: var(--bg-deep);
}

.progressInner {
display: grid;
grid-template-columns: 1.3fr 1fr;
gap: 4rem;
padding-top: 4rem;
padding-bottom: 4rem;
}

.bars {
display: flex;
flex-direction: column;
gap: 2rem;
align-self: center;
}

.barRow {
display: grid;
grid-template-columns: 16rem 1fr 6rem;
align-items: center;
gap: 1.6rem;
}

.barLabel {
font-size: 1.35rem;
color: var(--text-muted);
}

.barTrack {
height: 0.6rem;
border-radius: 999px;
background: var(--bg-elev-2);
overflow: hidden;
}

.barFill {
display: block;
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, var(--crest-deep), var(--crest));
}

.barVal {
font-size: 1.3rem;
font-weight: 700;
color: var(--crest);
text-align: right;
}

.aside {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 2.6rem;
}

.asideHead {
font-size: 1.7rem;
font-weight: 800;
margin-bottom: 1.4rem;
}

.steps {
display: flex;
flex-direction: column;
gap: 1rem;
padding-left: 2rem;
font-size: 1.4rem;
color: var(--text-muted);
}

/* Sections */
.head {
max-width: 70rem;
margin-bottom: 3.6rem;
}

.h2 {
font-size: clamp(2.8rem, 3.5vw, 4rem);
font-weight: 800;
margin-top: 1rem;
}

.sub {
font-size: 1.5rem;
color: var(--text-muted);
margin-top: 1.2rem;
}

.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(30rem, 1fr));
gap: 2.2rem;
align-items: start;
}

/* Criteria */
.criteria {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 2.2rem;
}

.criterion {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 2.8rem;
}

.criterionTag {
display: inline-block;
font-size: 1.05rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--crest);
border: 1px solid var(--border);
border-radius: 999px;
padding: 0.4rem 1rem;
margin-bottom: 1.8rem;
}

.criterion h3 {
font-size: 2rem;
font-weight: 800;
margin-bottom: 1rem;
}

.criterion p {
font-size: 1.45rem;
color: var(--text-muted);
}

/* Band */
.band {
padding: 4rem 0 8rem;
}

.bandInner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 3rem;
background:
radial-gradient(80% 140% at 100% 0%, rgba(37, 194, 110, 0.10), transparent 60%),
var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 4rem;
}

.bandInner p {
color: var(--text-muted);
font-size: 1.5rem;
max-width: 56rem;
margin-top: 1rem;
}

.bandActions {
display: flex;
flex-wrap: wrap;
gap: 1.2rem;
}

@media (max-width: 980px) {
.progressInner {
grid-template-columns: 1fr;
gap: 3rem;
}
.criteria {
grid-template-columns: 1fr;
}
}

@media (max-width: 680px) {
.barRow {
grid-template-columns: 1fr 5rem;
row-gap: 0.8rem;
}
.barLabel {
grid-column: 1 / -1;
}
.bandInner {
flex-direction: column;
align-items: flex-start;
padding: 2.8rem;
}
}
Loading
Loading