Redesign site and modernize tooling
My Gatsby Deployment pipeline / build-and-deploy (push) Successful in 4m4s

Replace the HTML5-UP "Strata" theme with a Swiss-minimal single-page
design (plain CSS, Space Grotesk / Hanken Grotesk / Space Mono). Rewrite
copy for current positioning: Staff Data, Analytics & AI Engineer at
Swisscom, open to new work. Experience shown as a pipeline timeline;
contact simplified to email + LinkedIn (drop the broken form).

Prune unused deps (lightbox, FontAwesome, sass/gatsby-plugin-sass) and
the old SCSS, fonts and gallery assets. Fix package.json metadata.

Harden the Gitea deploy: merge build+deploy into one job (removes the
deprecated artifact upload/download handoff), bump Node 18 -> 20, use
npx gatsby build. Rename .nvrmc -> .nvmrc and refresh the README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
dennisthiessenandClaude Opus 4.8 committed 2026-06-28 16:53:32 +02:00
1 parent 0c68d6dfb0
commit 8856e7c273
47 files changed
+675 -5810

No files matched your search

+10 -38
View File
@@ -7,34 +7,22 @@ on:
- master
jobs:
build:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Repository auschecken
uses: actions/checkout@v3
- name: Checkout repository
uses: actions/checkout@v4
- name: Node.js einrichten
uses: actions/setup-node@v3
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 18
node-version: 20
- name: Clear node_modules and package-lock
run: |
rm -rf node_modules
rm -f package-lock.json
- name: Install dependencies
run: npm install --legacy-peer-deps --force
- name: Install Dependencies
run: |
npm install --legacy-peer-deps --force
npm install -g gatsby-cli
- name: Clean Gatsby cache
run: |
gatsby clean
- name: Build Gatsby Site
run: |
gatsby build
- name: Build Gatsby site
run: npx gatsby build
env:
NODE_ENV: production
CI: true
@@ -46,22 +34,6 @@ jobs:
exit 1
fi
- name: Upload build artifacts
uses: actions/upload-artifact@v3
with:
name: gatsby-build
path: public/
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Download build artifacts
uses: actions/download-artifact@v3
with:
name: gatsby-build
path: public
- name: Install SSH key
uses: shimataro/ssh-key-action@v2
with:
+1
View File
@@ -0,0 +1 @@
20
-1
View File
@@ -1 +0,0 @@
v18
+19 -10
View File
@@ -1,18 +1,27 @@
# gatsby-starter-strata
# thiessen.io
**This is a starter for Gatsby.js V2.**
Personal site of **Dennis Thiessen** — Staff Data, Analytics & AI Engineer (Bern, Switzerland).
**The older V1 version of this starter can be found on the v1 branch**
Built with [Gatsby](https://www.gatsbyjs.com/) 5 and React 18. Single-page, Swiss-minimal design; styles are plain CSS in `src/assets/style.css` (no preprocessor).
Gatsby starter based on the Strata site template, designed by [HTML5 UP](https://html5up.net/strata). Check out https://codebushi.com/gatsby-starters/ for more Gatsby starters and templates.
## Develop
## Preview
```bash
npm install --legacy-peer-deps
npx gatsby develop # http://localhost:8000
```
http://gatsby-strata.surge.sh/
## Build
## Installation
```bash
npx gatsby build # output in ./public
```
Install this starter (assuming Gatsby is installed) by running from your CLI:
`gatsby new gatsby-starter-strata https://github.com/ChangoMan/gatsby-starter-strata`
Use Node 20 (see `.nvmrc`).
Run `gatsby develop` in the terminal to start.
## Deploy
Pushing to `master` triggers the Gitea Actions workflow in
`.gitea/workflows/workflow.yaml`, which builds the site and rsyncs `public/`
to the web server over SSH. It relies on these repository secrets:
`SSH_PRIVATE_KEY`, `SSH_KNOWN_HOSTS`, `DEPLOY_USER`, `DEPLOY_HOST`, `DEPLOY_PATH`.
+7 -7
View File
@@ -1,24 +1,24 @@
module.exports = {
siteMetadata: {
title: "Dennis Thiessen - IT Software Engineering",
title: "Dennis Thiessen — Data, Analytics & AI Engineer",
author: "Dennis Thiessen",
description: "Portfolio of Dennis Thiessen - IT Software Engineer Freelancer"
description:
"Dennis Thiessen is a Staff Data, Analytics & AI Engineer in Bern, Switzerland, building the data pipelines and ML infrastructure that production systems run on."
},
plugins: [
'gatsby-plugin-react-helmet',
{
resolve: `gatsby-plugin-manifest`,
options: {
name: 'gatsby-starter-default',
short_name: 'Dennis Thiessen',
name: 'Dennis Thiessen',
short_name: 'D. Thiessen',
start_url: '/',
background_color: '#663399',
theme_color: '#663399',
background_color: '#f4f5f7',
theme_color: '#15171c',
display: 'minimal-ui',
icon: 'src/assets/images/website-icon-dt.png',
},
},
'gatsby-plugin-sass',
'gatsby-plugin-offline'
],
}
+3 -9
View File
@@ -1,22 +1,16 @@
{
"name": "gatsby-starter-default",
"description": "Gatsby default starter",
"name": "thiessen-io",
"description": "Personal site of Dennis Thiessen — thiessen.io",
"version": "1.0.0",
"author": "Hunter Chang",
"author": "Dennis Thiessen",
"dependencies": {
"gatsby": "^5.12.12",
"gatsby-plugin-manifest": "^5.12.3",
"gatsby-plugin-offline": "^6.12.3",
"gatsby-plugin-react-helmet": "^6.12.0",
"gatsby-plugin-sass": "^6.12.3",
"sass": "^1.69.5",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-helmet": "^6.1.0",
"yet-another-react-lightbox": "^3.15.6",
"@fortawesome/fontawesome-svg-core": "^6.5.1",
"@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/react-fontawesome": "^0.2.0",
"ajv": "^8.12.0",
"ajv-keywords": "^5.1.0",
"schema-utils": "^4.2.0"
-4
View File
@@ -1,4 +0,0 @@
/*!
* Font Awesome 4.7.0 by @davegandy - http://fontawesome.io - @fontawesome
* License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License)
*/@font-face{font-family:'FontAwesome';src:url('../fonts/fontawesome-webfont.eot?v=4.7.0');src:url('../fonts/fontawesome-webfont.eot?#iefix&v=4.7.0') format('embedded-opentype'),url('../fonts/fontawesome-webfont.woff2?v=4.7.0') format('woff2'),url('../fonts/fontawesome-webfont.woff?v=4.7.0') format('woff'),url('../fonts/fontawesome-webfont.ttf?v=4.7.0') format('truetype'),url('../fonts/fontawesome-webfont.svg?v=4.7.0#fontawesomeregular') format('svg');font-weight:normal;font-style:normal}.fa{display:inline-block;font:normal normal normal 14px/1 FontAwesome;font-size:inherit;text-rendering:auto;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.fa-lg{font-size:1.33333333em;line-height:.75em;vertical-align:-15%}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-fw{width:1.28571429em;text-align:center}.fa-ul{padding-left:0;margin-left:2.14285714em;list-style-type:none}.fa-ul>li{position:relative}.fa-li{position:absolute;left:-2.14285714em;width:2.14285714em;top:.14285714em;text-align:center}.fa-li.fa-lg{left:-1.85714286em}.fa-border{padding:.2em .25em .15em;border:solid .08em #eee;border-radius:.1em}.fa-pull-left{float:left}.fa-pull-right{float:right}.fa.fa-pull-left{margin-right:.3em}.fa.fa-pull-right{margin-left:.3em}.pull-right{float:right}.pull-left{float:left}.fa.pull-left{margin-right:.3em}.fa.pull-right{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s infinite linear;animation:fa-spin 2s infinite linear}.fa-pulse{-webkit-animation:fa-spin 1s infinite steps(8);animation:fa-spin 1s infinite steps(8)}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}.fa-rotate-90{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=1)";-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2)";-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=3)";-webkit-transform:rotate(270deg);-ms-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1)";-webkit-transform:scale(-1, 1);-ms-transform:scale(-1, 1);transform:scale(-1, 1)}.fa-flip-vertical{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)";-webkit-transform:scale(1, -1);-ms-transform:scale(1, -1);transform:scale(1, -1)}:root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270,:root .fa-flip-horizontal,:root .fa-flip-vertical{filter:none}.fa-stack{position:relative;display:inline-block;width:2em;height:2em;line-height:2em;vertical-align:middle}.fa-stack-1x,.fa-stack-2x{position:absolute;left:0;width:100%;text-align:center}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-glass:before{content:"\f000"}.fa-music:before{content:"\f001"}.fa-search:before{content:"\f002"}.fa-envelope-o:before{content:"\f003"}.fa-heart:before{content:"\f004"}.fa-star:before{content:"\f005"}.fa-star-o:before{content:"\f006"}.fa-user:before{content:"\f007"}.fa-film:before{content:"\f008"}.fa-th-large:before{content:"\f009"}.fa-th:before{content:"\f00a"}.fa-th-list:before{content:"\f00b"}.fa-check:before{content:"\f00c"}.fa-remove:before,.fa-close:before,.fa-times:before{content:"\f00d"}.fa-search-plus:before{content:"\f00e"}.fa-search-minus:before{content:"\f010"}.fa-power-off:before{content:"\f011"}.fa-signal:before{content:"\f012"}.fa-gear:before,.fa-cog:before{content:"\f013"}.fa-trash-o:before{content:"\f014"}.fa-home:before{content:"\f015"}.fa-file-o:before{content:"\f016"}.fa-clock-o:before{content:"\f017"}.fa-road:before{content:"\f018"}.fa-download:before{content:"\f019"}.fa-arrow-circle-o-down:before{content:"\f01a"}.fa-arrow-circle-o-up:before{content:"\f01b"}.fa-inbox:before{content:"\f01c"}.fa-play-circle-o:before{content:"\f01d"}.fa-rotate-right:before,.fa-repeat:before{content:"\f01e"}.fa-refresh:before{content:"\f021"}.fa-list-alt:before{content:"\f022"}.fa-lock:before{content:"\f023"}.fa-flag:before{content:"\f024"}.fa-headphones:before{content:"\f025"}.fa-volume-off:before{content:"\f026"}.fa-volume-down:before{content:"\f027"}.fa-volume-up:before{content:"\f028"}.fa-qrcode:before{content:"\f029"}.fa-barcode:before{content:"\f02a"}.fa-tag:before{content:"\f02b"}.fa-tags:before{content:"\f02c"}.fa-book:before{content:"\f02d"}.fa-bookmark:before{content:"\f02e"}.fa-print:before{content:"\f02f"}.fa-camera:before{content:"\f030"}.fa-font:before{content:"\f031"}.fa-bold:before{content:"\f032"}.fa-italic:before{content:"\f033"}.fa-text-height:before{content:"\f034"}.fa-text-width:before{content:"\f03Line truncated
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large. Load diff

Before

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

-87
View File
@@ -1,87 +0,0 @@
@import 'libs/vars';
@import 'libs/functions';
@import 'libs/mixins';
@import 'libs/skel';
/*
Strata by HTML5 UP
html5up.net | @ajlkn
Free for personal and commercial use under the CCA 3.0 license (html5up.net/license)
*/
/* Button */
input[type="submit"],
input[type="reset"],
input[type="button"],
.button {
position: relative;
-ms-behavior: url('assets/js/ie/PIE.htc');
}
/* Form */
input[type="text"],
input[type="password"],
input[type="email"],
select,
textarea {
position: relative;
-ms-behavior: url('assets/js/ie/PIE.htc');
}
input[type="text"],
input[type="password"],
input[type="email"],
select {
height: _size(element-height);
line-height: _size(element-height);
}
input[type="checkbox"],
input[type="radio"] {
& + label {
&:before {
display: none;
}
}
}
/* Image */
.image {
position: relative;
-ms-behavior: url('assets/js/ie/PIE.htc');
&:before, &:after {
display: none !important;
}
img {
position: relative;
-ms-behavior: url('assets/js/ie/PIE.htc');
}
}
/* Header */
#header {
background-image: url('../../images/bg.jpg');
background-repeat: no-repeat;
background-size: cover;
-ms-behavior: url('assets/js/ie/backgroundsize.min.htc');
h1 {
color: _palette(accent2, fg-bold);
}
}
/* Footer */
#footer {
.icons {
a {
color: _palette(accent2, fg-bold);
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

-34
View File
@@ -1,34 +0,0 @@
/// Gets a duration value.
/// @param {string} $keys Key(s).
/// @return {string} Value.
@function _duration($keys...) {
@return val($duration, $keys...);
}
/// Gets a font value.
/// @param {string} $keys Key(s).
/// @return {string} Value.
@function _font($keys...) {
@return val($font, $keys...);
}
/// Gets a misc value.
/// @param {string} $keys Key(s).
/// @return {string} Value.
@function _misc($keys...) {
@return val($misc, $keys...);
}
/// Gets a palette value.
/// @param {string} $keys Key(s).
/// @return {string} Value.
@function _palette($keys...) {
@return val($palette, $keys...);
}
/// Gets a size value.
/// @param {string} $keys Key(s).
/// @return {string} Value.
@function _size($keys...) {
@return val($size, $keys...);
}
-398
View File
@@ -1,398 +0,0 @@
/// Makes an element's :before pseudoelement a FontAwesome icon.
/// @param {string} $content Optional content value to use.
/// @param {string} $where Optional pseudoelement to target (before or after).
@mixin icon($content: false, $where: before) {
text-decoration: none;
&:#{$where} {
@if $content {
content: $content;
}
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
font-family: FontAwesome;
font-style: normal;
font-weight: normal;
text-transform: none !important;
}
}
/// Applies padding to an element, taking the current element-margin value into account.
/// @param {mixed} $tb Top/bottom padding.
/// @param {mixed} $lr Left/right padding.
/// @param {list} $pad Optional extra padding (in the following order top, right, bottom, left)
/// @param {bool} $important If true, adds !important.
@mixin padding($tb, $lr, $pad: (0,0,0,0), $important: null) {
@if $important {
$important: '!important';
}
$x: 0.1em;
@if unit(_size(element-margin)) == 'rem' {
$x: 0.1rem;
}
padding: ($tb + nth($pad,1)) ($lr + nth($pad,2)) max($x, $tb - _size(element-margin) + nth($pad,3)) ($lr + nth($pad,4)) #{$important};
}
/// Encodes a SVG data URL so IE doesn't choke (via codepen.io/jakob-e/pen/YXXBrp).
/// @param {string} $svg SVG data URL.
/// @return {string} Encoded SVG data URL.
@function svg-url($svg) {
$svg: str-replace($svg, '"', '\'');
$svg: str-replace($svg, '%', '%25');
$svg: str-replace($svg, '<', '%3C');
$svg: str-replace($svg, '>', '%3E');
$svg: str-replace($svg, '&', '%26');
$svg: str-replace($svg, '#', '%23');
$svg: str-replace($svg, '{', '%7B');
$svg: str-replace($svg, '}', '%7D');
$svg: str-replace($svg, ';', '%3B');
@return url("data:image/svg+xml;charset=utf8,#{$svg}");
}
/// Initializes base flexgrid classes.
/// @param {string} $vertical-align Vertical alignment of cells.
/// @param {string} $horizontal-align Horizontal alignment of cells.
@mixin flexgrid-base($vertical-align: null, $horizontal-align: null) {
// Grid.
@include vendor('display', 'flex');
@include vendor('flex-wrap', 'wrap');
// Vertical alignment.
@if ($vertical-align == top) {
@include vendor('align-items', 'flex-start');
}
@else if ($vertical-align == bottom) {
@include vendor('align-items', 'flex-end');
}
@else if ($vertical-align == center) {
@include vendor('align-items', 'center');
}
@else {
@include vendor('align-items', 'stretch');
}
// Horizontal alignment.
@if ($horizontal-align != null) {
text-align: $horizontal-align;
}
// Cells.
> * {
@include vendor('flex-shrink', '1');
@include vendor('flex-grow', '0');
}
}
/// Sets up flexgrid columns.
/// @param {integer} $columns Columns.
@mixin flexgrid-columns($columns) {
> * {
$cell-width: 100% / $columns;
width: #{$cell-width};
}
}
/// Sets up flexgrid gutters.
/// @param {integer} $columns Columns.
/// @param {number} $gutters Gutters.
@mixin flexgrid-gutters($columns, $gutters) {
// Apply padding.
> * {
$cell-width: 100% / $columns;
padding: ($gutters * 0.5);
width: $cell-width;
}
}
/// Sets up flexgrid gutters (flush).
/// @param {integer} $columns Columns.
/// @param {number} $gutters Gutters.
@mixin flexgrid-gutters-flush($columns, $gutters) {
// Apply padding.
> * {
$cell-width: 100% / $columns;
$cell-width-pad: $gutters / $columns;
padding: ($gutters * 0.5);
width: calc(#{$cell-width} + #{$cell-width-pad});
}
// Clear top/bottom gutters.
> :nth-child(-n + #{$columns}) {
padding-top: 0;
}
> :nth-last-child(-n + #{$columns}) {
padding-bottom: 0;
}
// Clear left/right gutters.
> :nth-child(#{$columns}n + 1) {
padding-left: 0;
}
> :nth-child(#{$columns}n) {
padding-right: 0;
}
// Adjust widths of leftmost and rightmost cells.
> :nth-child(#{$columns}n + 1),
> :nth-child(#{$columns}n) {
$cell-width: 100% / $columns;
$cell-width-pad: ($gutters / $columns) - ($gutters / 2);
width: calc(#{$cell-width} + #{$cell-width-pad});
}
}
/// Reset flexgrid gutters (flush only).
/// Used to override a previous set of flexgrid gutter classes.
/// @param {integer} $columns Columns.
/// @param {number} $gutters Gutters.
/// @param {integer} $prev-columns Previous columns.
@mixin flexgrid-gutters-flush-reset($columns, $gutters, $prev-columns) {
// Apply padding.
> * {
$cell-width: 100% / $prev-columns;
$cell-width-pad: $gutters / $prev-columns;
padding: ($gutters * 0.5);
width: calc(#{$cell-width} + #{$cell-width-pad});
}
// Clear top/bottom gutters.
> :nth-child(-n + #{$prev-columns}) {
padding-top: ($gutters * 0.5);
}
> :nth-last-child(-n + #{$prev-columns}) {
padding-bottom: ($gutters * 0.5);
}
// Clear left/right gutters.
> :nth-child(#{$prev-columns}n + 1) {
padding-left: ($gutters * 0.5);
}
> :nth-child(#{$prev-columns}n) {
padding-right: ($gutters * 0.5);
}
// Adjust widths of leftmost and rightmost cells.
> :nth-child(#{$prev-columns}n + 1),
> :nth-child(#{$prev-columns}n) {
$cell-width: 100% / $columns;
$cell-width-pad: $gutters / $columns;
padding: ($gutters * 0.5);
width: calc(#{$cell-width} + #{$cell-width-pad});
}
}
/// Adds debug styles to current flexgrid element.
@mixin flexgrid-debug() {
box-shadow: 0 0 0 1px red;
> * {
box-shadow: inset 0 0 0 1px blue;
position: relative;
> * {
position: relative;
box-shadow: inset 0 0 0 1px green;
}
}
}
/// Initializes the current element as a flexgrid.
/// @param {integer} $columns Columns (optional).
/// @param {number} $gutters Gutters (optional).
/// @param {bool} $flush If true, clears padding around the very edge of the grid.
@mixin flexgrid($settings: ()) {
// Settings.
// Debug.
$debug: false;
@if (map-has-key($settings, 'debug')) {
$debug: map-get($settings, 'debug');
}
// Vertical align.
$vertical-align: null;
@if (map-has-key($settings, 'vertical-align')) {
$vertical-align: map-get($settings, 'vertical-align');
}
// Horizontal align.
$horizontal-align: null;
@if (map-has-key($settings, 'horizontal-align')) {
$horizontal-align: map-get($settings, 'horizontal-align');
}
// Columns.
$columns: null;
@if (map-has-key($settings, 'columns')) {
$columns: map-get($settings, 'columns');
}
// Gutters.
$gutters: 0;
@if (map-has-key($settings, 'gutters')) {
$gutters: map-get($settings, 'gutters');
}
// Flush.
$flush: true;
@if (map-has-key($settings, 'flush')) {
$flush: map-get($settings, 'flush');
}
// Initialize base grid.
@include flexgrid-base($vertical-align, $horizontal-align);
// Debug?
@if ($debug) {
@include flexgrid-debug;
}
// Columns specified?
@if ($columns != null) {
// Initialize columns.
@include flexgrid-columns($columns);
// Gutters specified?
@if ($gutters > 0) {
// Flush gutters?
@if ($flush) {
// Initialize gutters (flush).
@include flexgrid-gutters-flush($columns, $gutters);
}
// Otherwise ...
@else {
// Initialize gutters.
@include flexgrid-gutters($columns, $gutters);
}
}
}
}
/// Resizes a previously-initialized grid.
/// @param {integer} $columns Columns.
/// @param {number} $gutters Gutters (optional).
/// @param {list} $reset A list of previously-initialized grid columns (only if $flush is true).
/// @param {bool} $flush If true, clears padding around the very edge of the grid.
@mixin flexgrid-resize($settings: ()) {
// Settings.
// Columns.
$columns: 1;
@if (map-has-key($settings, 'columns')) {
$columns: map-get($settings, 'columns');
}
// Gutters.
$gutters: 0;
@if (map-has-key($settings, 'gutters')) {
$gutters: map-get($settings, 'gutters');
}
// Previous columns.
$prev-columns: false;
@if (map-has-key($settings, 'prev-columns')) {
$prev-columns: map-get($settings, 'prev-columns');
}
// Flush.
$flush: true;
@if (map-has-key($settings, 'flush')) {
$flush: map-get($settings, 'flush');
}
// Resize columns.
@include flexgrid-columns($columns);
// Gutters specified?
@if ($gutters > 0) {
// Flush gutters?
@if ($flush) {
// Previous columns specified?
@if ($prev-columns) {
// Convert to list if it isn't one already.
@if (type-of($prev-columns) != list) {
$prev-columns: ($prev-columns);
}
// Step through list of previous columns and reset them.
@each $x in $prev-columns {
@include flexgrid-gutters-flush-reset($columns, $gutters, $x);
}
}
// Resize gutters (flush).
@include flexgrid-gutters-flush($columns, $gutters);
}
// Otherwise ...
@else {
// Resize gutters.
@include flexgrid-gutters($columns, $gutters);
}
}
}
-587
View File
@@ -1,587 +0,0 @@
// skel.scss v3.0.2-dev | (c) skel.io | MIT licensed */
// Vars.
/// Breakpoints.
/// @var {list}
$breakpoints: () !global;
/// Vendor prefixes.
/// @var {list}
$vendor-prefixes: (
'-moz-',
'-webkit-',
'-ms-',
''
);
/// Properties that should be vendorized.
/// @var {list}
$vendor-properties: (
'align-content',
'align-items',
'align-self',
'animation',
'animation-delay',
'animation-direction',
'animation-duration',
'animation-fill-mode',
'animation-iteration-count',
'animation-name',
'animation-play-state',
'animation-timing-function',
'appearance',
'backface-visibility',
'box-sizing',
'filter',
'flex',
'flex-basis',
'flex-direction',
'flex-flow',
'flex-grow',
'flex-shrink',
'flex-wrap',
'justify-content',
'object-fit',
'object-position',
'order',
'perspective',
'pointer-events',
'transform',
'transform-origin',
'transform-style',
'transition',
'transition-delay',
'transition-duration',
'transition-property',
'transition-timing-function',
'user-select'
);
/// Values that should be vendorized.
/// @var {list}
$vendor-values: (
'filter',
'flex',
'linear-gradient',
'radial-gradient',
'transform'
);
// Functions.
/// Removes a specific item from a list.
/// @author Hugo Giraudel
/// @param {list} $list List.
/// @param {integer} $index Index.
/// @return {list} Updated list.
@function remove-nth($list, $index) {
$result: null;
@if type-of($index) != number {
@warn "$index: #{quote($index)} is not a number for `remove-nth`.";
}
@else if $index == 0 {
@warn "List index 0 must be a non-zero integer for `remove-nth`.";
}
@else if abs($index) > length($list) {
@warn "List index is #{$index} but list is only #{length($list)} item long for `remove-nth`.";
}
@else {
$result: ();
$index: if($index < 0, length($list) + $index + 1, $index);
@for $i from 1 through length($list) {
@if $i != $index {
$result: append($result, nth($list, $i));
}
}
}
@return $result;
}
/// Replaces a substring within another string.
/// @author Hugo Giraudel
/// @param {string} $string String.
/// @param {string} $search Substring.
/// @param {string} $replace Replacement.
/// @return {string} Updated string.
@function str-replace($string, $search, $replace: '') {
$index: str-index($string, $search);
@if $index {
@return str-slice($string, 1, $index - 1) + $replace + str-replace(str-slice($string, $index + str-length($search)), $search, $replace);
}
@return $string;
}
/// Replaces a substring within each string in a list.
/// @param {list} $strings List of strings.
/// @param {string} $search Substring.
/// @param {string} $replace Replacement.
/// @return {list} Updated list of strings.
@function str-replace-all($strings, $search, $replace: '') {
@each $string in $strings {
$strings: set-nth($strings, index($strings, $string), str-replace($string, $search, $replace));
}
@return $strings;
}
/// Gets a value from a map.
/// @author Hugo Giraudel
/// @param {map} $map Map.
/// @param {string} $keys Key(s).
/// @return {string} Value.
@function val($map, $keys...) {
@if nth($keys, 1) == null {
$keys: remove-nth($keys, 1);
}
@each $key in $keys {
$map: map-get($map, $key);
}
@return $map;
}
// Mixins.
/// Sets the global box model.
/// @param {string} $model Model (default is content).
@mixin boxModel($model: 'content') {
$x: $model + '-box';
*, *:before, *:after {
-moz-box-sizing: #{$x};
-webkit-box-sizing: #{$x};
box-sizing: #{$x};
}
}
/// Wraps @content in a @media block using a given breakpoint.
/// @param {string} $breakpoint Breakpoint.
/// @param {map} $queries Additional queries.
@mixin breakpoint($breakpoint: null, $queries: null) {
$query: 'screen';
// Breakpoint.
@if $breakpoint and map-has-key($breakpoints, $breakpoint) {
$query: $query + ' and ' + map-get($breakpoints, $breakpoint);
}
// Queries.
@if $queries {
@each $k, $v in $queries {
$query: $query + ' and (' + $k + ':' + $v + ')';
}
}
@media #{$query} {
@content;
}
}
/// Wraps @content in a @media block targeting a specific orientation.
/// @param {string} $orientation Orientation.
@mixin orientation($orientation) {
@media screen and (orientation: #{$orientation}) {
@content;
}
}
/// Utility mixin for containers.
/// @param {mixed} $width Width.
@mixin containers($width) {
// Locked?
$lock: false;
@if length($width) == 2 {
$width: nth($width, 1);
$lock: true;
}
// Modifiers.
.container.\31 25\25 { width: 100%; max-width: $width * 1.25; min-width: $width; }
.container.\37 5\25 { width: $width * 0.75; }
.container.\35 0\25 { width: $width * 0.5; }
.container.\32 5\25 { width: $width * 0.25; }
// Main class.
.container {
@if $lock {
width: $width !important;
}
@else {
width: $width;
}
}
}
/// Utility mixin for grid.
/// @param {list} $gutters Column and row gutters (default is 40px).
/// @param {string} $breakpointName Optional breakpoint name.
@mixin grid($gutters: 40px, $breakpointName: null) {
// Gutters.
@include grid-gutters($gutters);
@include grid-gutters($gutters, \32 00\25, 2);
@include grid-gutters($gutters, \31 50\25, 1.5);
@include grid-gutters($gutters, \35 0\25, 0.5);
@include grid-gutters($gutters, \32 5\25, 0.25);
// Cells.
$x: '';
@if $breakpointName {
$x: '\\28' + $breakpointName + '\\29';
}
.\31 2u#{$x}, .\31 2u\24#{$x} { width: 100%; clear: none; margin-left: 0; }
.\31 1u#{$x}, .\31 1u\24#{$x} { width: 91.6666666667%; clear: none; margin-left: 0; }
.\31 0u#{$x}, .\31 0u\24#{$x} { width: 83.3333333333%; clear: none; margin-left: 0; }
.\39 u#{$x}, .\39 u\24#{$x} { width: 75%; clear: none; margin-left: 0; }
.\38 u#{$x}, .\38 u\24#{$x} { width: 66.6666666667%; clear: none; margin-left: 0; }
.\37 u#{$x}, .\37 u\24#{$x} { width: 58.3333333333%; clear: none; margin-left: 0; }
.\36 u#{$x}, .\36 u\24#{$x} { width: 50%; clear: none; margin-left: 0; }
.\35 u#{$x}, .\35 u\24#{$x} { width: 41.6666666667%; clear: none; margin-left: 0; }
.\34 u#{$x}, .\34 u\24#{$x} { width: 33.3333333333%; clear: none; margin-left: 0; }
.\33 u#{$x}, .\33 u\24#{$x} { width: 25%; clear: none; margin-left: 0; }
.\32 u#{$x}, .\32 u\24#{$x} { width: 16.6666666667%; clear: none; margin-left: 0; }
.\31 u#{$x}, .\31 u\24#{$x} { width: 8.3333333333%; clear: none; margin-left: 0; }
.\31 2u\24#{$x} + *,
.\31 1u\24#{$x} + *,
.\31 0u\24#{$x} + *,
.\39 u\24#{$x} + *,
.\38 u\24#{$x} + *,
.\37 u\24#{$x} + *,
.\36 u\24#{$x} + *,
.\35 u\24#{$x} + *,
.\34 u\24#{$x} + *,
.\33 u\24#{$x} + *,
.\32 u\24#{$x} + *,
.\31 u\24#{$x} + * {
clear: left;
}
.\-11u#{$x} { margin-left: 91.6666666667% }
.\-10u#{$x} { margin-left: 83.3333333333% }
.\-9u#{$x} { margin-left: 75% }
.\-8u#{$x} { margin-left: 66.6666666667% }
.\-7u#{$x} { margin-left: 58.3333333333% }
.\-6u#{$x} { margin-left: 50% }
.\-5u#{$x} { margin-left: 41.6666666667% }
.\-4u#{$x} { margin-left: 33.3333333333% }
.\-3u#{$x} { margin-left: 25% }
.\-2u#{$x} { margin-left: 16.6666666667% }
.\-1u#{$x} { margin-left: 8.3333333333% }
}
/// Utility mixin for grid.
/// @param {list} $gutters Gutters.
/// @param {string} $class Optional class name.
/// @param {integer} $multiplier Multiplier (default is 1).
@mixin grid-gutters($gutters, $class: null, $multiplier: 1) {
// Expand gutters if it's not a list.
@if length($gutters) == 1 {
$gutters: ($gutters, 0);
}
// Get column and row gutter values.
$c: nth($gutters, 1);
$r: nth($gutters, 2);
// Get class (if provided).
$x: '';
@if $class {
$x: '.' + $class;
}
// Default.
.row#{$x} > * { padding: ($r * $multiplier) 0 0 ($c * $multiplier); }
.row#{$x} { margin: ($r * $multiplier * -1) 0 -1px ($c * $multiplier * -1); }
// Uniform.
.row.uniform#{$x} > * { padding: ($c * $multiplier) 0 0 ($c * $multiplier); }
.row.uniform#{$x} { margin: ($c * $multiplier * -1) 0 -1px ($c * $multiplier * -1); }
}
/// Wraps @content in vendorized keyframe blocks.
/// @param {string} $name Name.
@mixin keyframes($name) {
@-moz-keyframes #{$name} { @content; }
@-webkit-keyframes #{$name} { @content; }
@-ms-keyframes #{$name} { @content; }
@keyframes #{$name} { @content; }
}
///
/// Sets breakpoints.
/// @param {map} $x Breakpoints.
///
@mixin skel-breakpoints($x: ()) {
$breakpoints: $x !global;
}
///
/// Initializes layout module.
/// @param {map} config Config.
///
@mixin skel-layout($config: ()) {
// Config.
$configPerBreakpoint: ();
$z: map-get($config, 'breakpoints');
@if $z {
$configPerBreakpoint: $z;
}
// Reset.
$x: map-get($config, 'reset');
@if $x {
/* Reset */
@include reset($x);
}
// Box model.
$x: map-get($config, 'boxModel');
@if $x {
/* Box Model */
@include boxModel($x);
}
// Containers.
$containers: map-get($config, 'containers');
@if $containers {
/* Containers */
.container {
margin-left: auto;
margin-right: auto;
}
// Use default is $containers is just "true".
@if $containers == true {
$containers: 960px;
}
// Apply base.
@include containers($containers);
// Apply per-breakpoint.
@each $name in map-keys($breakpoints) {
// Get/use breakpoint setting if it exists.
$x: map-get($configPerBreakpoint, $name);
// Per-breakpoint config exists?
@if $x {
$y: map-get($x, 'containers');
// Setting exists? Use it.
@if $y {
$containers: $y;
}
}
// Create @media block.
@media screen and #{map-get($breakpoints, $name)} {
@include containers($containers);
}
}
}
// Grid.
$grid: map-get($config, 'grid');
@if $grid {
/* Grid */
// Use defaults if $grid is just "true".
@if $grid == true {
$grid: ();
}
// Sub-setting: Gutters.
$grid-gutters: 40px;
$x: map-get($grid, 'gutters');
@if $x {
$grid-gutters: $x;
}
// Rows.
.row {
border-bottom: solid 1px transparent;
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
box-sizing: border-box;
}
.row > * {
float: left;
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
box-sizing: border-box;
}
.row:after, .row:before {
content: '';
display: block;
clear: both;
height: 0;
}
.row.uniform > * > :first-child {
margin-top: 0;
}
.row.uniform > * > :last-child {
margin-bottom: 0;
}
// Gutters (0%).
@include grid-gutters($grid-gutters, \30 \25, 0);
// Apply base.
@include grid($grid-gutters);
// Apply per-breakpoint.
@each $name in map-keys($breakpoints) {
// Get/use breakpoint setting if it exists.
$x: map-get($configPerBreakpoint, $name);
// Per-breakpoint config exists?
@if $x {
$y: map-get($x, 'grid');
// Setting exists?
@if $y {
// Sub-setting: Gutters.
$x: map-get($y, 'gutters');
@if $x {
$grid-gutters: $x;
}
}
}
// Create @media block.
@media screen and #{map-get($breakpoints, $name)} {
@include grid($grid-gutters, $name);
}
}
}
}
/// Resets browser styles.
/// @param {string} $mode Mode (default is 'normalize').
@mixin reset($mode: 'normalize') {
@if $mode == 'normalize' {
// normalize.css v3.0.2 | MIT License | git.io/normalize
html{font-family:sans-serif;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block;vertical-align:baseline}audio:not([controls]){display:none;height:0}[hidden],template{display:none}a{background-color:transparent}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:700}dfn{font-style:italic}h1{font-size:2em;margin:.67em 0}mark{background:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sup{top:-.5em}sub{bottom:-.25em}img{border:0}svg:not(:root){overflow:hidden}figure{margin:1em 40px}hr{-moz-box-sizing:content-box;box-sizing:content-box;height:0}pre{overflow:auto}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}button,input,optgroup,select,textarea{color:inherit;font:inherit;margin:0}button{overflow:visible}button,select{text-transform:none}button,html input[type=button],input[type=reset],input[type=submit]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0}input{line-height:normal}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer-spin-button{height:auto}input[type=search]{-webkit-appearance:textfield;-moz-box-sizing:content-box;-webkit-box-sizing:content-box;box-sizing:content-box}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration{-webkit-appearance:none}fieldset{border:1px solid silver;margin:0 2px;padding:.35em .625em .75em}legend{border:0;padding:0}textarea{overflow:auto}optgroup{font-weight:700}table{border-collapse:collapse;border-spacing:0}td,th{padding:0}
}
@else if $mode == 'full' {
// meyerweb.com/eric/tools/css/reset v2.0 | 20110126 | License: none (public domain)
html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,embed,figure,figcaption,footer,header,hgroup,menu,nav,output,ruby,section,summary,time,mark,audio,video{margin:0;padding:0;border:0;font-size:100%;font:inherit;vertical-align:baseline;}article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section{display:block;}body{line-height:1;}ol,ul{list-style:none;}blockquote,q{quotes:none;}blockquote:before,blockquote:after,q:before,q:after{content:'';content:none;}table{border-collapse:collapse;border-spacing:0;}body{-webkit-text-size-adjust:none}
}
}
/// Vendorizes a declaration's property and/or value(s).
/// @param {string} $property Property.
/// @param {mixed} $value String/list of value(s).
@mixin vendor($property, $value) {
// Determine if property should expand.
$expandProperty: index($vendor-properties, $property);
// Determine if value should expand (and if so, add '-prefix-' placeholder).
$expandValue: false;
@each $x in $value {
@each $y in $vendor-values {
@if $y == str-slice($x, 1, str-length($y)) {
$value: set-nth($value, index($value, $x), '-prefix-' + $x);
$expandValue: true;
}
}
}
// Expand property?
@if $expandProperty {
@each $vendor in $vendor-prefixes {
#{$vendor}#{$property}: #{str-replace-all($value, '-prefix-', $vendor)};
}
}
// Expand just the value?
@elseif $expandValue {
@each $vendor in $vendor-prefixes {
#{$property}: #{str-replace-all($value, '-prefix-', $vendor)};
}
}
// Neither? Treat them as a normal declaration.
@else {
#{$property}: #{$value};
}
}
-58
View File
@@ -1,58 +0,0 @@
// Misc.
$misc: (
z-index-base: 10000
);
// Duration.
$duration: (
transition: 0.2s
);
// Size.
$size: (
border-radius: 0.35em,
element-height: 2.75em,
element-margin: 2em
);
// Font.
$font: (
family: ('Source Sans Pro', Helvetica, sans-serif),
family-fixed: ('Courier New', monospace),
weight: 400,
weight-bold: 400
);
// Palette.
$palette: (
bg: #fff,
fg: #a2a2a2,
fg-bold: #787878,
fg-light: #b2b2b2,
border: #efefef,
border-bg: #f7f7f7,
border2: #dfdfdf,
border2-bg: #e7e7e7,
accent1: (
bg: #49bf9d,
fg: mix(#49bf9d, #ffffff, 25%),
fg-bold: #ffffff,
fg-light: mix(#49bf9d, #ffffff, 40%),
border: rgba(255,255,255,0.25),
border-bg: rgba(255,255,255,0.075),
border2: rgba(255,255,255,0.5),
border2-bg: rgba(255,255,255,0.2)
),
accent2: (
bg: #1f1815,
fg: rgba(255,255,255,0.5),
fg-bold: #ffffff,
fg-light: rgba(255,255,255,0.4),
border: rgba(255,255,255,0.25),
border-bg: rgba(255,255,255,0.075),
border2: rgba(255,255,255,0.5),
border2-bg: rgba(255,255,255,0.2)
)
);
File diff suppressed because it is too large. Load diff
+450
View File
@@ -0,0 +1,450 @@
/*
thiessen.io — personal site
Swiss-minimal direction. Display: Space Grotesk · Body: Hanken Grotesk · Mono: Space Mono.
One accent (signal cobalt), used only for links and the live timeline node.
*/
:root {
--paper: #f4f5f7;
--paper-2: #ffffff;
--ink: #15171c;
--graphite: #565d66;
--graphite-2: #8a919b;
--mist: #e4e7ec;
--signal: #1f44f0;
--signal-weak: rgba(31, 68, 240, 0.10);
--font-display: 'Space Grotesk', 'Hanken Grotesk', system-ui, sans-serif;
--font-body: 'Hanken Grotesk', system-ui, -apple-system, sans-serif;
--font-mono: 'Space Mono', ui-monospace, 'SFMono-Regular', monospace;
--wrap: 1100px;
}
/* ---------- reset ---------- */
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font-family: var(--font-body);
font-size: 1.0625rem;
line-height: 1.65;
font-weight: 400;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
img { display: block; max-width: 100%; }
a { color: inherit; }
::selection { background: var(--signal); color: #fff; }
:focus-visible {
outline: 2px solid var(--signal);
outline-offset: 3px;
border-radius: 1px;
}
/* ---------- layout ---------- */
.wrap {
width: 100%;
max-width: var(--wrap);
margin: 0 auto;
padding: 0 clamp(1.25rem, 5vw, 3.5rem);
}
.section {
display: grid;
grid-template-columns: minmax(0, 11rem) minmax(0, 1fr);
gap: 2.75rem;
padding: clamp(3.5rem, 7vw, 6rem) 0;
border-top: 1px solid var(--mist);
}
.section__label {
position: sticky;
top: 2rem;
align-self: start;
font-family: var(--font-mono);
font-size: 0.72rem;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--graphite-2);
}
/* ---------- top bar ---------- */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1.75rem 0;
font-family: var(--font-mono);
font-size: 0.74rem;
letter-spacing: 0.08em;
}
.topbar__name { color: var(--ink); text-transform: uppercase; }
.status {
display: inline-flex;
align-items: center;
gap: 0.55rem;
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--graphite);
}
.status__dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--signal);
box-shadow: 0 0 0 4px var(--signal-weak);
animation: pulse 2.6s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { box-shadow: 0 0 0 4px var(--signal-weak); }
50% { box-shadow: 0 0 0 7px rgba(31, 68, 240, 0.03); }
}
/* ---------- hero ---------- */
.hero {
display: grid;
grid-template-columns: minmax(0, 1.55fr) minmax(0, 0.7fr);
gap: clamp(2rem, 5vw, 4rem);
align-items: center;
padding: clamp(2rem, 5vw, 3.5rem) 0 clamp(3.5rem, 7vw, 5.5rem);
}
.eyebrow {
font-family: var(--font-mono);
font-size: 0.74rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--graphite-2);
margin: 0 0 1.5rem;
}
.hero__name {
font-family: var(--font-display);
font-weight: 600;
font-size: clamp(3rem, 9.5vw, 7rem);
line-height: 0.92;
letter-spacing: -0.035em;
margin: 0;
}
.hero__lead {
font-family: var(--font-display);
font-weight: 400;
font-size: clamp(1.35rem, 2.7vw, 2rem);
line-height: 1.18;
letter-spacing: -0.01em;
color: var(--ink);
max-width: 30ch;
margin: 1.75rem 0 0;
}
.hero__meta {
margin: 1.5rem 0 0;
color: var(--graphite);
max-width: 46ch;
}
.hero__meta strong { color: var(--ink); font-weight: 600; }
.actions {
display: flex;
flex-wrap: wrap;
gap: 1.25rem 2rem;
margin: 2.25rem 0 0;
}
.actions a {
font-weight: 500;
color: var(--ink);
text-decoration: none;
border-bottom: 1.5px solid var(--ink);
padding-bottom: 2px;
transition: color 0.15s ease, border-color 0.15s ease;
}
.actions a:hover { color: var(--signal); border-color: var(--signal); }
.portrait {
justify-self: end;
width: 100%;
max-width: 280px;
}
.portrait img {
width: 100%;
aspect-ratio: 4 / 5;
object-fit: cover;
border-radius: 6px;
border: 1px solid var(--mist);
filter: grayscale(100%) contrast(1.02);
transition: filter 0.4s ease;
}
.portrait img:hover { filter: grayscale(0%); }
/* ---------- profile ---------- */
.prose p {
margin: 0 0 1.25rem;
color: var(--graphite);
max-width: 62ch;
font-size: 1.1rem;
}
.prose p:last-child { margin-bottom: 0; }
.prose strong { color: var(--ink); font-weight: 600; }
/* ---------- experience timeline (the signature) ---------- */
.roles { list-style: none; margin: 0; padding: 0; }
.role {
display: grid;
grid-template-columns: 1.5rem minmax(0, 1fr);
column-gap: 1.5rem;
}
.role__rail { position: relative; }
.role__rail::before {
content: '';
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 1px;
background: var(--mist);
transform: translateX(-50%);
}
.role:first-child .role__rail::before { top: 0.62rem; }
.role:last-child .role__rail::before { bottom: auto; height: 0.62rem; }
.role__node {
position: absolute;
left: 50%;
top: 0.62rem;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--paper);
border: 1.5px solid var(--ink);
transform: translate(-50%, -50%);
z-index: 1;
}
.role--live .role__node {
background: var(--signal);
border-color: var(--signal);
box-shadow: 0 0 0 4px var(--signal-weak);
animation: pulse 2.6s ease-in-out infinite;
}
.role__body { padding-bottom: 2.75rem; }
.role:last-child .role__body { padding-bottom: 0; }
.role__year {
font-family: var(--font-mono);
font-size: 0.72rem;
letter-spacing: 0.08em;
color: var(--graphite-2);
margin: 0 0 0.5rem;
}
.role--live .role__year { color: var(--signal); }
.role__head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.4rem 0.85rem;
}
.role__co {
font-family: var(--font-display);
font-weight: 600;
font-size: 1.4rem;
letter-spacing: -0.015em;
margin: 0;
}
.role__loc {
font-family: var(--font-mono);
font-size: 0.72rem;
letter-spacing: 0.06em;
color: var(--graphite-2);
}
.role__title {
margin: 0.35rem 0 0;
font-weight: 600;
color: var(--ink);
}
.role__desc {
margin: 0.55rem 0 0;
color: var(--graphite);
max-width: 58ch;
}
/* ---------- stack ---------- */
.stack { display: flex; flex-direction: column; gap: 1.6rem; }
.group {
display: grid;
grid-template-columns: minmax(0, 10rem) minmax(0, 1fr);
gap: 0.5rem 1.5rem;
align-items: baseline;
padding-bottom: 1.6rem;
border-bottom: 1px solid var(--mist);
}
.stack .group:last-child { border-bottom: 0; padding-bottom: 0; }
.group__label {
font-family: var(--font-mono);
font-size: 0.72rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--graphite-2);
}
.group__tags {
font-family: var(--font-mono);
font-size: 0.82rem;
line-height: 1.95;
color: var(--graphite-2);
}
.group__tags .t { color: var(--ink); }
/* ---------- contact ---------- */
.contact__intro {
color: var(--graphite);
max-width: 50ch;
font-size: 1.1rem;
margin: 0 0 1.75rem;
}
.contact__email {
display: inline-block;
font-family: var(--font-display);
font-weight: 500;
font-size: clamp(1.7rem, 4.5vw, 3rem);
letter-spacing: -0.025em;
color: var(--ink);
text-decoration: none;
transition: color 0.15s ease;
}
.contact__email:hover { color: var(--signal); }
.contact__links {
display: flex;
flex-wrap: wrap;
gap: 1.25rem 2rem;
margin: 2rem 0 0;
}
.contact__links a {
font-weight: 500;
color: var(--ink);
text-decoration: none;
border-bottom: 1.5px solid var(--ink);
padding-bottom: 2px;
transition: color 0.15s ease, border-color 0.15s ease;
}
.contact__links a:hover { color: var(--signal); border-color: var(--signal); }
/* ---------- colophon ---------- */
.colophon {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 0.75rem 1.5rem;
padding: 2.25rem 0 3.5rem;
border-top: 1px solid var(--mist);
font-family: var(--font-mono);
font-size: 0.7rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--graphite-2);
}
/* ---------- 404 ---------- */
.notfound { padding: clamp(4rem, 12vw, 9rem) 0; }
.notfound h1 {
font-family: var(--font-display);
font-weight: 600;
font-size: clamp(2.5rem, 8vw, 5rem);
letter-spacing: -0.03em;
margin: 0 0 1rem;
}
.notfound p { color: var(--graphite); max-width: 40ch; }
.notfound a {
color: var(--ink);
border-bottom: 1.5px solid var(--ink);
text-decoration: none;
padding-bottom: 2px;
}
.notfound a:hover { color: var(--signal); border-color: var(--signal); }
/* ---------- responsive ---------- */
@media (max-width: 820px) {
.section { grid-template-columns: 1fr; gap: 1.5rem; }
.section__label { position: static; top: auto; }
.group { grid-template-columns: 1fr; }
}
@media (max-width: 680px) {
.hero {
grid-template-columns: 1fr;
gap: 2.5rem;
}
.portrait { justify-self: start; order: -1; max-width: 150px; }
.portrait img { aspect-ratio: 1 / 1; }
}
@media (max-width: 520px) {
.topbar { flex-wrap: wrap; gap: 0.4rem 1rem; }
}
@media (max-width: 460px) {
.topbar { font-size: 0.68rem; letter-spacing: 0.05em; }
.status { letter-spacing: 0.12em; }
}
/* ---------- motion ---------- */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
}
-22
View File
@@ -1,22 +0,0 @@
import React from 'react'
class Footer extends React.Component {
render() {
return (
<div id="footer">
<div className="inner">
<ul className="icons">
<li><a href="#" className="icon fa-gitlab"><span className="label">Github</span></a></li>
<li><a href="https://www.linkedin.com/in/dennis-thiessen" className="icon fa-linkedin"><span className="label">Linkedin</span></a></li>
<li><a href="mailto:dennis@thiessen.io" className="icon fa-envelope-o"><span className="label">Email</span></a></li>
</ul>
<ul className="copyright">
<li>&copy; Gatsby Starter Strata</li><li>Design: <a href="http://html5up.net">HTML5 UP</a></li>
</ul>
</div>
</div>
)
}
}
export default Footer
-50
View File
@@ -1,50 +0,0 @@
import React, { useState } from 'react'
import Lightbox from "yet-another-react-lightbox"
import "yet-another-react-lightbox/styles.css"
const Gallery = ({ images = [] }) => {
const [isOpen, setIsOpen] = useState(false)
const [photoIndex, setPhotoIndex] = useState(0)
const openLightbox = (index) => {
setPhotoIndex(index)
setIsOpen(true)
}
return (
<>
{isOpen && (
<Lightbox
open={isOpen}
close={() => setIsOpen(false)}
index={photoIndex}
slides={images.map(img => ({
src: img.src,
title: img.caption,
description: img.description
}))}
/>
)}
<div className="row">
{images.map((obj, i) => (
<article className="6u 12u$(xsmall) work-item" key={i}>
<a
className="image fit thumb"
onClick={(e) => {
e.preventDefault()
openLightbox(i)
}}
href={obj.src}
>
<img src={obj.thumbnail} alt={obj.caption} />
</a>
<h3>{obj.caption}</h3>
<p>{obj.description}</p>
</article>
))}
</div>
</>
)
}
export default Gallery
-21
View File
@@ -1,21 +0,0 @@
import React from 'react'
import Footer from './Footer'
import avatar from '../assets/images/avatar.jpg'
class Header extends React.Component {
render() {
return (
<header id="header">
<div className="inner">
<a href="#" className="image avatar"><img src={avatar} alt="Dennis Thießen" /></a>
<h1><strong>I&apos;m Dennis Thiessen</strong>, a swiss-based <br />
freelance software, data & ai engineer.</h1>
</div>
<Footer />
</header>
)
}
}
export default Header
+3 -16
View File
@@ -1,19 +1,6 @@
import React from 'react'
import '../assets/scss/main.scss'
import '../assets/style.css'
import Header from './Header'
const Layout = ({ children }) => <>{children}</>
class Template extends React.Component {
render() {
const { children } = this.props
return (
<div>
<Header />
{children}
</div>
)
}
}
export default Template
export default Layout
+13 -2
View File
@@ -1,10 +1,21 @@
import React from 'react'
import { Helmet } from 'react-helmet'
import Layout from '../components/layout'
const NotFoundPage = () => (
<Layout>
<h1>NOT FOUND</h1>
<p>You just hit a route that doesn&#39;t exist... the sadness.</p>
<Helmet>
<html lang="en" />
<title>Not found — Dennis Thiessen</title>
</Helmet>
<div className="wrap notfound">
<p className="eyebrow">Error 404</p>
<h1>This page took a wrong turn.</h1>
<p>
The page you were after isn&apos;t here. <a href="/">Head back home</a>.
</p>
</div>
</Layout>
)
+169 -262
View File
@@ -2,294 +2,201 @@ import React from 'react'
import { Helmet } from 'react-helmet'
import Layout from '../components/layout'
// import Lightbox from 'react-images'
import Gallery from '../components/Gallery'
import avatar from '../assets/images/avatar.jpg'
import thumb01 from '../assets/images/thumbs/01.jpg'
import thumb02 from '../assets/images/thumbs/02.jpg'
import thumb03 from '../assets/images/thumbs/03.jpg'
import thumb04 from '../assets/images/thumbs/04.jpg'
import thumb05 from '../assets/images/thumbs/05.jpg'
import thumb06 from '../assets/images/thumbs/06.jpg'
import thumb07 from '../assets/images/thumbs/07.jpg'
import thumb08 from '../assets/images/thumbs/08.jpg'
import thumb09 from '../assets/images/thumbs/09.jpg'
const SITE_TITLE = 'Dennis Thiessen — Data, Analytics & AI Engineer'
const SITE_DESCRIPTION =
'Dennis Thiessen is a Staff Data, Analytics & AI Engineer in Bern, Switzerland, building the data pipelines and ML infrastructure that production systems run on.'
import full01 from '../assets/images/fulls/01.jpg'
import full02 from '../assets/images/fulls/02.jpg'
import full03 from '../assets/images/fulls/03.jpg'
import full04 from '../assets/images/fulls/04.jpg'
import full05 from '../assets/images/fulls/05.jpg'
import full06 from '../assets/images/fulls/06.jpg'
import full07 from '../assets/images/fulls/07.jpg'
import full08 from '../assets/images/fulls/08.jpg'
import full09 from '../assets/images/fulls/09.jpg'
const EMAIL = 'dennis@thiessen.io'
const LINKEDIN = 'https://www.linkedin.com/in/dennis-thiessen'
const DEFAULT_IMAGES = [
{ id: '9', src: full09, thumbnail: thumb09, caption: 'Swisscom (Switzerland) AG, Bern, Switzerland', description: 'Working as a (Senior) Data, Analytics and AI Engineer.'},
{ id: '8', src: full08, thumbnail: thumb08, caption: 'Robert Bosch GmbH, Dresden, Germany', description: 'Working as a (Senior) Data Engineer for 300mm Semiconductor Lab Startup.'},
{ id: '6', src: full06, thumbnail: thumb06, caption: 'Fraunhofer CML, Hamburg, Germany', description: 'Worked as Software Engineer and Research Associate with development tasks in C#, .NET and Python for workforce scheduling and machine learning.'},
{ id: '5', src: full05, thumbnail: thumb05, caption: 'Vizrt, Bergen, Norway', description: 'Worked as DevOps-Engineer with development tasks in Python and C++ for a backend transcoding engine. Video & Audio Output Test Automation as well as CI & CD with Jenkins.'},
{ id: '4', src: full04, thumbnail: thumb04, caption: 'Generali, Hamburg, Germany', description: 'Worked as Software-Developer and IT Consultant for a web-based workflow application written in Java, using OracleDB while migrating from BPM Process Engine to Camunda BPM. Moreover doing a (successful) PoC with Behaviour Driven Development.'},
{ id: '3', src: full03, thumbnail: thumb03, caption: 'Capgemini, Hamburg, Germany', description: 'Worked as Software-Developer in a multi-national distributed team for a international logistics client. Development of a package track & trace application in Java and with great focus on Test Automation.'},
{ id: '1', src: full01, thumbnail: thumb01, caption: 'Freelance - RiskAhead.net', description: 'Side-project where I developed a native android app in Java using REST services in PHP with access to a MySQL database. Huge focus on performance and scalability.'},
{ id: '2', src: full02, thumbnail: thumb02, caption: 'Freelance - MyIdealAd.com', description: 'Freelance side-project with process automation in ZAPIER, Python scripting, development and hosting of a Wordpress Web-Site.'}
];
// Career as a pipeline — most recent first. Each node is a role.
const ROLES = [
{
year: '2023 — PRESENT',
company: 'Swisscom',
location: 'Bern, CH',
title: 'Staff Data, Analytics & AI Engineer',
description:
'ETL and streaming pipelines in Python, Kafka and SAP BODS feeding a Teradata warehouse — and migrating the legacy stack onto AWS (Glue, Redshift, Lambda, Step Functions, Airflow).',
live: true,
},
{
year: '2020 — 2023',
company: 'Bosch Semiconductor',
location: 'Dresden, DE',
title: 'Senior Data Engineer',
description:
'Containerised ML inference (Docker, Kubernetes, Ansible) into 24/7 semiconductor production lines, and built Python, Java and C# data services over Oracle and Hadoop/Impala.',
},
{
year: '2018 — 2020',
company: 'Fraunhofer CML',
location: 'Hamburg, DE',
title: 'Research Software Engineer',
description:
'Optimisation-based crew-scheduling decision support in C#/.NET, plus research microservices and a Jenkins CI/CD pipeline with quality gates.',
},
{
year: '2017 — 2018',
company: 'Vizrt',
location: 'Bergen, NO',
title: 'DevOps Engineer',
description:
'A distributed video-transcoding backend in Python and C++, with automated audio/video integration testing for reliable releases.',
},
{
year: '2015 — 2017',
company: 'Generali',
location: 'Hamburg, DE',
title: 'Software Engineer & IT Consultant',
description:
'A Java/J2EE workflow portal on Oracle, and introduced behaviour-driven development and RPA automation (UiPath, Camunda).',
},
]
class HomeIndex extends React.Component {
const STACK = [
{ label: 'Languages', items: ['Python', 'SQL', 'Java', 'C#', 'TypeScript', 'C++'] },
{ label: 'Data & Pipelines', items: ['ETL / ELT', 'Kafka', 'Airflow', 'SAP BODS', 'Teradata', 'Hadoop / Impala'] },
{ label: 'Cloud & Infra', items: ['AWS', 'Docker', 'Kubernetes', 'Ansible', 'CI/CD'] },
{ label: 'ML & Observability', items: ['PyTorch', 'scikit-learn', 'ELK', 'Grafana', 'Prometheus'] },
{ label: 'Credentials', items: ['M.Eng., UniBw München', 'AWS Solutions Architect – Associate', 'iSAQB CPSA-F'] },
]
constructor() {
super();
const Tags = ({ items }) =>
items.map((item, i) => (
<React.Fragment key={item}>
{i > 0 && ' · '}
<span className="t">{item}</span>
</React.Fragment>
))
this.state = {
invalid: false,
displayErrors: false,
res: null
}
this.handleSubmit = this.handleSubmit.bind(this)
this.closeLightbox = this.closeLightbox.bind(this);
this.gotoNext = this.gotoNext.bind(this);
this.gotoPrevious = this.gotoPrevious.bind(this);
this.openLightbox = this.openLightbox.bind(this);
this.handleClickImage = this.handleClickImage.bind(this);
}
handleSubmit(event) {
event.preventDefault();
if (!event.target.checkValidity()) {
this.setState({
invalid: true,
displayErrors: true,
});
return;
}
const form = event.target;
const data = new FormData(form);
for (let name of data.keys()) {
const input = form.elements[name];
const parserName = input.dataset.parse;
console.log('parser name is', parserName);
if (parserName) {
const parsedValue = inputParsers[parserName](data.get(name))
data.set(name, parsedValue);
}
}
data.append('token', 'gdfgdfashdf');
data.append('receiver', 'dennis@thiessen.io');
data.append('subject', 'Contact from www.thiessen.io');
this.setState({
res: stringifyFormData(data),
invalid: false,
displayErrors: false,
});
fetch('https://www.riskahead.de/api/v1/web/sendcustommail', {
method: 'POST',
body: data,
});
}
openLightbox (index, event) {
event.preventDefault();
this.setState({
currentImage: index,
lightboxIsOpen: true,
});
}
closeLightbox () {
this.setState({
currentImage: 0,
lightboxIsOpen: false,
});
}
gotoPrevious () {
this.setState({
currentImage: this.state.currentImage - 1,
});
}
gotoNext () {
this.setState({
currentImage: this.state.currentImage + 1,
});
}
handleClickImage () {
if (this.state.currentImage === this.props.images.length - 1) return;
this.gotoNext();
}
render() {
const siteTitle = "Dennis Thiessen - IT Software Engineering"
const siteDescription = "Portfolio of Dennis Thiessen - IT Software Engineering"
const { res, invalid, displayErrors } = this.state;
return (
const IndexPage = () => (
<Layout>
<Helmet>
<title>{siteTitle}</title>
<meta name="description" content={siteDescription}/>
<html lang="en" />
<meta charset="utf-8"/>
<meta charSet="utf-8" />
<title>{SITE_TITLE}</title>
<meta name="description" content={SITE_DESCRIPTION} />
<meta name="theme-color" content="#15171c" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600&family=Space+Grotesk:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap"
/>
</Helmet>
<div id="main">
<section id="one">
<header className="major">
<h3>Are you looking for a freelance Full-Stack Developer / Consultant? <br />Look no more!</h3>
<div className="wrap">
<header className="topbar">
<span className="topbar__name">Dennis Thiessen</span>
<span className="status">
<span className="status__dot" aria-hidden="true" />
Open to new work
</span>
</header>
<p>I&apos;m a <b>Software, Data & AI Engineer</b> and <b>Consultant</b> who has work experience in <b>Java</b>, <b>C#</b> and <b>Python</b>. I did multiple projects for various companies and below you can find a short resume and project portfolio.
If you are looking for a passionate <b>IT Freelancer</b>, don&apos;t hesitate to contact me. I mostly work in the D-A-CH area but I am also available for international remote work.</p>
<p>Here is a short enumeration of my skills & tools. Further below you can find the project & companies I have worked with. For a full CV, or if you need further information, just contact me.</p>
<p><strong>Programming &amp; Script &amp; Markup Languages: </strong></p>
<ul>
<li>Python</li>
<li>Java, J2EE</li>
<li>JavaScript</li>
<li>C# &amp; .NET</li>
<li>PHP</li>
<li>HTML &amp; CSS</li>
<li>XML, JSON &amp; YAML</li>
</ul>
<p><strong>Databases & DWH:</strong></p>
<ul>
<li>MySQL</li>
<li>PostgreSQL</li>
<li>Oracle</li>
<li>Teradata DWH</li>
</ul>
<p><strong>Tools &amp; Frameworks:</strong></p>
<ul>
<li>IDEs: IntelliJ, Android Studio, VS Code</li>
<li>Versioning: git (Gitlab, Gitea, Github)</li>
<li>Project Management: JIRA, Confluence</li>
<li>RPA: UIPath</li>
<li>Test Automation: JUnit, Selenium, Serenity BDD</li>
<li>Frameworks: Pandas, Numpy, Torch, .NET, Entity, Spring, PyMath</li>
<li>Build &amp; Virtualization: Jenkins, Docker, Kubernetes, Gitlab CI/CD</li>
<li>Web: Nginx, RESTless Web-Services</li>
<li>OS: Win, MacOs, Linux (Debian, RedHat, Ubuntu)</li>
</ul>
<p><strong>Certifications:</strong></p>
<ul>
<li>AWS Certified Solutions Architect – Associate (AWS)</li>
<li>Certified Professional for Software Architecture, Foundation Level (ISAQB)</li>
<li>Robotic Process Automation / UIPath Developer Training (UIPath)</li>
<li>Camunda BPM Process Engine Basic & Advanced Training (Camunda Services GmbH)</li>
<li>ITIL v3 Foundation Certificate in IT Service Management (Serview GmbH)</li>
<li>IT Project Management (Integrata AG)</li>
</ul>
<p><strong>Spoken Languages:</strong></p>
<ul>
<li>German (mother tongue)</li>
<li>English (Fluent)</li>
<li>Russian (Basic)</li>
<li>Norwegian (Basic)</li>
</ul>
<section className="hero">
<div className="hero__text">
<p className="eyebrow">Data · Analytics · AI Engineering</p>
<h1 className="hero__name">Dennis Thiessen</h1>
<p className="hero__lead">
I build the pipelines and platforms that move data and run models in production.
</p>
<p className="hero__meta">
<strong>Staff Data, Analytics &amp; AI Engineer at Swisscom</strong> — based in Bern,
Switzerland, with 8+ years across data engineering, ML infrastructure and DevOps.
</p>
<nav className="actions">
<a href={`mailto:${EMAIL}`}>{EMAIL}</a>
<a href={LINKEDIN} target="_blank" rel="noopener noreferrer">
LinkedIn ↗
</a>
</nav>
</div>
<div className="portrait">
<img src={avatar} alt="Dennis Thiessen" />
</div>
</section>
<section id="two">
<h2>Recent Work</h2>
<Gallery images={DEFAULT_IMAGES.map(({ id, src, thumbnail, caption, description }) => ({
src,
thumbnail,
caption,
description
}))} />
<section className="section">
<h2 className="section__label">Profile</h2>
<div className="prose">
<p>
I work where <strong>software engineering meets operations</strong> — designing ETL/ELT
and streaming pipelines, containerising ML inference, and turning monolithic data
processes into event-driven, serverless systems.
</p>
<p>
Right now I&apos;m at Swisscom in Bern, moving a legacy Teradata and Oracle stack onto
AWS. What I care about most is <strong>automation and reliability</strong>: systems that
run themselves, recover on their own, and don&apos;t page anyone at 3&nbsp;a.m.
</p>
</div>
</section>
<section id="three">
<h2>Send request</h2>
<p>You can contact me via this contact form. <br />I will get in touch with you <b>ASAP</b>.</p>
<div className="row">
<div className="8u 12u$(small)">
<form onSubmit={this.handleSubmit} noValidate className={displayErrors ? 'displayErrors' : ''}>
<div className="row uniform 50%">
<div className="6u 12u$(xsmall)"><input type="text" name="name" id="name" placeholder="Name" required/></div>
<div className="6u 12u$(xsmall)"><input type="email" name="email" id="email" placeholder="Email" required/></div>
<div className="12u"><textarea name="text" id="text" placeholder="Message" rows="4" required></textarea></div>
<section className="section">
<h2 className="section__label">Experience</h2>
<ol className="roles">
{ROLES.map((role) => (
<li className={`role${role.live ? ' role--live' : ''}`} key={role.company}>
<div className="role__rail" aria-hidden="true">
<span className="role__node" />
</div>
<ul className="actions">
<br />
<li><input type="submit" value="Send Message" /></li>
</ul>
</form>
<div className="res-block">
{invalid && (
<ShakingError text="Form is not valid, please check your input." />
)}
{!invalid && res && (
<ShakingError text="Message sent successfully." />
)}
<div className="role__body">
<p className="role__year">{role.year}</p>
<div className="role__head">
<h3 className="role__co">{role.company}</h3>
<span className="role__loc">{role.location}</span>
</div>
<p className="role__title">{role.title}</p>
<p className="role__desc">{role.description}</p>
</div>
<div className="4u 12u$(small)">
<ul className="labeled-icons">
<li>
<h3 className="icon fa-home"><span className="label">Address</span></h3>
Region Bern / Zurich<br />
Switzerland
</li>
<li>
<h3 className="icon fa-mobile"><span className="label">Phone</span></h3>
(on request)
</li>
<li>
<h3 className="icon fa-envelope-o"><span className="label">Email</span></h3>
<a href="mailto:dennis@thiessen.io">dennis@thiessen.io</a>
</li>
</ul>
))}
</ol>
</section>
<section className="section">
<h2 className="section__label">Stack</h2>
<div className="stack">
{STACK.map((group) => (
<div className="group" key={group.label}>
<p className="group__label">{group.label}</p>
<p className="group__tags">
<Tags items={group.items} />
</p>
</div>
))}
</div>
</section>
<section className="section">
<h2 className="section__label">Contact</h2>
<div>
<p className="contact__intro">
Open to interesting problems in data, ML and platform engineering — in Switzerland or
remote. Email is the fastest way to reach me.
</p>
<a className="contact__email" href={`mailto:${EMAIL}`}>
{EMAIL}
</a>
<div className="contact__links">
<a href={LINKEDIN} target="_blank" rel="noopener noreferrer">
LinkedIn ↗
</a>
{/* Add a GitHub profile URL here when you have one you want public. */}
</div>
</div>
</section>
<footer className="colophon">
<span>© {new Date().getFullYear()} Dennis Thiessen</span>
<span>Bern, Switzerland</span>
</footer>
</div>
</Layout>
)
}
}
const inputParsers = {
uppercase(input) {
return input.toUpperCase();
},
number(input) {
return parseFloat(input);
},
};
class ShakingError extends React.Component {
constructor() { super(); this.state = { key: 0 }; }
componentWillReceiveProps() {
this.setState({ key: ++this.state.key });
}
render() {
return <div key={this.state.key} className="bounce">{this.props.text}</div>;
}
}
function stringifyFormData(fd) {
const data = {};
for (let key of fd.keys()) {
data[key] = fd.get(key);
}
return JSON.stringify(data, null, 2);
}
export default HomeIndex
export default IndexPage