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:
1
drizzle/0001_sturdy_mysterio.sql
Normal file
1
drizzle/0001_sturdy_mysterio.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `projects` ADD `strand` text DEFAULT 'A' NOT NULL;
|
||||
568
drizzle/meta/0001_snapshot.json
Normal file
568
drizzle/meta/0001_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,13 @@
|
||||
"when": 1779267485591,
|
||||
"tag": "0000_giant_mother_askani",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1779268639045,
|
||||
"tag": "0001_sturdy_mysterio",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -36,6 +36,7 @@ const projects = [
|
||||
'- Used at Waterschap Brabantse Delta for plant simulation and live control'
|
||||
].join('\n'),
|
||||
cover_url: null,
|
||||
strand: 'A',
|
||||
status: 'published'
|
||||
},
|
||||
{
|
||||
@@ -52,7 +53,7 @@ const projects = [
|
||||
'## Stack',
|
||||
'',
|
||||
'- **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',
|
||||
'- **Gitea OAuth** for authoring',
|
||||
'- 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.'
|
||||
].join('\n'),
|
||||
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'
|
||||
}
|
||||
];
|
||||
@@ -113,9 +142,9 @@ const posts = [
|
||||
|
||||
const insertProject = db.prepare(`
|
||||
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
|
||||
(@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(`
|
||||
INSERT OR IGNORE INTO project_links
|
||||
|
||||
427
src/lib/components/VerticalHelix.svelte
Normal file
427
src/lib/components/VerticalHelix.svelte
Normal 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>
|
||||
@@ -35,6 +35,12 @@ export const projects = sqliteTable('projects', {
|
||||
summary: text('summary').notNull(),
|
||||
bodyMd: text('body_md').notNull().default(''),
|
||||
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' }),
|
||||
status: text('status', { enum: ['draft', 'published'] })
|
||||
.notNull()
|
||||
|
||||
@@ -4,19 +4,26 @@ import { projects, posts } from '$lib/server/db/schema';
|
||||
import { desc, eq, isNotNull } from 'drizzle-orm';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const recentProjects = db
|
||||
const helixProjects = db
|
||||
.select({
|
||||
slug: projects.slug,
|
||||
title: projects.title,
|
||||
summary: projects.summary,
|
||||
strand: projects.strand,
|
||||
coverUrl: projects.coverUrl
|
||||
})
|
||||
.from(projects)
|
||||
.where(eq(projects.status, 'published'))
|
||||
.orderBy(desc(projects.updatedAt))
|
||||
.limit(6)
|
||||
.limit(12)
|
||||
.all();
|
||||
|
||||
const totalPublished = db
|
||||
.select({ slug: projects.slug })
|
||||
.from(projects)
|
||||
.where(eq(projects.status, 'published'))
|
||||
.all().length;
|
||||
|
||||
const recentPosts = db
|
||||
.select({
|
||||
slug: posts.slug,
|
||||
@@ -27,8 +34,13 @@ export const load: PageServerLoad = async () => {
|
||||
.from(posts)
|
||||
.where(isNotNull(posts.publishedAt))
|
||||
.orderBy(desc(posts.publishedAt))
|
||||
.limit(5)
|
||||
.limit(3)
|
||||
.all();
|
||||
|
||||
return { recentProjects, recentPosts };
|
||||
return {
|
||||
helixProjects,
|
||||
totalPublished,
|
||||
moreProjects: Math.max(0, totalPublished - helixProjects.length),
|
||||
recentPosts
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import Helix from '$lib/components/Helix.svelte';
|
||||
import ProjectCard from '$lib/components/ProjectCard.svelte';
|
||||
import VerticalHelix from '$lib/components/VerticalHelix.svelte';
|
||||
import PostCard from '$lib/components/PostCard.svelte';
|
||||
import { SITE } from '$lib/config';
|
||||
|
||||
@@ -8,10 +7,6 @@
|
||||
</script>
|
||||
|
||||
<section class="hero">
|
||||
<div class="helix-layer">
|
||||
<Helix />
|
||||
</div>
|
||||
|
||||
<div class="hero-content">
|
||||
<p class="eyebrow">R&D · {SITE.organization}</p>
|
||||
<h1 class="title">
|
||||
@@ -20,32 +15,49 @@
|
||||
<p class="tagline">{SITE.tagline}</p>
|
||||
<p class="lede">{SITE.description}</p>
|
||||
|
||||
<div class="cta-row">
|
||||
<a href="/projects" class="cta primary">Explore projects <span class="arrow">→</span></a>
|
||||
<a href="/posts" class="cta">Read updates</a>
|
||||
<div class="legend">
|
||||
<span class="leg-item">
|
||||
<span class="leg-dot leg-a"></span>
|
||||
<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 & prototypes</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scroll-hint" aria-hidden="true">
|
||||
<span>scroll</span>
|
||||
<span class="bar"></span>
|
||||
<div class="scroll-hint" aria-hidden="true">
|
||||
<span>scroll</span>
|
||||
<span class="bar"></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="band">
|
||||
<header class="band-head">
|
||||
<h2>Recent projects</h2>
|
||||
<a href="/projects" class="more">All projects →</a>
|
||||
</header>
|
||||
|
||||
{#if data.recentProjects.length === 0}
|
||||
<p class="empty">No projects yet — be the first to <a href="/projects/new">add one</a>.</p>
|
||||
{:else}
|
||||
<div class="grid">
|
||||
{#each data.recentProjects as p}
|
||||
<ProjectCard project={p} />
|
||||
{/each}
|
||||
<section class="helix-anchor">
|
||||
{#if data.helixProjects.length === 0}
|
||||
<div class="empty">
|
||||
<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 & Innovations</h2>
|
||||
<p class="lede">
|
||||
Each entry below binds to one strand of the helix. {data.helixProjects.length}
|
||||
of {data.totalPublished} showing — the most recently updated.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<VerticalHelix projects={data.helixProjects} />
|
||||
|
||||
{#if data.moreProjects > 0}
|
||||
<div class="more-row">
|
||||
<a href="/projects" class="more-link">
|
||||
See all {data.totalPublished} projects <span class="arrow">→</span>
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -56,7 +68,9 @@
|
||||
</header>
|
||||
|
||||
{#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}
|
||||
<div class="post-list">
|
||||
{#each data.recentPosts as p}
|
||||
@@ -67,30 +81,23 @@
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* ---------- HERO ---------- */
|
||||
.hero {
|
||||
position: relative;
|
||||
min-height: 78vh;
|
||||
min-height: 60vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 5rem 1.5rem 3rem;
|
||||
overflow: hidden;
|
||||
padding: 4rem 1.5rem 6rem;
|
||||
}
|
||||
.helix-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.helix-layer::after {
|
||||
.hero::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(
|
||||
900px 500px at 50% 65%,
|
||||
rgba(7, 17, 29, 0.0),
|
||||
rgba(7, 17, 29, 0.55) 60%,
|
||||
rgba(7, 17, 29, 0.95) 100%
|
||||
);
|
||||
background:
|
||||
radial-gradient(900px 600px at 50% 30%, rgba(12, 153, 217, 0.16), transparent 60%),
|
||||
radial-gradient(700px 500px at 50% 80%, rgba(77, 208, 194, 0.10), transparent 60%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.hero-content {
|
||||
position: relative;
|
||||
@@ -135,47 +142,42 @@
|
||||
color: var(--color-helix-ink-dim);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.cta-row {
|
||||
|
||||
.legend {
|
||||
margin-top: 2rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.7rem 1.4rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--color-helix-border);
|
||||
flex-wrap: wrap;
|
||||
gap: 1.5rem 2rem;
|
||||
justify-content: center;
|
||||
color: var(--color-helix-ink-dim);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.legend strong {
|
||||
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;
|
||||
}
|
||||
.cta.primary:hover {
|
||||
box-shadow: 0 10px 30px -10px rgba(77, 208, 194, 0.55);
|
||||
.leg-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
.arrow {
|
||||
transition: transform 200ms ease;
|
||||
.leg-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.cta:hover .arrow {
|
||||
transform: translateX(3px);
|
||||
.leg-a {
|
||||
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 {
|
||||
position: absolute;
|
||||
bottom: 2rem;
|
||||
bottom: -1.5rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
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 {
|
||||
max-width: 1200px;
|
||||
max-width: 900px;
|
||||
margin: 5rem auto 0;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
@@ -227,20 +298,15 @@
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
}
|
||||
.post-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.empty {
|
||||
.empty-line {
|
||||
color: var(--color-helix-ink-dim);
|
||||
padding: 2rem 0;
|
||||
}
|
||||
.empty a {
|
||||
.empty-line a {
|
||||
color: var(--color-helix-accent);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -19,8 +19,10 @@ export const actions: Actions = {
|
||||
const summary = (data.get('summary') ?? '').toString().trim();
|
||||
const bodyMd = (data.get('body_md') ?? '').toString();
|
||||
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 (!title) return fail(400, { error: 'Title is required.', values });
|
||||
@@ -57,6 +59,7 @@ export const actions: Actions = {
|
||||
summary,
|
||||
bodyMd,
|
||||
coverUrl,
|
||||
strand,
|
||||
authorId: locals.user.id,
|
||||
status: 'published'
|
||||
})
|
||||
|
||||
@@ -70,6 +70,41 @@
|
||||
/>
|
||||
</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>
|
||||
<span>Cover image URL <em>(optional)</em></span>
|
||||
<input
|
||||
@@ -286,6 +321,72 @@
|
||||
.submit:hover {
|
||||
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) {
|
||||
.link-row {
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
@@ -293,5 +394,8 @@
|
||||
.link-row select {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.strand-options {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user