feat: vertical helix with projects bound to strands

Reshape the landing so each project anchors to a slot on one of the two
helix strands. Strand A = Projects (durable, in-production), strand B =
Innovations (experiments, prototypes). Cards alternate L/R based on the
strand's instantaneous position at the slot — strand identity is shown
by colour + badge, not by side.

Schema
- ALTER projects ADD strand TEXT NOT NULL DEFAULT 'A' CHECK ('A','B').
  Generated as drizzle/0001_*.sql.

Component
- New VerticalHelix.svelte replaces Helix.svelte on the landing.
- Slot Y = 120 + i * 240; period = 480, so each strand is at an extreme
  at every slot. Node sits at the strand's actual x; card hugs that side.
- Pulsing node halos and animated gradient stops give "alive" feeling
  without moving the strand geometry — projects stay anchored.
- prefers-reduced-motion disables the pulse.
- <760px: SVG hides, cards stack full-width.

Authoring
- /projects/new form gets a Project / Innovation radio picker.
- Seed adds an "DNA Scout" example on strand B so the helix renders with
  both strands populated on first boot.

Landing
- Hero shrinks (60vh, no helix backdrop) and gains a strand legend.
- Top 12 projects bind to the helix; "See all N →" appears if there's
  more.
- Posts band becomes compact (3 most recent), below the helix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rene De Ren
2026-05-20 11:24:41 +02:00
parent 103ba3cb5d
commit 408cf4460a
10 changed files with 1312 additions and 89 deletions

View File

@@ -0,0 +1 @@
ALTER TABLE `projects` ADD `strand` text DEFAULT 'A' NOT NULL;

View File

@@ -0,0 +1,568 @@
{
"version": "6",
"dialect": "sqlite",
"id": "037869a7-f3d4-43e6-802c-df7619bf4e3e",
"prevId": "4743e43c-8793-465c-9bd5-2e1c50f54396",
"tables": {
"post_tags": {
"name": "post_tags",
"columns": {
"post_id": {
"name": "post_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"tag_id": {
"name": "tag_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"post_tags_post_id_posts_id_fk": {
"name": "post_tags_post_id_posts_id_fk",
"tableFrom": "post_tags",
"tableTo": "posts",
"columnsFrom": [
"post_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"post_tags_tag_id_tags_id_fk": {
"name": "post_tags_tag_id_tags_id_fk",
"tableFrom": "post_tags",
"tableTo": "tags",
"columnsFrom": [
"tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"post_tags_post_id_tag_id_pk": {
"columns": [
"post_id",
"tag_id"
],
"name": "post_tags_post_id_tag_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"posts": {
"name": "posts",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"summary": {
"name": "summary",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"body_md": {
"name": "body_md",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"author_id": {
"name": "author_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"published_at": {
"name": "published_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"posts_slug_unique": {
"name": "posts_slug_unique",
"columns": [
"slug"
],
"isUnique": true
}
},
"foreignKeys": {
"posts_author_id_users_id_fk": {
"name": "posts_author_id_users_id_fk",
"tableFrom": "posts",
"tableTo": "users",
"columnsFrom": [
"author_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"project_links": {
"name": "project_links",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"position": {
"name": "position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"project_links_project_id_projects_id_fk": {
"name": "project_links_project_id_projects_id_fk",
"tableFrom": "project_links",
"tableTo": "projects",
"columnsFrom": [
"project_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"project_tags": {
"name": "project_tags",
"columns": {
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"tag_id": {
"name": "tag_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"project_tags_project_id_projects_id_fk": {
"name": "project_tags_project_id_projects_id_fk",
"tableFrom": "project_tags",
"tableTo": "projects",
"columnsFrom": [
"project_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"project_tags_tag_id_tags_id_fk": {
"name": "project_tags_tag_id_tags_id_fk",
"tableFrom": "project_tags",
"tableTo": "tags",
"columnsFrom": [
"tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"project_tags_project_id_tag_id_pk": {
"columns": [
"project_id",
"tag_id"
],
"name": "project_tags_project_id_tag_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"projects": {
"name": "projects",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"summary": {
"name": "summary",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"body_md": {
"name": "body_md",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"cover_url": {
"name": "cover_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"strand": {
"name": "strand",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'A'"
},
"author_id": {
"name": "author_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'published'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"projects_slug_unique": {
"name": "projects_slug_unique",
"columns": [
"slug"
],
"isUnique": true
}
},
"foreignKeys": {
"projects_author_id_users_id_fk": {
"name": "projects_author_id_users_id_fk",
"tableFrom": "projects",
"tableTo": "users",
"columnsFrom": [
"author_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tags": {
"name": "tags",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"tags_name_unique": {
"name": "tags_name_unique",
"columns": [
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"gitea_id": {
"name": "gitea_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"avatar_url": {
"name": "avatar_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'editor'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"users_gitea_id_unique": {
"name": "users_gitea_id_unique",
"columns": [
"gitea_id"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -8,6 +8,13 @@
"when": 1779267485591, "when": 1779267485591,
"tag": "0000_giant_mother_askani", "tag": "0000_giant_mother_askani",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1779268639045,
"tag": "0001_sturdy_mysterio",
"breakpoints": true
} }
] ]
} }

View File

@@ -36,6 +36,7 @@ const projects = [
'- Used at Waterschap Brabantse Delta for plant simulation and live control' '- Used at Waterschap Brabantse Delta for plant simulation and live control'
].join('\n'), ].join('\n'),
cover_url: null, cover_url: null,
strand: 'A',
status: 'published' status: 'published'
}, },
{ {
@@ -52,7 +53,7 @@ const projects = [
'## Stack', '## Stack',
'', '',
'- **SvelteKit 2** + **Svelte 5** + TypeScript', '- **SvelteKit 2** + **Svelte 5** + TypeScript',
'- **Tailwind v4** (CSS-first design tokens)', '- **Tailwind v3** + CSS-first design tokens',
'- **SQLite** + **Drizzle ORM** — single-file, easy to back up', '- **SQLite** + **Drizzle ORM** — single-file, easy to back up',
'- **Gitea OAuth** for authoring', '- **Gitea OAuth** for authoring',
'- Pure SVG + CSS for the helix animation — no WebGL', '- Pure SVG + CSS for the helix animation — no WebGL',
@@ -62,6 +63,34 @@ const projects = [
'Innovations were scattered: Gitea repos here, Grafana dashboards there, slide decks elsewhere. HELIX is the strand they share.' 'Innovations were scattered: Gitea repos here, Grafana dashboards there, slide decks elsewhere. HELIX is the strand they share.'
].join('\n'), ].join('\n'),
cover_url: null, cover_url: null,
strand: 'A',
status: 'published'
},
{
id: 'prj_dna_scout',
slug: 'dna-scout',
title: 'DNA Scout',
summary:
'Experimental anomaly-detection prototype that fingerprints reactor telemetry. R&D scout, not yet plant-ready.',
body_md: [
'# DNA Scout',
'',
"An experimental innovation: build short \"fingerprints\" of reactor telemetry to spot anomalies before the operator does. Think of it as a sequence read on the plant's metabolism.",
'',
'## Status',
'',
"Prototype. Runs on bench data from one reactor at the WBD test site. Not production-ready — accuracy on overflow events is still 60-something percent.",
'',
'## Idea',
'',
'- Slide a 5-minute window across all telemetry channels',
'- Hash each window into a compact descriptor',
'- Cluster descriptors → anomalies fall outside the dense clusters',
'',
'Belongs on strand B (Innovation) — it might never ship, and that\'s fine. If it does, it graduates to a Project.'
].join('\n'),
cover_url: null,
strand: 'B',
status: 'published' status: 'published'
} }
]; ];
@@ -113,9 +142,9 @@ const posts = [
const insertProject = db.prepare(` const insertProject = db.prepare(`
INSERT OR IGNORE INTO projects INSERT OR IGNORE INTO projects
(id, slug, title, summary, body_md, cover_url, author_id, status, created_at, updated_at) (id, slug, title, summary, body_md, cover_url, strand, author_id, status, created_at, updated_at)
VALUES VALUES
(@id, @slug, @title, @summary, @body_md, @cover_url, NULL, @status, ${now}, ${now}) (@id, @slug, @title, @summary, @body_md, @cover_url, @strand, NULL, @status, ${now}, ${now})
`); `);
const insertLink = db.prepare(` const insertLink = db.prepare(`
INSERT OR IGNORE INTO project_links INSERT OR IGNORE INTO project_links

View File

@@ -0,0 +1,427 @@
<script lang="ts">
/**
* Vertical DNA helix where each project binds to a slot on a strand.
*
* Strand A = Projects (durable / in-production)
* Strand B = Innovations (experiments / prototypes / scouts)
*
* Each project sits at slot Y = TOP_PAD + i * SLOT_HEIGHT, at the
* instantaneous x of its strand. Card sits on whichever side the node is.
* Strand colour + badge encode strand identity regardless of L/R position.
*
* Animation: gradient stops drift along the strands + node pulse.
* The strand geometry itself is static — keeps project anchors stable.
*/
type StrandProject = {
slug: string;
title: string;
summary: string;
strand: 'A' | 'B';
coverUrl: string | null;
};
let { projects }: { projects: StrandProject[] } = $props();
// Geometry constants (user-space units)
const W = 1000;
const CX = W / 2;
const AMP = 140;
const TOP_PAD = 120;
const BOTTOM_PAD = 120;
const SLOT_HEIGHT = 240;
const PERIOD = SLOT_HEIGHT * 2;
const k = (2 * Math.PI) / PERIOD;
type Rung = { y: number; x1: number; x2: number; depth: number; aFront: boolean };
type Slot = {
project: StrandProject;
index: number;
y: number;
nodeX: number;
side: 'left' | 'right';
};
const H = $derived(TOP_PAD + Math.max(1, projects.length) * SLOT_HEIGHT + BOTTOM_PAD);
const strandA = $derived.by(() => {
const steps = Math.max(80, Math.floor(H / 4));
const parts: string[] = [];
for (let i = 0; i <= steps; i++) {
const y = (H / steps) * i;
const x = CX + AMP * Math.sin(k * y);
parts.push(`${i === 0 ? 'M' : 'L'}${x.toFixed(2)} ${y.toFixed(2)}`);
}
return parts.join(' ');
});
const strandB = $derived.by(() => {
const steps = Math.max(80, Math.floor(H / 4));
const parts: string[] = [];
for (let i = 0; i <= steps; i++) {
const y = (H / steps) * i;
const x = CX - AMP * Math.sin(k * y);
parts.push(`${i === 0 ? 'M' : 'L'}${x.toFixed(2)} ${y.toFixed(2)}`);
}
return parts.join(' ');
});
const rungs = $derived.by<Rung[]>(() => {
const out: Rung[] = [];
const RUNG_SPACING = 22;
for (let y = 0; y <= H; y += RUNG_SPACING) {
const sA = Math.sin(k * y);
out.push({
y,
x1: CX + AMP * sA,
x2: CX - AMP * sA,
depth: Math.abs(sA),
aFront: sA > 0
});
}
return out;
});
const slots = $derived<Slot[]>(
projects.map((p, i) => {
const y = TOP_PAD + i * SLOT_HEIGHT;
const sign = p.strand === 'A' ? 1 : -1;
const nodeX = CX + sign * AMP * Math.sin(k * y);
return {
project: p,
index: i,
y,
nodeX,
side: nodeX > CX ? 'right' : 'left'
};
})
);
</script>
<section
class="vhelix"
style:--vh-h="{H}px"
style:--vh-w="{W}px"
aria-label="Projects bound to the HELIX strands"
>
<svg
class="vhelix-svg"
viewBox="0 0 {W} {H}"
preserveAspectRatio="xMidYMid meet"
aria-hidden="true"
>
<defs>
<linearGradient id="vstrand-a" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#0f52a5">
<animate attributeName="offset" values="-0.3;1.3" dur="18s" repeatCount="indefinite" />
</stop>
<stop offset="50%" stop-color="#0c99d9">
<animate attributeName="offset" values="0.0;1.6" dur="18s" repeatCount="indefinite" />
</stop>
<stop offset="100%" stop-color="#4dd0c2">
<animate attributeName="offset" values="0.3;1.9" dur="18s" repeatCount="indefinite" />
</stop>
</linearGradient>
<linearGradient id="vstrand-b" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#c084fc">
<animate attributeName="offset" values="-0.3;1.3" dur="22s" repeatCount="indefinite" />
</stop>
<stop offset="50%" stop-color="#7e6ce8">
<animate attributeName="offset" values="0.0;1.6" dur="22s" repeatCount="indefinite" />
</stop>
<stop offset="100%" stop-color="#50a8d9">
<animate attributeName="offset" values="0.3;1.9" dur="22s" repeatCount="indefinite" />
</stop>
</linearGradient>
<filter id="vhelix-glow" x="-10%" y="-2%" width="120%" height="104%">
<feGaussianBlur stdDeviation="4" result="b" />
<feMerge>
<feMergeNode in="b" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id="vnode-glow" x="-200%" y="-200%" width="500%" height="500%">
<feGaussianBlur stdDeviation="6" />
</filter>
</defs>
<!-- Back rungs -->
{#each rungs as r}
{#if !r.aFront}
<line
x1={r.x1}
y1={r.y}
x2={r.x2}
y2={r.y}
stroke="#86bbdd"
stroke-opacity={0.08 + 0.32 * r.depth}
stroke-width={0.6 + 1.0 * r.depth}
stroke-linecap="round"
/>
{/if}
{/each}
<!-- Strand B (back) -->
<path
d={strandB}
fill="none"
stroke="url(#vstrand-b)"
stroke-width="3.2"
stroke-linecap="round"
stroke-opacity="0.85"
filter="url(#vhelix-glow)"
/>
<!-- Front rungs -->
{#each rungs as r}
{#if r.aFront}
<line
x1={r.x1}
y1={r.y}
x2={r.x2}
y2={r.y}
stroke="#a9daee"
stroke-opacity={0.15 + 0.50 * r.depth}
stroke-width={1.0 + 1.6 * r.depth}
stroke-linecap="round"
/>
{/if}
{/each}
<!-- Strand A (front) -->
<path
d={strandA}
fill="none"
stroke="url(#vstrand-a)"
stroke-width="3.6"
stroke-linecap="round"
filter="url(#vhelix-glow)"
/>
<!-- Project nodes -->
{#each slots as s}
<g class="vnode" data-strand={s.project.strand}>
<circle
cx={s.nodeX}
cy={s.y}
r="22"
fill={s.project.strand === 'A' ? '#0c99d9' : '#c084fc'}
fill-opacity="0.25"
filter="url(#vnode-glow)"
class="vnode-halo"
/>
<circle
cx={s.nodeX}
cy={s.y}
r="12"
fill={s.project.strand === 'A' ? '#0c99d9' : '#c084fc'}
/>
<circle cx={s.nodeX} cy={s.y} r="6" fill="#07111d" />
<circle
cx={s.nodeX}
cy={s.y}
r="3.5"
fill={s.project.strand === 'A' ? '#4dd0c2' : '#c084fc'}
/>
</g>
{/each}
<!-- Connector lines from each node out to its card edge -->
{#each slots as s}
<line
x1={s.nodeX}
y1={s.y}
x2={s.side === 'right' ? W - 80 : 80}
y2={s.y}
stroke={s.project.strand === 'A' ? '#0c99d9' : '#c084fc'}
stroke-opacity="0.35"
stroke-width="1"
stroke-dasharray="2 4"
/>
{/each}
</svg>
<!-- HTML card layer absolutely positioned over the SVG -->
<div class="vhelix-cards">
{#each slots as s}
<a
href="/projects/{s.project.slug}"
class="vcard side-{s.side} strand-{s.project.strand}"
style:top="{(s.y / H * 100).toFixed(2)}%"
>
<span class="badge">
<span class="dot" aria-hidden="true"></span>
{s.project.strand === 'A' ? 'Project' : 'Innovation'}
</span>
<h3>{s.project.title}</h3>
<p>{s.project.summary}</p>
<span class="cta">Open <span class="arrow"></span></span>
</a>
{/each}
</div>
</section>
<style>
.vhelix {
position: relative;
width: 100%;
max-width: 1100px;
margin: 0 auto;
padding: 0 1rem;
height: var(--vh-h);
}
.vhelix-svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.vhelix-cards {
position: relative;
width: 100%;
height: 100%;
}
.vcard {
position: absolute;
width: 280px;
max-width: calc(50% - 110px);
padding: 1rem 1.15rem 1.1rem;
border-radius: 12px;
background: color-mix(in oklab, var(--color-helix-bg-2) 90%, transparent);
border: 1px solid var(--color-helix-border);
backdrop-filter: blur(6px);
text-decoration: none;
color: inherit;
transform: translateY(-50%);
transition: border-color 200ms ease, background 200ms ease, transform 200ms ease;
}
.vcard:hover {
border-color: var(--card-accent);
background: var(--color-helix-bg-3);
transform: translateY(calc(-50% - 2px));
}
.vcard.side-right {
right: 1.5rem;
text-align: left;
}
.vcard.side-left {
left: 1.5rem;
text-align: left;
}
.vcard.strand-A {
--card-accent: var(--color-helix-process);
--card-accent-soft: rgba(12, 153, 217, 0.18);
}
.vcard.strand-B {
--card-accent: var(--color-helix-accent-2);
--card-accent-soft: rgba(192, 132, 252, 0.18);
}
.badge {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-family: var(--font-mono);
font-size: 0.68rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--card-accent);
padding: 0.18rem 0.55rem;
border-radius: 999px;
background: var(--card-accent-soft);
margin-bottom: 0.6rem;
}
.badge .dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--card-accent);
box-shadow: 0 0 8px var(--card-accent);
}
.vcard h3 {
font-size: 1.1rem;
font-weight: 600;
letter-spacing: -0.01em;
margin: 0 0 0.35rem;
}
.vcard p {
color: var(--color-helix-ink-dim);
font-size: 0.92rem;
line-height: 1.5;
margin: 0 0 0.55rem;
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.cta {
color: var(--card-accent);
font-size: 0.85rem;
font-weight: 500;
}
.arrow {
display: inline-block;
transition: transform 200ms ease;
}
.vcard:hover .arrow {
transform: translateX(3px);
}
/* Node pulse (subtle "alive" feeling, no rotation) */
@media (prefers-reduced-motion: no-preference) {
:global(.vnode-halo) {
animation: vnode-pulse 3.5s ease-in-out infinite;
transform-box: fill-box;
transform-origin: center;
}
}
@keyframes vnode-pulse {
0%,
100% {
opacity: 0.6;
r: 22;
}
50% {
opacity: 1;
r: 28;
}
}
/* Mobile: helix becomes a thin centerline; cards stack full-width below each node */
@media (max-width: 760px) {
.vhelix {
height: auto;
min-height: 0;
}
.vhelix-svg {
display: none; /* hide the wide-helix SVG on narrow screens */
}
.vhelix-cards {
display: flex;
flex-direction: column;
gap: 1rem;
height: auto;
padding: 1rem 0;
}
.vcard {
position: relative;
top: auto !important;
right: auto !important;
left: auto !important;
width: 100%;
max-width: 100%;
transform: none;
}
.vcard:hover {
transform: none;
}
}
</style>

View File

@@ -35,6 +35,12 @@ export const projects = sqliteTable('projects', {
summary: text('summary').notNull(), summary: text('summary').notNull(),
bodyMd: text('body_md').notNull().default(''), bodyMd: text('body_md').notNull().default(''),
coverUrl: text('cover_url'), coverUrl: text('cover_url'),
/**
* Strand the project binds to on the landing helix.
* A = Project (durable, in-production work — e.g. EVOLV)
* B = Innovation (experiments, prototypes, scouts)
*/
strand: text('strand', { enum: ['A', 'B'] }).notNull().default('A'),
authorId: text('author_id').references(() => users.id, { onDelete: 'set null' }), authorId: text('author_id').references(() => users.id, { onDelete: 'set null' }),
status: text('status', { enum: ['draft', 'published'] }) status: text('status', { enum: ['draft', 'published'] })
.notNull() .notNull()

View File

@@ -4,19 +4,26 @@ import { projects, posts } from '$lib/server/db/schema';
import { desc, eq, isNotNull } from 'drizzle-orm'; import { desc, eq, isNotNull } from 'drizzle-orm';
export const load: PageServerLoad = async () => { export const load: PageServerLoad = async () => {
const recentProjects = db const helixProjects = db
.select({ .select({
slug: projects.slug, slug: projects.slug,
title: projects.title, title: projects.title,
summary: projects.summary, summary: projects.summary,
strand: projects.strand,
coverUrl: projects.coverUrl coverUrl: projects.coverUrl
}) })
.from(projects) .from(projects)
.where(eq(projects.status, 'published')) .where(eq(projects.status, 'published'))
.orderBy(desc(projects.updatedAt)) .orderBy(desc(projects.updatedAt))
.limit(6) .limit(12)
.all(); .all();
const totalPublished = db
.select({ slug: projects.slug })
.from(projects)
.where(eq(projects.status, 'published'))
.all().length;
const recentPosts = db const recentPosts = db
.select({ .select({
slug: posts.slug, slug: posts.slug,
@@ -27,8 +34,13 @@ export const load: PageServerLoad = async () => {
.from(posts) .from(posts)
.where(isNotNull(posts.publishedAt)) .where(isNotNull(posts.publishedAt))
.orderBy(desc(posts.publishedAt)) .orderBy(desc(posts.publishedAt))
.limit(5) .limit(3)
.all(); .all();
return { recentProjects, recentPosts }; return {
helixProjects,
totalPublished,
moreProjects: Math.max(0, totalPublished - helixProjects.length),
recentPosts
};
}; };

View File

@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import Helix from '$lib/components/Helix.svelte'; import VerticalHelix from '$lib/components/VerticalHelix.svelte';
import ProjectCard from '$lib/components/ProjectCard.svelte';
import PostCard from '$lib/components/PostCard.svelte'; import PostCard from '$lib/components/PostCard.svelte';
import { SITE } from '$lib/config'; import { SITE } from '$lib/config';
@@ -8,10 +7,6 @@
</script> </script>
<section class="hero"> <section class="hero">
<div class="helix-layer">
<Helix />
</div>
<div class="hero-content"> <div class="hero-content">
<p class="eyebrow">R&amp;D · {SITE.organization}</p> <p class="eyebrow">R&amp;D · {SITE.organization}</p>
<h1 class="title"> <h1 class="title">
@@ -20,33 +15,50 @@
<p class="tagline">{SITE.tagline}</p> <p class="tagline">{SITE.tagline}</p>
<p class="lede">{SITE.description}</p> <p class="lede">{SITE.description}</p>
<div class="cta-row"> <div class="legend">
<a href="/projects" class="cta primary">Explore projects <span class="arrow"></span></a> <span class="leg-item">
<a href="/posts" class="cta">Read updates</a> <span class="leg-dot leg-a"></span>
</div> <span><strong>Projects</strong> · durable, in-production</span>
</span>
<span class="leg-item">
<span class="leg-dot leg-b"></span>
<span><strong>Innovations</strong> · experiments &amp; prototypes</span>
</span>
</div> </div>
<div class="scroll-hint" aria-hidden="true"> <div class="scroll-hint" aria-hidden="true">
<span>scroll</span> <span>scroll</span>
<span class="bar"></span> <span class="bar"></span>
</div> </div>
</div>
</section> </section>
<section class="band"> <section class="helix-anchor">
<header class="band-head"> {#if data.helixProjects.length === 0}
<h2>Recent projects</h2> <div class="empty">
<a href="/projects" class="more">All projects →</a> <h2>The strands are empty</h2>
<p>HELIX needs its first project. <a href="/projects/new">Add one →</a></p>
</div>
{:else}
<header class="section-head">
<p class="eyebrow">On the strands</p>
<h2>Projects &amp; Innovations</h2>
<p class="lede">
Each entry below binds to one strand of the helix. {data.helixProjects.length}
of {data.totalPublished} showing &mdash; the most recently updated.
</p>
</header> </header>
{#if data.recentProjects.length === 0} <VerticalHelix projects={data.helixProjects} />
<p class="empty">No projects yet — be the first to <a href="/projects/new">add one</a>.</p>
{:else} {#if data.moreProjects > 0}
<div class="grid"> <div class="more-row">
{#each data.recentProjects as p} <a href="/projects" class="more-link">
<ProjectCard project={p} /> See all {data.totalPublished} projects <span class="arrow"></span>
{/each} </a>
</div> </div>
{/if} {/if}
{/if}
</section> </section>
<section class="band"> <section class="band">
@@ -56,7 +68,9 @@
</header> </header>
{#if data.recentPosts.length === 0} {#if data.recentPosts.length === 0}
<p class="empty">No posts yet — be the first to <a href="/posts/new">write one</a>.</p> <p class="empty-line">
No posts yet — be the first to <a href="/posts/new">write one</a>.
</p>
{:else} {:else}
<div class="post-list"> <div class="post-list">
{#each data.recentPosts as p} {#each data.recentPosts as p}
@@ -67,30 +81,23 @@
</section> </section>
<style> <style>
/* ---------- HERO ---------- */
.hero { .hero {
position: relative; position: relative;
min-height: 78vh; min-height: 60vh;
display: grid; display: grid;
place-items: center; place-items: center;
padding: 5rem 1.5rem 3rem;
overflow: hidden; overflow: hidden;
padding: 4rem 1.5rem 6rem;
} }
.helix-layer { .hero::before {
position: absolute;
inset: 0;
z-index: 0;
opacity: 0.85;
}
.helix-layer::after {
content: ''; content: '';
position: absolute; position: absolute;
inset: 0; inset: 0;
background: radial-gradient( background:
900px 500px at 50% 65%, radial-gradient(900px 600px at 50% 30%, rgba(12, 153, 217, 0.16), transparent 60%),
rgba(7, 17, 29, 0.0), radial-gradient(700px 500px at 50% 80%, rgba(77, 208, 194, 0.10), transparent 60%);
rgba(7, 17, 29, 0.55) 60%, pointer-events: none;
rgba(7, 17, 29, 0.95) 100%
);
} }
.hero-content { .hero-content {
position: relative; position: relative;
@@ -135,47 +142,42 @@
color: var(--color-helix-ink-dim); color: var(--color-helix-ink-dim);
line-height: 1.6; line-height: 1.6;
} }
.cta-row {
.legend {
margin-top: 2rem; margin-top: 2rem;
display: flex;
justify-content: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.cta {
display: inline-flex; display: inline-flex;
align-items: center; flex-wrap: wrap;
gap: 0.5rem; gap: 1.5rem 2rem;
padding: 0.7rem 1.4rem; justify-content: center;
border-radius: 10px; color: var(--color-helix-ink-dim);
border: 1px solid var(--color-helix-border); font-size: 0.88rem;
}
.legend strong {
color: var(--color-helix-ink); color: var(--color-helix-ink);
text-decoration: none;
font-weight: 500;
transition: border-color 160ms ease, background 160ms ease, transform 160ms ease;
}
.cta:hover {
border-color: var(--color-helix-accent);
transform: translateY(-1px);
}
.cta.primary {
background: linear-gradient(135deg, var(--color-helix-process), var(--color-helix-accent));
border-color: transparent;
color: var(--color-helix-bg);
font-weight: 600; font-weight: 600;
} }
.cta.primary:hover { .leg-item {
box-shadow: 0 10px 30px -10px rgba(77, 208, 194, 0.55); display: inline-flex;
align-items: center;
gap: 0.55rem;
} }
.arrow { .leg-dot {
transition: transform 200ms ease; width: 10px;
height: 10px;
border-radius: 50%;
} }
.cta:hover .arrow { .leg-a {
transform: translateX(3px); background: var(--color-helix-process);
box-shadow: 0 0 10px var(--color-helix-process);
} }
.leg-b {
background: var(--color-helix-accent-2);
box-shadow: 0 0 10px var(--color-helix-accent-2);
}
.scroll-hint { .scroll-hint {
position: absolute; position: absolute;
bottom: 2rem; bottom: -1.5rem;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
display: flex; display: flex;
@@ -206,8 +208,77 @@
} }
} }
/* ---------- HELIX SECTION ---------- */
.helix-anchor {
margin-top: 4rem;
padding: 0 1rem;
}
.section-head {
max-width: 720px;
margin: 0 auto 3rem;
padding: 0 1rem;
text-align: center;
}
.section-head h2 {
margin: 0.4rem 0 0.8rem;
font-size: clamp(1.8rem, 4vw, 2.5rem);
font-weight: 700;
letter-spacing: -0.02em;
}
.section-head .lede {
margin: 0 auto;
max-width: 560px;
}
.more-row {
margin-top: 2.5rem;
text-align: center;
}
.more-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.7rem 1.4rem;
border-radius: 10px;
border: 1px solid var(--color-helix-border);
color: var(--color-helix-ink);
text-decoration: none;
font-weight: 500;
transition: border-color 160ms ease, background 160ms ease;
}
.more-link:hover {
border-color: var(--color-helix-accent);
background: var(--color-helix-bg-2);
}
.more-link .arrow {
transition: transform 160ms ease;
}
.more-link:hover .arrow {
transform: translateX(3px);
}
.empty {
max-width: 540px;
margin: 4rem auto;
text-align: center;
padding: 3rem 2rem;
border: 1px dashed var(--color-helix-border);
border-radius: 14px;
}
.empty h2 {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 0.6rem;
}
.empty p {
color: var(--color-helix-ink-dim);
}
.empty a {
color: var(--color-helix-accent);
}
/* ---------- POSTS BAND ---------- */
.band { .band {
max-width: 1200px; max-width: 900px;
margin: 5rem auto 0; margin: 5rem auto 0;
padding: 0 1.5rem; padding: 0 1.5rem;
} }
@@ -227,20 +298,15 @@
text-decoration: none; text-decoration: none;
font-size: 0.9rem; font-size: 0.9rem;
} }
.grid {
display: grid;
gap: 1.25rem;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
}
.post-list { .post-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.empty { .empty-line {
color: var(--color-helix-ink-dim); color: var(--color-helix-ink-dim);
padding: 2rem 0; padding: 2rem 0;
} }
.empty a { .empty-line a {
color: var(--color-helix-accent); color: var(--color-helix-accent);
} }
</style> </style>

View File

@@ -19,8 +19,10 @@ export const actions: Actions = {
const summary = (data.get('summary') ?? '').toString().trim(); const summary = (data.get('summary') ?? '').toString().trim();
const bodyMd = (data.get('body_md') ?? '').toString(); const bodyMd = (data.get('body_md') ?? '').toString();
const coverUrl = (data.get('cover_url') ?? '').toString().trim() || null; const coverUrl = (data.get('cover_url') ?? '').toString().trim() || null;
const strandRaw = (data.get('strand') ?? 'A').toString();
const strand: 'A' | 'B' = strandRaw === 'B' ? 'B' : 'A';
const values = { title, slug: slugRaw, summary, body_md: bodyMd, cover_url: coverUrl }; const values = { title, slug: slugRaw, summary, body_md: bodyMd, cover_url: coverUrl, strand };
if (!locals.user) return fail(401, { error: 'Not authenticated', values }); if (!locals.user) return fail(401, { error: 'Not authenticated', values });
if (!title) return fail(400, { error: 'Title is required.', values }); if (!title) return fail(400, { error: 'Title is required.', values });
@@ -57,6 +59,7 @@ export const actions: Actions = {
summary, summary,
bodyMd, bodyMd,
coverUrl, coverUrl,
strand,
authorId: locals.user.id, authorId: locals.user.id,
status: 'published' status: 'published'
}) })

View File

@@ -70,6 +70,41 @@
/> />
</label> </label>
<fieldset class="strand">
<legend>Strand</legend>
<p class="help">
Which helix strand does this belong to? You can change it later.
</p>
<div class="strand-options">
<label class="strand-opt" class:checked={(form?.values?.strand ?? 'A') === 'A'}>
<input
type="radio"
name="strand"
value="A"
checked={(form?.values?.strand ?? 'A') === 'A'}
/>
<span class="strand-mark strand-a" aria-hidden="true"></span>
<span class="strand-text">
<strong>Project</strong>
<em>Durable, in-production. Like EVOLV.</em>
</span>
</label>
<label class="strand-opt" class:checked={form?.values?.strand === 'B'}>
<input
type="radio"
name="strand"
value="B"
checked={form?.values?.strand === 'B'}
/>
<span class="strand-mark strand-b" aria-hidden="true"></span>
<span class="strand-text">
<strong>Innovation</strong>
<em>Experiment, prototype, scout work.</em>
</span>
</label>
</div>
</fieldset>
<label> <label>
<span>Cover image URL <em>(optional)</em></span> <span>Cover image URL <em>(optional)</em></span>
<input <input
@@ -286,6 +321,72 @@
.submit:hover { .submit:hover {
background: var(--color-helix-area); background: var(--color-helix-area);
} }
fieldset.strand {
border: 1px solid var(--color-helix-border);
border-radius: 10px;
padding: 1rem 1.25rem 1.25rem;
background: var(--color-helix-bg-2);
}
.strand-options {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
margin-top: 0.5rem;
}
.strand-opt {
display: grid;
grid-template-columns: 14px 1fr;
gap: 0.7rem;
padding: 0.85rem 1rem;
border-radius: 10px;
border: 1px solid var(--color-helix-border);
background: var(--color-helix-bg);
cursor: pointer;
transition: border-color 160ms ease, background 160ms ease;
align-items: center;
}
.strand-opt:hover {
border-color: var(--color-helix-accent);
}
.strand-opt.checked {
border-color: var(--color-helix-accent);
background: var(--color-helix-bg-3);
}
.strand-opt input {
appearance: none;
width: 0;
height: 0;
padding: 0;
border: none;
}
.strand-mark {
width: 14px;
height: 14px;
border-radius: 50%;
box-shadow: 0 0 12px currentColor;
}
.strand-mark.strand-a {
background: var(--color-helix-process);
color: rgba(12, 153, 217, 0.5);
}
.strand-mark.strand-b {
background: var(--color-helix-accent-2);
color: rgba(192, 132, 252, 0.5);
}
.strand-text {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.strand-text strong {
font-weight: 600;
}
.strand-text em {
color: var(--color-helix-ink-faint);
font-style: normal;
font-size: 0.85rem;
}
@media (max-width: 600px) { @media (max-width: 600px) {
.link-row { .link-row {
grid-template-columns: 1fr 1fr auto; grid-template-columns: 1fr 1fr auto;
@@ -293,5 +394,8 @@
.link-row select { .link-row select {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
.strand-options {
grid-template-columns: 1fr;
}
} }
</style> </style>