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',
slug: 'helix',
title: 'HELIX',
title: 'R&D-lab',
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: [
'# 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',
'',
@@ -60,7 +60,7 @@ const projects = [
'',
'## 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'),
cover_url: null,
strand: 'A',
@@ -117,13 +117,13 @@ const links = [
const posts = [
{
id: 'pst_welcome',
slug: 'welcome-to-helix',
title: 'Welcome to HELIX',
slug: 'welcome-to-rd-lab',
title: 'Welcome to R&D-lab',
summary: 'Why this site exists and how to contribute.',
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',
'',

View File

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

View File

@@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="%sveltekit.assets%/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<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="stylesheet" href="https://rsms.me/inter/inter.css" />
%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">
<a href="/" class="brand">
<span class="mark" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22">
<path
d="M4 4 C 4 10, 20 14, 20 20"
fill="none"
stroke="#4dd0c2"
stroke-width="2"
stroke-linecap="round"
/>
<path
d="M4 20 C 4 14, 20 10, 20 4"
fill="none"
stroke="#0c99d9"
stroke-width="2"
stroke-linecap="round"
/>
<svg viewBox="0 0 24 24" width="24" height="24">
<!-- WBD tilted-square core -->
<g transform="rotate(45 12 12)">
<rect x="5.5" y="5.5" width="13" height="13" rx="0.5" fill="#0d4f9e" fill-opacity="0.9"/>
<rect x="8.2" y="8.2" width="7.6" height="7.6" rx="0.4" fill="#1fa0db"/>
</g>
<!-- Helix strands wrapping the core -->
<path d="M4 4 C 4 10, 20 14, 20 20"
fill="none" stroke="#bed137" stroke-width="2.1" stroke-linecap="round"/>
<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"/>
<!-- 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>
</span>
<span class="wordmark">{SITE.name}</span>
@@ -105,8 +104,8 @@
}
.wordmark {
font-weight: 700;
letter-spacing: 0.08em;
font-size: 0.95rem;
letter-spacing: 0.02em;
font-size: 0.98rem;
}
.links {
display: flex;

View File

@@ -7,23 +7,36 @@
*
* 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.
* Cards are lab-slides: frosted glass surface with a thick strand-coloured
* 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 = {
slug: string;
title: string;
summary: string;
strand: 'A' | 'B';
coverUrl: string | null;
links: ProjectLink[];
};
let { projects }: { projects: StrandProject[] } = $props();
// Geometry constants (user-space units)
// Geometry (user-space units inside the SVG)
const W = 1000;
const CX = W / 2;
const AMP = 140;
@@ -102,8 +115,7 @@
<section
class="vhelix"
style:--vh-h="{H}px"
style:--vh-w="{W}px"
aria-label="Projects bound to the HELIX strands"
aria-label="Projects bound to the R&D-lab strands"
>
<svg
class="vhelix-svg"
@@ -113,24 +125,24 @@
>
<defs>
<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" />
</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" />
</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" />
</stop>
</linearGradient>
<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" />
</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" />
</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" />
</stop>
</linearGradient>
@@ -154,8 +166,8 @@
y1={r.y}
x2={r.x2}
y2={r.y}
stroke="#86bbdd"
stroke-opacity={0.08 + 0.32 * r.depth}
stroke="#6fc3ec"
stroke-opacity={0.08 + 0.30 * r.depth}
stroke-width={0.6 + 1.0 * r.depth}
stroke-linecap="round"
/>
@@ -181,8 +193,8 @@
y1={r.y}
x2={r.x2}
y2={r.y}
stroke="#a9daee"
stroke-opacity={0.15 + 0.50 * r.depth}
stroke="#b8dff5"
stroke-opacity={0.18 + 0.50 * r.depth}
stroke-width={1.0 + 1.6 * r.depth}
stroke-linecap="round"
/>
@@ -206,7 +218,7 @@
cx={s.nodeX}
cy={s.y}
r="22"
fill={s.project.strand === 'A' ? '#0c99d9' : '#c084fc'}
fill={s.project.strand === 'A' ? '#1fa0db' : '#bed137'}
fill-opacity="0.25"
filter="url(#vnode-glow)"
class="vnode-halo"
@@ -215,49 +227,78 @@
cx={s.nodeX}
cy={s.y}
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="3.5"
fill={s.project.strand === 'A' ? '#4dd0c2' : '#c084fc'}
fill={s.project.strand === 'A' ? '#bed137' : '#1fa0db'}
/>
</g>
{/each}
<!-- Connector lines from each node out to its card edge -->
<!-- Connector dash lines from node to card -->
{#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={s.project.strand === 'A' ? '#1fa0db' : '#bed137'}
stroke-opacity="0.32"
stroke-width="1"
stroke-dasharray="2 4"
/>
{/each}
</svg>
<!-- HTML card layer absolutely positioned over the SVG -->
<!-- HTML lab-slide cards layered 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)}%"
<article
class="slide 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>
<!-- Strand-coloured edge stripe (lab-slide signature) -->
<span class="stripe" aria-hidden="true"></span>
<!-- Overlay link covers the body but not the chips -->
<a
href="/projects/{s.project.slug}"
class="slide-link"
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}
</div>
</section>
@@ -286,41 +327,90 @@
height: 100%;
}
.vcard {
/* ========== LAB SLIDE CARD ========== */
.slide {
position: absolute;
width: 280px;
max-width: calc(50% - 110px);
padding: 1rem 1.15rem 1.1rem;
width: 300px;
max-width: calc(50% - 90px);
padding: 1.1rem 1.25rem 0.95rem;
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);
backdrop-filter: blur(6px);
text-decoration: none;
color: inherit;
backdrop-filter: blur(10px) saturate(140%);
-webkit-backdrop-filter: blur(10px) saturate(140%);
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);
background: var(--color-helix-bg-3);
background: color-mix(in oklab, var(--color-helix-bg-3) 90%, transparent);
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;
text-align: left;
}
.vcard.side-left {
.slide.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);
.slide.strand-A {
--card-accent: #1fa0db;
--card-accent-soft: rgba(31, 160, 219, 0.18);
}
.vcard.strand-B {
--card-accent: var(--color-helix-accent-2);
--card-accent-soft: rgba(192, 132, 252, 0.18);
.slide.strand-B {
--card-accent: #bed137;
--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 {
@@ -328,14 +418,13 @@
align-items: center;
gap: 0.4rem;
font-family: var(--font-mono);
font-size: 0.68rem;
letter-spacing: 0.16em;
font-size: 0.66rem;
letter-spacing: 0.18em;
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;
@@ -345,37 +434,73 @@
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-weight: 600;
letter-spacing: -0.01em;
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);
font-size: 0.92rem;
font-size: 0.9rem;
line-height: 1.5;
margin: 0 0 0.55rem;
margin: 0 0 0.7rem;
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
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);
font-size: 0.85rem;
font-size: 0.82rem;
font-weight: 500;
}
.arrow {
display: inline-block;
transition: transform 200ms ease;
}
.vcard:hover .arrow {
.slide:hover .arrow {
transform: translateX(3px);
}
/* Node pulse (subtle "alive" feeling, no rotation) */
/* Node pulse */
@media (prefers-reduced-motion: no-preference) {
:global(.vnode-halo) {
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) {
.vhelix {
height: auto;
min-height: 0;
}
.vhelix-svg {
display: none; /* hide the wide-helix SVG on narrow screens */
display: none;
}
.vhelix-cards {
display: flex;
@@ -411,7 +535,7 @@
height: auto;
padding: 1rem 0;
}
.vcard {
.slide {
position: relative;
top: auto !important;
right: auto !important;
@@ -420,7 +544,7 @@
max-width: 100%;
transform: none;
}
.vcard:hover {
.slide:hover {
transform: none;
}
}

View File

@@ -2,11 +2,12 @@
* Site-wide configuration. Edit here to rebrand without touching components.
*/
export const SITE = {
name: 'HELIX',
tagline: 'EVOLV and every R&D strand, one helix.',
name: 'R&D-lab',
shortName: 'R&D-lab',
tagline: 'Projects, innovations, and every strand between.',
description:
'The R&D showcase platform of Waterschap Brabantse Delta. EVOLV at its core, every innovation along the strands.',
organization: 'Waterschap Brabantse Delta R&D',
'The R&D lab of Waterschap Brabantse Delta. EVOLV at its core, every project and innovation along the strands.',
organization: 'Waterschap Brabantse Delta',
giteaOrg: 'RnD',
giteaBaseUrl: 'https://gitea.wbd-rd.nl'
} as const;
@@ -32,3 +33,13 @@ export const LINK_KIND_LABEL: Record<LinkKind, string> = {
paper: 'Paper',
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,
* 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 {
return marked.parse(md) as string;

View File

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

View File

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

View File

@@ -45,7 +45,7 @@ export const GET = async ({ url, cookies }) => {
if (GITEA_ALLOWED_ORG) {
const allowed = await isUserInOrg(accessToken, giteaUser.login, GITEA_ALLOWED_ORG);
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">
<h1 class="text-3xl font-semibold tracking-tight">Sign in to {SITE.name}</h1>
<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>.
Anyone can read; authoring is restricted to the
<code class="font-mono text-sm">{SITE.giteaOrg}</code> organisation.
@@ -30,7 +30,7 @@
</a>
<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>
</div>
</section>

View File

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

View File

@@ -1,4 +1,6 @@
<script lang="ts">
import { SITE } from '$lib/config';
let { data } = $props();
const published = $derived(
data.post.publishedAt
@@ -12,7 +14,7 @@
</script>
<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} />
</svelte:head>

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
<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';
let { form } = $props();
@@ -16,7 +16,7 @@
</script>
<svelte:head>
<title>New project · HELIX</title>
<title>New project · {SITE.name}</title>
</svelte:head>
<section class="page">

View File

@@ -1,8 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<!-- Dark backplate -->
<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"/>
<line x1="6" y1="6" x2="6" y2="6.5" stroke="#a9daee" stroke-width="1.2"/>
<line x1="12" y1="12" x2="12" y2="12.5" stroke="#a9daee" stroke-width="1.2"/>
<line x1="18" y1="18" x2="18" y2="18.5" stroke="#a9daee" stroke-width="1.2"/>
<!-- WBD-style tilted square mark (echo of the Waterschap diamond) -->
<g transform="rotate(45 12 12)">
<rect x="5.5" y="5.5" width="13" height="13" rx="0.5" fill="#0d4f9e" fill-opacity="0.9"/>
<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>

Before

Width:  |  Height:  |  Size: 581 B

After

Width:  |  Height:  |  Size: 950 B

View File

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