From 408cf4460aeb6420e51093dcc5b675a1861aee4b Mon Sep 17 00:00:00 2001 From: Rene De Ren Date: Wed, 20 May 2026 11:24:41 +0200 Subject: [PATCH] feat: vertical helix with projects bound to strands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- drizzle/0001_sturdy_mysterio.sql | 1 + drizzle/meta/0001_snapshot.json | 568 ++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + scripts/seed.js | 35 +- src/lib/components/VerticalHelix.svelte | 427 ++++++++++++++++++ src/lib/server/db/schema.ts | 6 + src/routes/+page.server.ts | 20 +- src/routes/+page.svelte | 228 ++++++---- src/routes/projects/new/+page.server.ts | 5 +- src/routes/projects/new/+page.svelte | 104 +++++ 10 files changed, 1312 insertions(+), 89 deletions(-) create mode 100644 drizzle/0001_sturdy_mysterio.sql create mode 100644 drizzle/meta/0001_snapshot.json create mode 100644 src/lib/components/VerticalHelix.svelte diff --git a/drizzle/0001_sturdy_mysterio.sql b/drizzle/0001_sturdy_mysterio.sql new file mode 100644 index 0000000..914e75d --- /dev/null +++ b/drizzle/0001_sturdy_mysterio.sql @@ -0,0 +1 @@ +ALTER TABLE `projects` ADD `strand` text DEFAULT 'A' NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..8760b19 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 79b4849..a31cb0c 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/scripts/seed.js b/scripts/seed.js index 8b623d3..ddd69f4 100644 --- a/scripts/seed.js +++ b/scripts/seed.js @@ -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 diff --git a/src/lib/components/VerticalHelix.svelte b/src/lib/components/VerticalHelix.svelte new file mode 100644 index 0000000..80f5385 --- /dev/null +++ b/src/lib/components/VerticalHelix.svelte @@ -0,0 +1,427 @@ + + +
+ + + + +
+ + diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 023563d..c227365 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -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() diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index 94c3a9f..923513f 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -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 + }; }; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index f55d363..a9ab24f 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,6 +1,5 @@
-
- -
-

R&D · {SITE.organization}

@@ -20,32 +15,49 @@

{SITE.tagline}

{SITE.description}

-
- Explore projects - Read updates +
+ + + Projects · durable, in-production + + + + Innovations · experiments & prototypes +
-
-

-
-
-

Recent projects

- All projects → -
- - {#if data.recentProjects.length === 0} -

No projects yet — be the first to add one.

- {:else} -
- {#each data.recentProjects as p} - - {/each} +
+ {#if data.helixProjects.length === 0} +
+

The strands are empty

+

HELIX needs its first project. Add one →

+ {:else} +
+

On the strands

+

Projects & Innovations

+

+ Each entry below binds to one strand of the helix. {data.helixProjects.length} + of {data.totalPublished} showing — the most recently updated. +

+
+ + + + {#if data.moreProjects > 0} + + {/if} {/if}
@@ -56,7 +68,9 @@ {#if data.recentPosts.length === 0} -

No posts yet — be the first to write one.

+

+ No posts yet — be the first to write one. +

{:else}
{#each data.recentPosts as p} @@ -67,30 +81,23 @@
diff --git a/src/routes/projects/new/+page.server.ts b/src/routes/projects/new/+page.server.ts index 812ff17..853725e 100644 --- a/src/routes/projects/new/+page.server.ts +++ b/src/routes/projects/new/+page.server.ts @@ -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' }) diff --git a/src/routes/projects/new/+page.svelte b/src/routes/projects/new/+page.svelte index 46f62fa..466e6a3 100644 --- a/src/routes/projects/new/+page.svelte +++ b/src/routes/projects/new/+page.svelte @@ -70,6 +70,41 @@ /> +
+ Strand +

+ Which helix strand does this belong to? You can change it later. +

+
+ + +
+
+