feat: rebrand to R&D-lab + WBD palette + lab-slide cards

Identity refresh aligned to Waterschap Brabantse Delta.

Brand
- HELIX → R&D-lab everywhere user-facing (SITE.name; literal "HELIX"s
  swept across routes, app.html, login, error messages, seed).
- New tagline: "Projects, innovations, and every strand between."
- Site description updated.

Palette (sourced from the official WSBD-logo.svg)
- Primary #0d4f9e, secondary #1fa0db, accent #bed137 — added as
  --wbd-blue / --wbd-cyan / --wbd-lime CSS vars and as wbd.* in
  tailwind.config.js. helix.* aliases now point to the WBD palette.
- Strand A (Projects) → #1fa0db cyan. Strand B (Innovations) → #bed137 lime.
- Body vignette + scroll-bar + legend dots repainted accordingly.

Composite logo
- New 24px nav glyph + favicon.svg: WBD-style tilted-square mark in WBD
  blues at the centre, helix strands (lime + cyan) wrapping it, lime
  "active site" dot at the crossing. Says "R&D-lab × Brabantse Delta"
  in one mark.

Lab-slide cards (VerticalHelix)
- Frosted-glass surface (backdrop-filter blur+saturate).
- Thick 5px strand-coloured stripe on the helix-facing edge (gradient,
  glowing shadow). Slide rounds the stripe corners; the rest is square.
- Slide header has the strand badge and a monospace serial number
  (01/03 etc) — lab-specimen feel.
- Dashed footer rule + "Open detail →" CTA.
- Inline link chips (Gitea / Dashboard / Demo / Docs / Paper / Video)
  with inline SVG icons + short labels. Hover lights up in the strand
  colour. Capped at 5 visible, "+N" overflow indicator.
- Real <a> chips inside the card without nested <a>: overlay-link
  pattern (transparent slide-link absolute fills the card, chips sit on
  z-index: 2 above it).

Server load
- + Page now fetches each project's links in one Drizzle relational
  query (db.query.projects.findMany with: { links }), capped at 12.
- + Form: strand picker (Project / Innovation radios) reads + persists
  the new column.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rene De Ren
2026-05-20 12:13:57 +02:00
parent 408cf4460a
commit 241411054e
20 changed files with 466 additions and 162 deletions

View File

@@ -42,13 +42,13 @@ const projects = [
{ {
id: 'prj_helix', id: 'prj_helix',
slug: 'helix', slug: 'helix',
title: 'HELIX', title: 'R&D-lab',
summary: summary:
'This very site — the R&D showcase platform. EVOLV and every R&D strand, one helix.', 'This very site — the R&D lab of Waterschap Brabantse Delta. Projects, innovations, and every strand between.',
body_md: [ body_md: [
'# HELIX', '# R&D-lab',
'', '',
'HELIX is the R&D showcase platform of Waterschap Brabantse Delta. It collects projects, innovations, and updates from across the team in one place — with deep links to the actual repos, dashboards, and demos.', 'The R&D-lab is the showcase platform of Waterschap Brabantse Delta. It collects projects, innovations, and updates from across the team in one place — with deep links to the actual repos, dashboards, and demos.',
'', '',
'## Stack', '## Stack',
'', '',
@@ -60,7 +60,7 @@ const projects = [
'', '',
'## Why?', '## Why?',
'', '',
'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. R&D-lab is the strand they share.'
].join('\n'), ].join('\n'),
cover_url: null, cover_url: null,
strand: 'A', strand: 'A',
@@ -117,13 +117,13 @@ const links = [
const posts = [ const posts = [
{ {
id: 'pst_welcome', id: 'pst_welcome',
slug: 'welcome-to-helix', slug: 'welcome-to-rd-lab',
title: 'Welcome to HELIX', title: 'Welcome to R&D-lab',
summary: 'Why this site exists and how to contribute.', summary: 'Why this site exists and how to contribute.',
body_md: [ body_md: [
'# Welcome to HELIX', '# Welcome to R&D-lab',
'', '',
'HELIX is the home of R&D output at Waterschap Brabantse Delta. If you have a project, a dashboard, or a one-off experiment worth showing — it belongs here.', 'R&D-lab is the home of R&D output at Waterschap Brabantse Delta. If you have a project, a dashboard, or a one-off experiment worth showing — it belongs here.',
'', '',
'## How to post', '## How to post',
'', '',

View File

@@ -5,14 +5,21 @@
/* Design tokens exposed as CSS vars so Svelte component <style> blocks /* Design tokens exposed as CSS vars so Svelte component <style> blocks
can use them without going through Tailwind. */ can use them without going through Tailwind. */
:root { :root {
/* S88-inspired hierarchy palette (mirrors EVOLV) */ /* Waterschap Brabantse Delta brand palette.
--color-helix-area: #0f52a5; Sourced from the official WSBD logo SVG (fd-cdn.nl/.../WSBD-logo.svg). */
--color-helix-process: #0c99d9; --wbd-deep: #0a3d80; /* deeper shade derived from primary, for hover/depth */
--color-helix-unit: #50a8d9; --wbd-blue: #0d4f9e; /* WBD primary blue */
--color-helix-equipment: #86bbdd; --wbd-cyan: #1fa0db; /* WBD secondary blue */
--color-helix-control: #a9daee; --wbd-lime: #bed137; /* WBD accent lime/green */
/* Surfaces */ /* Hierarchy palette (semantic) — points back to the WBD palette */
--color-helix-area: var(--wbd-deep);
--color-helix-process: var(--wbd-blue);
--color-helix-unit: var(--wbd-cyan);
--color-helix-equipment: #6fc3ec;
--color-helix-control: #b8dff5;
/* Surfaces (dark UI, on-brand) */
--color-helix-bg: #07111d; --color-helix-bg: #07111d;
--color-helix-bg-2: #0c1c30; --color-helix-bg-2: #0c1c30;
--color-helix-bg-3: #122842; --color-helix-bg-3: #122842;
@@ -23,9 +30,17 @@
--color-helix-ink-dim: #8fa6b8; --color-helix-ink-dim: #8fa6b8;
--color-helix-ink-faint: #5b7388; --color-helix-ink-faint: #5b7388;
/* Accent (helix glow / R&D signal) */ /* Accents
--color-helix-accent: #4dd0c2; accent = WBD lime — used for primary CTAs, focus, and Strand B (Innovations)
--color-helix-accent-2: #c084fc; accent2 = a lighter lime, used for highlights */
--color-helix-accent: var(--wbd-lime);
--color-helix-accent-2: #d8e36a;
/* Strand colors (semantic) — pick from the WBD palette */
--strand-a-primary: var(--wbd-cyan); /* Projects */
--strand-a-secondary: var(--wbd-blue);
--strand-b-primary: var(--wbd-lime); /* Innovations */
--strand-b-secondary: #8fa024;
--font-sans: 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif; --font-sans: 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace; --font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace;
@@ -40,15 +55,15 @@ body {
font-feature-settings: 'cv11', 'ss01', 'ss03'; font-feature-settings: 'cv11', 'ss01', 'ss03';
} }
/* Subtle radial vignette anchoring the landing page */ /* Subtle radial vignette anchoring the landing page (WBD palette) */
body::before { body::before {
content: ''; content: '';
position: fixed; position: fixed;
inset: 0; inset: 0;
background: background:
radial-gradient(1200px 800px at 20% 0%, rgba(12, 153, 217, 0.18), transparent 60%), radial-gradient(1200px 800px at 20% 0%, rgba(31, 160, 219, 0.18), transparent 60%),
radial-gradient(900px 700px at 90% 20%, rgba(77, 208, 194, 0.12), transparent 60%), radial-gradient(900px 700px at 90% 20%, rgba(13, 79, 158, 0.16), transparent 60%),
radial-gradient(700px 500px at 50% 100%, rgba(192, 132, 252, 0.08), transparent 60%); radial-gradient(700px 500px at 50% 100%, rgba(190, 209, 55, 0.08), transparent 60%);
pointer-events: none; pointer-events: none;
z-index: -1; z-index: -1;
} }

View File

@@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="%sveltekit.assets%/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="%sveltekit.assets%/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#0c99d9" /> <meta name="theme-color" content="#0c99d9" />
<meta name="description" content="HELIX — the R&D showcase platform of Waterschap Brabantse Delta. EVOLV and every R&D strand, one helix." /> <meta name="description" content="R&D-lab — the R&D platform of Waterschap Brabantse Delta. Projects, innovations, and every strand between." />
<link rel="preconnect" href="https://rsms.me/" /> <link rel="preconnect" href="https://rsms.me/" />
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" /> <link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
%sveltekit.head% %sveltekit.head%

View File

@@ -0,0 +1,123 @@
<script lang="ts">
import { LINK_KIND_SHORT, type LinkKind } from '$lib/config';
let {
kind,
label,
url
}: {
kind: string;
label: string;
url: string;
} = $props();
// Display short kind name; fall back to label for unknown kinds
const shortKind = $derived(
(LINK_KIND_SHORT as Record<string, string>)[kind] ?? label.slice(0, 6)
);
</script>
<a
class="chip"
data-kind={kind}
href={url}
target="_blank"
rel="noreferrer noopener"
aria-label={`${label} (${kind})`}
title={label}
>
<span class="ico" aria-hidden="true">
{#if kind === 'gitea'}
<!-- code-branch -->
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
<circle cx="4" cy="3" r="1.5" />
<circle cx="4" cy="13" r="1.5" />
<circle cx="12" cy="6" r="1.5" />
<path d="M4 4.5v7" />
<path d="M4 8c0-1.5 1-3 4-3v0" />
</svg>
{:else if kind === 'dashboard'}
<!-- grid -->
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="2" width="5" height="5" rx="0.6" />
<rect x="9" y="2" width="5" height="5" rx="0.6" />
<rect x="2" y="9" width="5" height="5" rx="0.6" />
<rect x="9" y="9" width="5" height="5" rx="0.6" />
</svg>
{:else if kind === 'demo'}
<!-- play -->
<svg viewBox="0 0 16 16" fill="currentColor">
<path d="M5 3.5v9l8-4.5z" />
</svg>
{:else if kind === 'docs'}
<!-- doc lines -->
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
<path d="M3.5 2.5h7l2.5 2.5v8.5h-9.5z" />
<path d="M10 2.5v3h3" />
<path d="M5 8h6" />
<path d="M5 10.5h6" />
<path d="M5 13h4" />
</svg>
{:else if kind === 'paper'}
<!-- paper / page -->
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 2.5h10v11h-10z" />
<path d="M5.5 5.5h5" />
<path d="M5.5 8h5" />
<path d="M5.5 10.5h3" />
</svg>
{:else if kind === 'video'}
<!-- video rect with play -->
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="3.5" width="12" height="9" rx="1" />
<path d="M7 6v4l3.5-2z" fill="currentColor" stroke="none" />
</svg>
{:else}
<!-- generic external link -->
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
<path d="M9.5 3h3.5v3.5" />
<path d="M13 3l-5.5 5.5" />
<path d="M11 9v3.5h-7v-7h3.5" />
</svg>
{/if}
</span>
<span class="lbl">{shortKind}</span>
</a>
<style>
.chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.25rem 0.6rem 0.25rem 0.45rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--color-helix-border);
color: var(--color-helix-ink-dim);
text-decoration: none;
font-size: 0.72rem;
font-family: var(--font-mono);
letter-spacing: 0.04em;
transition: color 160ms ease, border-color 160ms ease, background 160ms ease;
position: relative;
z-index: 2;
}
.chip:hover {
color: var(--color-helix-ink);
border-color: var(--card-accent, var(--color-helix-accent));
background: color-mix(in oklab, var(--card-accent, var(--color-helix-accent)) 14%, transparent);
}
.ico {
display: inline-flex;
width: 14px;
height: 14px;
color: var(--card-accent, var(--color-helix-accent));
}
.ico svg {
width: 100%;
height: 100%;
}
.lbl {
display: inline-block;
}
</style>

View File

@@ -20,21 +20,20 @@
<div class="inner"> <div class="inner">
<a href="/" class="brand"> <a href="/" class="brand">
<span class="mark" aria-hidden="true"> <span class="mark" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22"> <svg viewBox="0 0 24 24" width="24" height="24">
<path <!-- WBD tilted-square core -->
d="M4 4 C 4 10, 20 14, 20 20" <g transform="rotate(45 12 12)">
fill="none" <rect x="5.5" y="5.5" width="13" height="13" rx="0.5" fill="#0d4f9e" fill-opacity="0.9"/>
stroke="#4dd0c2" <rect x="8.2" y="8.2" width="7.6" height="7.6" rx="0.4" fill="#1fa0db"/>
stroke-width="2" </g>
stroke-linecap="round" <!-- Helix strands wrapping the core -->
/> <path d="M4 4 C 4 10, 20 14, 20 20"
<path fill="none" stroke="#bed137" stroke-width="2.1" stroke-linecap="round"/>
d="M4 20 C 4 14, 20 10, 20 4" <path d="M4 20 C 4 14, 20 10, 20 4"
fill="none" fill="none" stroke="#1fa0db" stroke-width="2.1" stroke-linecap="round" stroke-opacity="0.9"/>
stroke="#0c99d9" <!-- Active-site lime dot -->
stroke-width="2" <circle cx="12" cy="12" r="1.9" fill="#bed137"/>
stroke-linecap="round" <circle cx="12" cy="12" r="0.9" fill="#07111d"/>
/>
</svg> </svg>
</span> </span>
<span class="wordmark">{SITE.name}</span> <span class="wordmark">{SITE.name}</span>
@@ -105,8 +104,8 @@
} }
.wordmark { .wordmark {
font-weight: 700; font-weight: 700;
letter-spacing: 0.08em; letter-spacing: 0.02em;
font-size: 0.95rem; font-size: 0.98rem;
} }
.links { .links {
display: flex; display: flex;

View File

@@ -7,23 +7,36 @@
* *
* Each project sits at slot Y = TOP_PAD + i * SLOT_HEIGHT, at the * 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. * 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. * Cards are lab-slides: frosted glass surface with a thick strand-coloured
* The strand geometry itself is static — keeps project anchors stable. * stripe on the helix-facing edge. Inline link chips per project are real
* anchor elements stacked above an overlay link to the detail page
* (HTML doesn't allow nested <a>, so we use the overlay pattern).
*
* Palette: Waterschap Brabantse Delta — #0d4f9e, #1fa0db, #bed137.
*/ */
import LinkChip from './LinkChip.svelte';
type ProjectLink = {
kind: string;
label: string;
url: string;
position: number;
};
type StrandProject = { type StrandProject = {
slug: string; slug: string;
title: string; title: string;
summary: string; summary: string;
strand: 'A' | 'B'; strand: 'A' | 'B';
coverUrl: string | null; coverUrl: string | null;
links: ProjectLink[];
}; };
let { projects }: { projects: StrandProject[] } = $props(); let { projects }: { projects: StrandProject[] } = $props();
// Geometry constants (user-space units) // Geometry (user-space units inside the SVG)
const W = 1000; const W = 1000;
const CX = W / 2; const CX = W / 2;
const AMP = 140; const AMP = 140;
@@ -102,8 +115,7 @@
<section <section
class="vhelix" class="vhelix"
style:--vh-h="{H}px" style:--vh-h="{H}px"
style:--vh-w="{W}px" aria-label="Projects bound to the R&D-lab strands"
aria-label="Projects bound to the HELIX strands"
> >
<svg <svg
class="vhelix-svg" class="vhelix-svg"
@@ -113,24 +125,24 @@
> >
<defs> <defs>
<linearGradient id="vstrand-a" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="vstrand-a" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#0f52a5"> <stop offset="0%" stop-color="#0d4f9e">
<animate attributeName="offset" values="-0.3;1.3" dur="18s" repeatCount="indefinite" /> <animate attributeName="offset" values="-0.3;1.3" dur="18s" repeatCount="indefinite" />
</stop> </stop>
<stop offset="50%" stop-color="#0c99d9"> <stop offset="50%" stop-color="#1fa0db">
<animate attributeName="offset" values="0.0;1.6" dur="18s" repeatCount="indefinite" /> <animate attributeName="offset" values="0.0;1.6" dur="18s" repeatCount="indefinite" />
</stop> </stop>
<stop offset="100%" stop-color="#4dd0c2"> <stop offset="100%" stop-color="#6fc3ec">
<animate attributeName="offset" values="0.3;1.9" dur="18s" repeatCount="indefinite" /> <animate attributeName="offset" values="0.3;1.9" dur="18s" repeatCount="indefinite" />
</stop> </stop>
</linearGradient> </linearGradient>
<linearGradient id="vstrand-b" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="vstrand-b" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#c084fc"> <stop offset="0%" stop-color="#bed137">
<animate attributeName="offset" values="-0.3;1.3" dur="22s" repeatCount="indefinite" /> <animate attributeName="offset" values="-0.3;1.3" dur="22s" repeatCount="indefinite" />
</stop> </stop>
<stop offset="50%" stop-color="#7e6ce8"> <stop offset="50%" stop-color="#d8e36a">
<animate attributeName="offset" values="0.0;1.6" dur="22s" repeatCount="indefinite" /> <animate attributeName="offset" values="0.0;1.6" dur="22s" repeatCount="indefinite" />
</stop> </stop>
<stop offset="100%" stop-color="#50a8d9"> <stop offset="100%" stop-color="#8fa024">
<animate attributeName="offset" values="0.3;1.9" dur="22s" repeatCount="indefinite" /> <animate attributeName="offset" values="0.3;1.9" dur="22s" repeatCount="indefinite" />
</stop> </stop>
</linearGradient> </linearGradient>
@@ -154,8 +166,8 @@
y1={r.y} y1={r.y}
x2={r.x2} x2={r.x2}
y2={r.y} y2={r.y}
stroke="#86bbdd" stroke="#6fc3ec"
stroke-opacity={0.08 + 0.32 * r.depth} stroke-opacity={0.08 + 0.30 * r.depth}
stroke-width={0.6 + 1.0 * r.depth} stroke-width={0.6 + 1.0 * r.depth}
stroke-linecap="round" stroke-linecap="round"
/> />
@@ -181,8 +193,8 @@
y1={r.y} y1={r.y}
x2={r.x2} x2={r.x2}
y2={r.y} y2={r.y}
stroke="#a9daee" stroke="#b8dff5"
stroke-opacity={0.15 + 0.50 * r.depth} stroke-opacity={0.18 + 0.50 * r.depth}
stroke-width={1.0 + 1.6 * r.depth} stroke-width={1.0 + 1.6 * r.depth}
stroke-linecap="round" stroke-linecap="round"
/> />
@@ -206,7 +218,7 @@
cx={s.nodeX} cx={s.nodeX}
cy={s.y} cy={s.y}
r="22" r="22"
fill={s.project.strand === 'A' ? '#0c99d9' : '#c084fc'} fill={s.project.strand === 'A' ? '#1fa0db' : '#bed137'}
fill-opacity="0.25" fill-opacity="0.25"
filter="url(#vnode-glow)" filter="url(#vnode-glow)"
class="vnode-halo" class="vnode-halo"
@@ -215,49 +227,78 @@
cx={s.nodeX} cx={s.nodeX}
cy={s.y} cy={s.y}
r="12" r="12"
fill={s.project.strand === 'A' ? '#0c99d9' : '#c084fc'} fill={s.project.strand === 'A' ? '#1fa0db' : '#bed137'}
/> />
<circle cx={s.nodeX} cy={s.y} r="6" fill="#07111d" /> <circle cx={s.nodeX} cy={s.y} r="6" fill="#07111d" />
<circle <circle
cx={s.nodeX} cx={s.nodeX}
cy={s.y} cy={s.y}
r="3.5" r="3.5"
fill={s.project.strand === 'A' ? '#4dd0c2' : '#c084fc'} fill={s.project.strand === 'A' ? '#bed137' : '#1fa0db'}
/> />
</g> </g>
{/each} {/each}
<!-- Connector lines from each node out to its card edge --> <!-- Connector dash lines from node to card -->
{#each slots as s} {#each slots as s}
<line <line
x1={s.nodeX} x1={s.nodeX}
y1={s.y} y1={s.y}
x2={s.side === 'right' ? W - 80 : 80} x2={s.side === 'right' ? W - 80 : 80}
y2={s.y} y2={s.y}
stroke={s.project.strand === 'A' ? '#0c99d9' : '#c084fc'} stroke={s.project.strand === 'A' ? '#1fa0db' : '#bed137'}
stroke-opacity="0.35" stroke-opacity="0.32"
stroke-width="1" stroke-width="1"
stroke-dasharray="2 4" stroke-dasharray="2 4"
/> />
{/each} {/each}
</svg> </svg>
<!-- HTML card layer absolutely positioned over the SVG --> <!-- HTML lab-slide cards layered over the SVG -->
<div class="vhelix-cards"> <div class="vhelix-cards">
{#each slots as s} {#each slots as s}
<a <article
href="/projects/{s.project.slug}" class="slide side-{s.side} strand-{s.project.strand}"
class="vcard side-{s.side} strand-{s.project.strand}" style:top="{((s.y / H) * 100).toFixed(2)}%"
style:top="{(s.y / H * 100).toFixed(2)}%"
> >
<span class="badge"> <!-- Strand-coloured edge stripe (lab-slide signature) -->
<span class="dot" aria-hidden="true"></span> <span class="stripe" aria-hidden="true"></span>
{s.project.strand === 'A' ? 'Project' : 'Innovation'}
</span> <!-- Overlay link covers the body but not the chips -->
<h3>{s.project.title}</h3> <a
<p>{s.project.summary}</p> href="/projects/{s.project.slug}"
<span class="cta">Open <span class="arrow"></span></span> class="slide-link"
</a> aria-label={`Open ${s.project.title}`}
></a>
<header class="slide-head">
<span class="badge">
<span class="dot" aria-hidden="true"></span>
{s.project.strand === 'A' ? 'Project' : 'Innovation'}
</span>
<span class="serial" aria-hidden="true">
{String(s.index + 1).padStart(2, '0')}/{String(slots.length).padStart(2, '0')}
</span>
</header>
<h3 class="title">{s.project.title}</h3>
<p class="summary">{s.project.summary}</p>
{#if s.project.links.length > 0}
<div class="chips">
{#each s.project.links.slice(0, 5) as l}
<LinkChip kind={l.kind} label={l.label} url={l.url} />
{/each}
{#if s.project.links.length > 5}
<span class="more-chips">+{s.project.links.length - 5}</span>
{/if}
</div>
{/if}
<footer class="slide-foot">
<span class="open">Open detail <span class="arrow"></span></span>
</footer>
</article>
{/each} {/each}
</div> </div>
</section> </section>
@@ -286,41 +327,90 @@
height: 100%; height: 100%;
} }
.vcard { /* ========== LAB SLIDE CARD ========== */
.slide {
position: absolute; position: absolute;
width: 280px; width: 300px;
max-width: calc(50% - 110px); max-width: calc(50% - 90px);
padding: 1rem 1.15rem 1.1rem; padding: 1.1rem 1.25rem 0.95rem;
border-radius: 12px; border-radius: 12px;
background: color-mix(in oklab, var(--color-helix-bg-2) 90%, transparent); background: color-mix(in oklab, var(--color-helix-bg-2) 88%, transparent);
border: 1px solid var(--color-helix-border); border: 1px solid var(--color-helix-border);
backdrop-filter: blur(6px); backdrop-filter: blur(10px) saturate(140%);
text-decoration: none; -webkit-backdrop-filter: blur(10px) saturate(140%);
color: inherit;
transform: translateY(-50%); transform: translateY(-50%);
transition: border-color 200ms ease, background 200ms ease, transform 200ms ease; transition: border-color 220ms ease, background 220ms ease, transform 220ms ease,
box-shadow 220ms ease;
overflow: hidden;
} }
.vcard:hover { .slide:hover {
border-color: var(--card-accent); border-color: var(--card-accent);
background: var(--color-helix-bg-3); background: color-mix(in oklab, var(--color-helix-bg-3) 90%, transparent);
transform: translateY(calc(-50% - 2px)); transform: translateY(calc(-50% - 2px));
box-shadow: 0 16px 40px -20px color-mix(in oklab, var(--card-accent) 60%, transparent);
} }
.vcard.side-right { .slide.side-right {
right: 1.5rem; right: 1.5rem;
text-align: left;
} }
.vcard.side-left { .slide.side-left {
left: 1.5rem; left: 1.5rem;
text-align: left;
} }
.vcard.strand-A { .slide.strand-A {
--card-accent: var(--color-helix-process); --card-accent: #1fa0db;
--card-accent-soft: rgba(12, 153, 217, 0.18); --card-accent-soft: rgba(31, 160, 219, 0.18);
} }
.vcard.strand-B { .slide.strand-B {
--card-accent: var(--color-helix-accent-2); --card-accent: #bed137;
--card-accent-soft: rgba(192, 132, 252, 0.18); --card-accent-soft: rgba(190, 209, 55, 0.22);
}
/* Thick coloured stripe on the helix-facing edge */
.stripe {
position: absolute;
top: 0;
bottom: 0;
width: 5px;
background: linear-gradient(
180deg,
var(--card-accent) 0%,
color-mix(in oklab, var(--card-accent) 60%, transparent) 100%
);
box-shadow: 0 0 12px var(--card-accent-soft);
}
.slide.side-right .stripe {
left: 0;
border-radius: 12px 0 0 12px;
}
.slide.side-left .stripe {
right: 0;
border-radius: 0 12px 12px 0;
}
/* Invisible overlay link: covers the card except the chips row */
.slide-link {
position: absolute;
inset: 0;
z-index: 1;
text-indent: -9999px;
overflow: hidden;
}
.slide-head,
.title,
.summary,
.slide-foot {
position: relative;
z-index: 0; /* below the overlay link */
pointer-events: none;
}
.slide-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.6rem;
} }
.badge { .badge {
@@ -328,14 +418,13 @@
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.4rem;
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 0.68rem; font-size: 0.66rem;
letter-spacing: 0.16em; letter-spacing: 0.18em;
text-transform: uppercase; text-transform: uppercase;
color: var(--card-accent); color: var(--card-accent);
padding: 0.18rem 0.55rem; padding: 0.18rem 0.55rem;
border-radius: 999px; border-radius: 999px;
background: var(--card-accent-soft); background: var(--card-accent-soft);
margin-bottom: 0.6rem;
} }
.badge .dot { .badge .dot {
width: 6px; width: 6px;
@@ -345,37 +434,73 @@
box-shadow: 0 0 8px var(--card-accent); box-shadow: 0 0 8px var(--card-accent);
} }
.vcard h3 { .serial {
font-family: var(--font-mono);
font-size: 0.66rem;
letter-spacing: 0.12em;
color: var(--color-helix-ink-faint);
}
.title {
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 600; font-weight: 600;
letter-spacing: -0.01em; letter-spacing: -0.01em;
margin: 0 0 0.35rem; margin: 0 0 0.35rem;
padding-left: 0.45rem; /* clear the stripe a hair */
} }
.vcard p { .slide.side-left .title {
padding-left: 0;
padding-right: 0.45rem;
}
.summary {
color: var(--color-helix-ink-dim); color: var(--color-helix-ink-dim);
font-size: 0.92rem; font-size: 0.9rem;
line-height: 1.5; line-height: 1.5;
margin: 0 0 0.55rem; margin: 0 0 0.7rem;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 3; -webkit-line-clamp: 3;
line-clamp: 3; line-clamp: 3;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
} }
.cta {
/* Chips ROW — above the overlay link */
.chips {
position: relative;
z-index: 2;
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin: 0 0 0.6rem;
}
.more-chips {
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--color-helix-ink-faint);
align-self: center;
}
.slide-foot {
border-top: 1px dashed color-mix(in oklab, var(--card-accent) 30%, transparent);
padding-top: 0.55rem;
display: flex;
justify-content: flex-end;
}
.open {
color: var(--card-accent); color: var(--card-accent);
font-size: 0.85rem; font-size: 0.82rem;
font-weight: 500; font-weight: 500;
} }
.arrow { .arrow {
display: inline-block; display: inline-block;
transition: transform 200ms ease; transition: transform 200ms ease;
} }
.vcard:hover .arrow { .slide:hover .arrow {
transform: translateX(3px); transform: translateX(3px);
} }
/* Node pulse (subtle "alive" feeling, no rotation) */ /* Node pulse */
@media (prefers-reduced-motion: no-preference) { @media (prefers-reduced-motion: no-preference) {
:global(.vnode-halo) { :global(.vnode-halo) {
animation: vnode-pulse 3.5s ease-in-out infinite; animation: vnode-pulse 3.5s ease-in-out infinite;
@@ -395,14 +520,13 @@
} }
} }
/* Mobile: helix becomes a thin centerline; cards stack full-width below each node */ /* Mobile: helix hides, cards stack full-width */
@media (max-width: 760px) { @media (max-width: 760px) {
.vhelix { .vhelix {
height: auto; height: auto;
min-height: 0;
} }
.vhelix-svg { .vhelix-svg {
display: none; /* hide the wide-helix SVG on narrow screens */ display: none;
} }
.vhelix-cards { .vhelix-cards {
display: flex; display: flex;
@@ -411,7 +535,7 @@
height: auto; height: auto;
padding: 1rem 0; padding: 1rem 0;
} }
.vcard { .slide {
position: relative; position: relative;
top: auto !important; top: auto !important;
right: auto !important; right: auto !important;
@@ -420,7 +544,7 @@
max-width: 100%; max-width: 100%;
transform: none; transform: none;
} }
.vcard:hover { .slide:hover {
transform: none; transform: none;
} }
} }

View File

@@ -2,11 +2,12 @@
* Site-wide configuration. Edit here to rebrand without touching components. * Site-wide configuration. Edit here to rebrand without touching components.
*/ */
export const SITE = { export const SITE = {
name: 'HELIX', name: 'R&D-lab',
tagline: 'EVOLV and every R&D strand, one helix.', shortName: 'R&D-lab',
tagline: 'Projects, innovations, and every strand between.',
description: description:
'The R&D showcase platform of Waterschap Brabantse Delta. EVOLV at its core, every innovation along the strands.', 'The R&D lab of Waterschap Brabantse Delta. EVOLV at its core, every project and innovation along the strands.',
organization: 'Waterschap Brabantse Delta R&D', organization: 'Waterschap Brabantse Delta',
giteaOrg: 'RnD', giteaOrg: 'RnD',
giteaBaseUrl: 'https://gitea.wbd-rd.nl' giteaBaseUrl: 'https://gitea.wbd-rd.nl'
} as const; } as const;
@@ -32,3 +33,13 @@ export const LINK_KIND_LABEL: Record<LinkKind, string> = {
paper: 'Paper', paper: 'Paper',
video: 'Video' video: 'Video'
}; };
/** Short labels used inline on cards (≤ 6 chars). */
export const LINK_KIND_SHORT: Record<LinkKind, string> = {
gitea: 'Repo',
dashboard: 'Dash',
demo: 'Demo',
docs: 'Docs',
paper: 'Paper',
video: 'Video'
};

View File

@@ -10,7 +10,7 @@ marked.setOptions({
* *
* Trust model: authoring is gated to members of the configured Gitea org, * Trust model: authoring is gated to members of the configured Gitea org,
* so we render markdown as-is (raw HTML in markdown is passed through). * so we render markdown as-is (raw HTML in markdown is passed through).
* If HELIX is opened to untrusted authors, swap this for a DOMPurify pass. * If authoring is opened to untrusted authors, swap this for a DOMPurify pass.
*/ */
export function renderMarkdown(md: string): string { export function renderMarkdown(md: string): string {
return marked.parse(md) as string; return marked.parse(md) as string;

View File

@@ -1,22 +1,28 @@
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { projects, posts } from '$lib/server/db/schema'; import { projects, posts, projectLinks } from '$lib/server/db/schema';
import { desc, eq, isNotNull } from 'drizzle-orm'; import { asc, desc, eq, isNotNull } from 'drizzle-orm';
export const load: PageServerLoad = async () => { export const load: PageServerLoad = async () => {
const helixProjects = db // Top 12 projects + their links (one round-trip via Drizzle relational query)
.select({ const helixProjects = await db.query.projects.findMany({
slug: projects.slug, where: eq(projects.status, 'published'),
title: projects.title, orderBy: [desc(projects.updatedAt)],
summary: projects.summary, limit: 12,
strand: projects.strand, columns: {
coverUrl: projects.coverUrl slug: true,
}) title: true,
.from(projects) summary: true,
.where(eq(projects.status, 'published')) strand: true,
.orderBy(desc(projects.updatedAt)) coverUrl: true
.limit(12) },
.all(); with: {
links: {
columns: { kind: true, label: true, url: true, position: true },
orderBy: [asc(projectLinks.position)]
}
}
});
const totalPublished = db const totalPublished = db
.select({ slug: projects.slug }) .select({ slug: projects.slug })

View File

@@ -10,7 +10,7 @@
<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">
<span class="word">HELIX</span> <span class="word">{SITE.name}</span>
</h1> </h1>
<p class="tagline">{SITE.tagline}</p> <p class="tagline">{SITE.tagline}</p>
<p class="lede">{SITE.description}</p> <p class="lede">{SITE.description}</p>
@@ -37,7 +37,7 @@
{#if data.helixProjects.length === 0} {#if data.helixProjects.length === 0}
<div class="empty"> <div class="empty">
<h2>The strands are empty</h2> <h2>The strands are empty</h2>
<p>HELIX needs its first project. <a href="/projects/new">Add one →</a></p> <p>R&amp;D-lab needs its first project. <a href="/projects/new">Add one →</a></p>
</div> </div>
{:else} {:else}
<header class="section-head"> <header class="section-head">
@@ -167,12 +167,12 @@
border-radius: 50%; border-radius: 50%;
} }
.leg-a { .leg-a {
background: var(--color-helix-process); background: #1fa0db;
box-shadow: 0 0 10px var(--color-helix-process); box-shadow: 0 0 10px #1fa0db;
} }
.leg-b { .leg-b {
background: var(--color-helix-accent-2); background: #bed137;
box-shadow: 0 0 10px var(--color-helix-accent-2); box-shadow: 0 0 10px #bed137;
} }
.scroll-hint { .scroll-hint {
@@ -208,7 +208,7 @@
} }
} }
/* ---------- HELIX SECTION ---------- */ /* ---------- STRAND SECTION ---------- */
.helix-anchor { .helix-anchor {
margin-top: 4rem; margin-top: 4rem;
padding: 0 1rem; padding: 0 1rem;

View File

@@ -45,7 +45,7 @@ export const GET = async ({ url, cookies }) => {
if (GITEA_ALLOWED_ORG) { if (GITEA_ALLOWED_ORG) {
const allowed = await isUserInOrg(accessToken, giteaUser.login, GITEA_ALLOWED_ORG); const allowed = await isUserInOrg(accessToken, giteaUser.login, GITEA_ALLOWED_ORG);
if (!allowed) { if (!allowed) {
error(403, `HELIX is restricted to members of the "${GITEA_ALLOWED_ORG}" Gitea organisation.`); error(403, `R&D-lab is restricted to members of the "${GITEA_ALLOWED_ORG}" Gitea organisation.`);
} }
} }

View File

@@ -10,7 +10,7 @@
<div class="rounded-2xl border border-helix-border bg-helix-bg-2/60 p-10 backdrop-blur"> <div class="rounded-2xl border border-helix-border bg-helix-bg-2/60 p-10 backdrop-blur">
<h1 class="text-3xl font-semibold tracking-tight">Sign in to {SITE.name}</h1> <h1 class="text-3xl font-semibold tracking-tight">Sign in to {SITE.name}</h1>
<p class="mt-3 text-helix-ink-dim"> <p class="mt-3 text-helix-ink-dim">
HELIX uses your <strong class="text-helix-ink">Gitea</strong> account at {SITE.name} uses your <strong class="text-helix-ink">Gitea</strong> account at
<code class="font-mono text-sm">{SITE.giteaBaseUrl.replace('https://', '')}</code>. <code class="font-mono text-sm">{SITE.giteaBaseUrl.replace('https://', '')}</code>.
Anyone can read; authoring is restricted to the Anyone can read; authoring is restricted to the
<code class="font-mono text-sm">{SITE.giteaOrg}</code> organisation. <code class="font-mono text-sm">{SITE.giteaOrg}</code> organisation.
@@ -30,7 +30,7 @@
</a> </a>
<p class="mt-6 text-xs text-helix-ink-faint"> <p class="mt-6 text-xs text-helix-ink-faint">
You'll be redirected to Gitea to approve, then back here. No password is stored by HELIX. You'll be redirected to Gitea to approve, then back here. No password is stored by {SITE.name}.
</p> </p>
</div> </div>
</section> </section>

View File

@@ -1,11 +1,12 @@
<script lang="ts"> <script lang="ts">
import PostCard from '$lib/components/PostCard.svelte'; import PostCard from '$lib/components/PostCard.svelte';
import { SITE } from '$lib/config';
let { data } = $props(); let { data } = $props();
</script> </script>
<svelte:head> <svelte:head>
<title>Posts · HELIX</title> <title>Posts · {SITE.name}</title>
</svelte:head> </svelte:head>
<section class="page"> <section class="page">

View File

@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { SITE } from '$lib/config';
let { data } = $props(); let { data } = $props();
const published = $derived( const published = $derived(
data.post.publishedAt data.post.publishedAt
@@ -12,7 +14,7 @@
</script> </script>
<svelte:head> <svelte:head>
<title>{data.post.title} · HELIX</title> <title>{data.post.title} · {SITE.name}</title>
<meta name="description" content={data.post.summary || data.post.title} /> <meta name="description" content={data.post.summary || data.post.title} />
</svelte:head> </svelte:head>

View File

@@ -1,11 +1,12 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { SITE } from '$lib/config';
let { form } = $props(); let { form } = $props();
</script> </script>
<svelte:head> <svelte:head>
<title>New post · HELIX</title> <title>New post · {SITE.name}</title>
</svelte:head> </svelte:head>
<section class="page"> <section class="page">

View File

@@ -1,11 +1,12 @@
<script lang="ts"> <script lang="ts">
import ProjectCard from '$lib/components/ProjectCard.svelte'; import ProjectCard from '$lib/components/ProjectCard.svelte';
import { SITE } from '$lib/config';
let { data } = $props(); let { data } = $props();
</script> </script>
<svelte:head> <svelte:head>
<title>Projects · HELIX</title> <title>Projects · {SITE.name}</title>
</svelte:head> </svelte:head>
<section class="page"> <section class="page">

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import LinkChips from '$lib/components/LinkChips.svelte'; import LinkChips from '$lib/components/LinkChips.svelte';
import DashboardEmbed from '$lib/components/DashboardEmbed.svelte'; import DashboardEmbed from '$lib/components/DashboardEmbed.svelte';
import { SITE } from '$lib/config';
let { data } = $props(); let { data } = $props();
const updated = $derived( const updated = $derived(
@@ -13,7 +14,7 @@
</script> </script>
<svelte:head> <svelte:head>
<title>{data.project.title} · HELIX</title> <title>{data.project.title} · {SITE.name}</title>
<meta name="description" content={data.project.summary} /> <meta name="description" content={data.project.summary} />
</svelte:head> </svelte:head>

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { LINK_KINDS, LINK_KIND_LABEL } from '$lib/config'; import { LINK_KINDS, LINK_KIND_LABEL, SITE } from '$lib/config';
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
let { form } = $props(); let { form } = $props();
@@ -16,7 +16,7 @@
</script> </script>
<svelte:head> <svelte:head>
<title>New project · HELIX</title> <title>New project · {SITE.name}</title>
</svelte:head> </svelte:head>
<section class="page"> <section class="page">

View File

@@ -1,8 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<!-- Dark backplate -->
<rect width="24" height="24" rx="5" fill="#07111d"/> <rect width="24" height="24" rx="5" fill="#07111d"/>
<path d="M5 4 C 5 10, 19 14, 19 20" fill="none" stroke="#4dd0c2" stroke-width="2.2" stroke-linecap="round"/>
<path d="M5 20 C 5 14, 19 10, 19 4" fill="none" stroke="#0c99d9" stroke-width="2.2" stroke-linecap="round"/> <!-- WBD-style tilted square mark (echo of the Waterschap diamond) -->
<line x1="6" y1="6" x2="6" y2="6.5" stroke="#a9daee" stroke-width="1.2"/> <g transform="rotate(45 12 12)">
<line x1="12" y1="12" x2="12" y2="12.5" stroke="#a9daee" stroke-width="1.2"/> <rect x="5.5" y="5.5" width="13" height="13" rx="0.5" fill="#0d4f9e" fill-opacity="0.9"/>
<line x1="18" y1="18" x2="18" y2="18.5" stroke="#a9daee" stroke-width="1.2"/> <rect x="8.2" y="8.2" width="7.6" height="7.6" rx="0.4" fill="#1fa0db"/>
</g>
<!-- Helix strand A (front) — WBD lime, wraps around -->
<path d="M4 4 C 4 10, 20 14, 20 20"
fill="none" stroke="#bed137" stroke-width="2.1" stroke-linecap="round"/>
<!-- Helix strand B (back) — softer cyan -->
<path d="M4 20 C 4 14, 20 10, 20 4"
fill="none" stroke="#1fa0db" stroke-width="2.1" stroke-linecap="round" stroke-opacity="0.9"/>
<!-- Center "active site" lime dot -->
<circle cx="12" cy="12" r="1.9" fill="#bed137"/>
<circle cx="12" cy="12" r="0.9" fill="#07111d"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 581 B

After

Width:  |  Height:  |  Size: 950 B

View File

@@ -4,12 +4,19 @@ export default {
theme: { theme: {
extend: { extend: {
colors: { colors: {
// Waterschap Brabantse Delta brand palette (from WSBD-logo.svg)
wbd: {
deep: '#0a3d80',
blue: '#0d4f9e',
cyan: '#1fa0db',
lime: '#bed137'
},
helix: { helix: {
area: '#0f52a5', area: '#0a3d80',
process: '#0c99d9', process: '#0d4f9e',
unit: '#50a8d9', unit: '#1fa0db',
equipment: '#86bbdd', equipment: '#6fc3ec',
control: '#a9daee', control: '#b8dff5',
bg: '#07111d', bg: '#07111d',
'bg-2': '#0c1c30', 'bg-2': '#0c1c30',
'bg-3': '#122842', 'bg-3': '#122842',
@@ -17,8 +24,8 @@ export default {
ink: '#e6f1fb', ink: '#e6f1fb',
'ink-dim': '#8fa6b8', 'ink-dim': '#8fa6b8',
'ink-faint': '#5b7388', 'ink-faint': '#5b7388',
accent: '#4dd0c2', accent: '#bed137',
'accent-2': '#c084fc' 'accent-2': '#d8e36a'
} }
}, },
fontFamily: { fontFamily: {