diff --git a/README.md b/README.md
index b2bc9ba..309707a 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,31 @@
-# dead-simple-crm
-A minimal, opinionated CRM that does contacts, companies, deals, and notes — nothing more. Clean UI, fast search, CSV import/export. Built for people who want a CRM without the bloat of Salesforce or HubSpot. Every feature is a discrete bounty: filters, email integration, pipeline views, reporting.
+# Dead Simple CRM
+
+A minimal CRM contact-list view with sortable columns, pagination, and a clean no-build frontend.
+
+## Bounty scope
+
+Shipyard bounty `bnt_6YVRvFJX`: **Build contacts list view**
+
+Implemented:
+
+- Contacts table with columns for name, email, company, and last updated.
+- Clickable sortable column headers with ascending/descending state.
+- Paginated contact rows with next/previous controls and range text.
+- Minimal responsive design and an app thumbnail at `public/thumbnail.svg`.
+- Unit coverage for sort and pagination behavior.
+
+## Run locally
+
+Open `index.html` in a browser, or serve the folder with any static server:
+
+```bash
+python -m http.server 4173
+```
+
+Then visit `http://localhost:4173`.
+
+## Verify
+
+```bash
+npm test
+```
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..2ef7037
--- /dev/null
+++ b/index.html
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+
+
+ Dead Simple CRM
+
+
+
+
+
+
Dead Simple CRM
+
Contacts
+
+ A focused contact list for people who want names, companies, and follow-up freshness without CRM bloat.
+
+
+
+
+
+
+
+
+
+
+
+
+ Name
+ Email
+ Company
+ Last updated
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..c3eb3a8
--- /dev/null
+++ b/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "dead-simple-crm",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "test": "node tests/contact-utils.test.mjs"
+ }
+}
diff --git a/public/thumbnail.svg b/public/thumbnail.svg
new file mode 100644
index 0000000..6eed632
--- /dev/null
+++ b/public/thumbnail.svg
@@ -0,0 +1,37 @@
+
+ Dead Simple CRM contact list thumbnail
+ A dark slate dashboard card with contact rows, soft green highlights, and the words Dead Simple CRM.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Dead Simple CRM
+ Sortable contacts. Clean follow-up.
+
diff --git a/src/contact-utils.js b/src/contact-utils.js
new file mode 100644
index 0000000..4db1e7e
--- /dev/null
+++ b/src/contact-utils.js
@@ -0,0 +1,44 @@
+export function sortContacts(contacts, sortKey, direction = "asc") {
+ const multiplier = direction === "desc" ? -1 : 1;
+
+ return [...contacts].sort((left, right) => {
+ const leftValue = normalizeSortValue(left[sortKey], sortKey);
+ const rightValue = normalizeSortValue(right[sortKey], sortKey);
+
+ if (leftValue < rightValue) return -1 * multiplier;
+ if (leftValue > rightValue) return 1 * multiplier;
+ return left.name.localeCompare(right.name);
+ });
+}
+
+export function paginateContacts(contacts, page, pageSize) {
+ const totalPages = Math.max(1, Math.ceil(contacts.length / pageSize));
+ const safePage = Math.min(Math.max(1, page), totalPages);
+ const start = (safePage - 1) * pageSize;
+
+ return {
+ rows: contacts.slice(start, start + pageSize),
+ page: safePage,
+ pageSize,
+ totalPages,
+ totalRows: contacts.length,
+ rangeStart: contacts.length === 0 ? 0 : start + 1,
+ rangeEnd: Math.min(start + pageSize, contacts.length)
+ };
+}
+
+export function formatUpdatedDate(value) {
+ return new Intl.DateTimeFormat("en", {
+ month: "short",
+ day: "numeric",
+ year: "numeric"
+ }).format(new Date(value));
+}
+
+function normalizeSortValue(value, sortKey) {
+ if (sortKey === "lastUpdated") {
+ return new Date(value).getTime();
+ }
+
+ return String(value ?? "").toLowerCase();
+}
diff --git a/src/main.js b/src/main.js
new file mode 100644
index 0000000..1397fa2
--- /dev/null
+++ b/src/main.js
@@ -0,0 +1,92 @@
+import { formatUpdatedDate, paginateContacts, sortContacts } from "./contact-utils.js";
+
+const contacts = [
+ { name: "Maya Patel", email: "maya@northstar.dev", company: "Northstar Labs", lastUpdated: "2026-05-26T14:05:00Z" },
+ { name: "Jon Bell", email: "jon@quietcrm.co", company: "Quiet CRM", lastUpdated: "2026-05-24T10:30:00Z" },
+ { name: "Sofia Chen", email: "sofia@ledgerloop.io", company: "LedgerLoop", lastUpdated: "2026-05-28T18:15:00Z" },
+ { name: "Arun Desai", email: "arun@fieldbase.ai", company: "Fieldbase", lastUpdated: "2026-05-22T09:45:00Z" },
+ { name: "Elena Torres", email: "elena@dockwise.app", company: "Dockwise", lastUpdated: "2026-05-21T16:20:00Z" },
+ { name: "Nate Brooks", email: "nate@signalforge.dev", company: "Signal Forge", lastUpdated: "2026-05-20T11:00:00Z" },
+ { name: "Priya Rao", email: "priya@brightstack.co", company: "Brightstack", lastUpdated: "2026-05-27T12:10:00Z" },
+ { name: "Theo Martin", email: "theo@yardbird.tools", company: "Yardbird Tools", lastUpdated: "2026-05-19T08:25:00Z" },
+ { name: "Grace Okafor", email: "grace@orbitnotes.com", company: "Orbit Notes", lastUpdated: "2026-05-23T13:35:00Z" },
+ { name: "Iris Walker", email: "iris@cloverbase.io", company: "Cloverbase", lastUpdated: "2026-05-25T15:50:00Z" },
+ { name: "Sam Rivera", email: "sam@tinyops.dev", company: "TinyOps", lastUpdated: "2026-05-18T17:05:00Z" },
+ { name: "Leah Kim", email: "leah@humblepipeline.com", company: "Humble Pipeline", lastUpdated: "2026-05-29T07:40:00Z" }
+];
+
+const state = {
+ page: 1,
+ pageSize: 5,
+ sortKey: "lastUpdated",
+ sortDirection: "desc"
+};
+
+const contactsBody = document.querySelector("#contacts-body");
+const pageSizeSelect = document.querySelector("#page-size");
+const pageText = document.querySelector("#page-text");
+const rangeText = document.querySelector("#range-text");
+const previousButton = document.querySelector("#prev-page");
+const nextButton = document.querySelector("#next-page");
+const sortButtons = [...document.querySelectorAll(".sort-button")];
+
+function render() {
+ const sortedContacts = sortContacts(contacts, state.sortKey, state.sortDirection);
+ const pageData = paginateContacts(sortedContacts, state.page, state.pageSize);
+ state.page = pageData.page;
+
+ contactsBody.innerHTML = pageData.rows
+ .map(
+ (contact) => `
+
+
+ ${contact.name}
+
+ ${contact.email}
+ ${contact.company}
+ ${formatUpdatedDate(contact.lastUpdated)}
+
+ `
+ )
+ .join("");
+
+ pageText.textContent = `Page ${pageData.page} of ${pageData.totalPages}`;
+ rangeText.textContent = `Showing ${pageData.rangeStart}-${pageData.rangeEnd} of ${pageData.totalRows} contacts`;
+ previousButton.disabled = pageData.page === 1;
+ nextButton.disabled = pageData.page === pageData.totalPages;
+
+ sortButtons.forEach((button) => {
+ const icon = button.querySelector("span");
+ const isActive = button.dataset.sortKey === state.sortKey;
+ button.setAttribute("aria-sort", isActive ? state.sortDirection : "none");
+ icon.textContent = isActive ? (state.sortDirection === "asc" ? "↑" : "↓") : "";
+ });
+}
+
+sortButtons.forEach((button) => {
+ button.addEventListener("click", () => {
+ const nextKey = button.dataset.sortKey;
+ state.sortDirection = state.sortKey === nextKey && state.sortDirection === "asc" ? "desc" : "asc";
+ state.sortKey = nextKey;
+ state.page = 1;
+ render();
+ });
+});
+
+pageSizeSelect.addEventListener("change", (event) => {
+ state.pageSize = Number(event.target.value);
+ state.page = 1;
+ render();
+});
+
+previousButton.addEventListener("click", () => {
+ state.page -= 1;
+ render();
+});
+
+nextButton.addEventListener("click", () => {
+ state.page += 1;
+ render();
+});
+
+render();
diff --git a/src/styles.css b/src/styles.css
new file mode 100644
index 0000000..1f51bef
--- /dev/null
+++ b/src/styles.css
@@ -0,0 +1,189 @@
+:root {
+ color-scheme: dark;
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ background: #0f172a;
+ color: #e5edf7;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ min-height: 100vh;
+ background:
+ radial-gradient(circle at top right, rgba(34, 197, 94, 0.2), transparent 34rem),
+ radial-gradient(circle at bottom left, rgba(56, 189, 248, 0.13), transparent 30rem),
+ #0f172a;
+}
+
+button,
+select {
+ font: inherit;
+}
+
+.shell {
+ width: min(1120px, calc(100% - 32px));
+ margin: 0 auto;
+ padding: 48px 0;
+}
+
+.hero {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 24px;
+ margin-bottom: 28px;
+}
+
+.eyebrow {
+ margin: 0 0 8px;
+ color: #86efac;
+ font-size: 0.82rem;
+ font-weight: 800;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+}
+
+h1,
+h2,
+p {
+ margin-top: 0;
+}
+
+h1 {
+ margin-bottom: 10px;
+ font-size: clamp(2.5rem, 8vw, 5.5rem);
+ line-height: 0.92;
+}
+
+h2 {
+ margin-bottom: 6px;
+}
+
+.lede {
+ max-width: 620px;
+ margin-bottom: 0;
+ color: #a8b3c7;
+ font-size: 1.08rem;
+}
+
+.thumbnail {
+ width: min(280px, 30vw);
+ border-radius: 22px;
+ box-shadow: 0 24px 90px rgba(0, 0, 0, 0.35);
+}
+
+.panel {
+ overflow: hidden;
+ border: 1px solid #263244;
+ border-radius: 24px;
+ background: rgba(15, 23, 42, 0.78);
+ box-shadow: 0 24px 80px rgba(0, 0, 0, 0.24);
+}
+
+.panel__header,
+.pagination {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 20px 22px;
+}
+
+.muted {
+ margin: 0;
+ color: #94a3b8;
+}
+
+.page-size {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: #cbd5e1;
+}
+
+select,
+.pagination button {
+ border: 1px solid #334155;
+ border-radius: 999px;
+ background: #111827;
+ color: #f8fafc;
+ padding: 8px 12px;
+}
+
+.pagination button {
+ cursor: pointer;
+ min-width: 92px;
+}
+
+.pagination button:disabled {
+ cursor: not-allowed;
+ opacity: 0.4;
+}
+
+.table-wrap {
+ overflow-x: auto;
+}
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+th,
+td {
+ border-top: 1px solid #263244;
+ padding: 16px 22px;
+ text-align: left;
+ white-space: nowrap;
+}
+
+th {
+ background: rgba(17, 24, 39, 0.76);
+}
+
+tbody tr:hover {
+ background: rgba(34, 197, 94, 0.05);
+}
+
+a {
+ color: #7dd3fc;
+ text-decoration: none;
+}
+
+a:hover {
+ text-decoration: underline;
+}
+
+.sort-button {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ border: 0;
+ background: transparent;
+ color: #cbd5e1;
+ cursor: pointer;
+ font-weight: 800;
+ padding: 0;
+}
+
+.sort-button span {
+ min-width: 1ch;
+ color: #86efac;
+}
+
+@media (max-width: 760px) {
+ .hero,
+ .panel__header,
+ .pagination {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .thumbnail {
+ width: 100%;
+ max-width: 360px;
+ }
+}
diff --git a/tests/contact-utils.test.mjs b/tests/contact-utils.test.mjs
new file mode 100644
index 0000000..64725ab
--- /dev/null
+++ b/tests/contact-utils.test.mjs
@@ -0,0 +1,39 @@
+import assert from "node:assert/strict";
+import { paginateContacts, sortContacts } from "../src/contact-utils.js";
+
+const fixtures = [
+ { name: "Beta", email: "beta@example.com", company: "B Co", lastUpdated: "2026-05-01T00:00:00Z" },
+ { name: "Alpha", email: "alpha@example.com", company: "A Co", lastUpdated: "2026-05-03T00:00:00Z" },
+ { name: "Gamma", email: "gamma@example.com", company: "C Co", lastUpdated: "2026-05-02T00:00:00Z" }
+];
+
+assert.deepEqual(
+ sortContacts(fixtures, "name", "asc").map((contact) => contact.name),
+ ["Alpha", "Beta", "Gamma"],
+ "sorts contact names ascending"
+);
+
+assert.deepEqual(
+ sortContacts(fixtures, "lastUpdated", "desc").map((contact) => contact.name),
+ ["Alpha", "Gamma", "Beta"],
+ "sorts last updated dates descending"
+);
+
+assert.deepEqual(
+ paginateContacts(fixtures, 2, 2),
+ {
+ rows: [fixtures[2]],
+ page: 2,
+ pageSize: 2,
+ totalPages: 2,
+ totalRows: 3,
+ rangeStart: 3,
+ rangeEnd: 3
+ },
+ "paginates contacts and reports row range"
+);
+
+assert.equal(paginateContacts(fixtures, 99, 2).page, 2, "clamps page to the last page");
+assert.equal(paginateContacts([], 1, 5).rangeStart, 0, "empty pagination has zero range start");
+
+console.log("contact utilities: ok");