Repository navigation
Expand file tree
/
Copy pathindex.html
More file actions
498 lines (456 loc) · 31.9 KB
/
Copy pathindex.html
File metadata and controls
498 lines (456 loc) · 31.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<link rel="icon" href="favicon.svg" type="image/svg+xml">
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#0C0E13">
<link rel="canonical" href="https://blust.ch/">
<title>Robert Blust — Software Engineer & Architect</title>
<meta id="metadesc" name="description" content="The constraint has moved from building fast to deciding well. Over twenty-five years of platforms, and the talks that argue the point.">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Robert Blust">
<meta property="og:url" content="https://blust.ch/">
<meta property="og:title" content="Robert Blust — Software Engineer & Architect">
<meta property="og:description" content="The constraint has moved from building fast to deciding well. Over twenty-five years of platforms, and the talks that argue the point.">
<meta property="og:image" content="https://blust.ch/og.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="Robert Blust — Software Engineer and Architect.">
<meta property="og:locale" content="en_US">
<meta property="og:locale:alternate" content="de_CH">
<meta name="twitter:card" content="summary_large_image">
<!-- Publisher and page, nothing about the product. The same rule every page here follows:
structured data is not a back door for claims the prose does not make. Each page carries
the whole graph it references — Google resolves @graph within one document, so a bare
{ "@id": ... } pointing at a node defined on another page resolves to nothing. That was
the bug this replaced, alongside a logo.svg this site has never served. -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Person",
"@id": "https://blust.ch/id/01a03d9b-e108-7712-830f-6e315eeab5c9",
"name": "Robert Blust",
"url": "https://blust.ch/",
"jobTitle": "Software Engineer & Architect",
"image": "https://blust.ch/images/profiles/robert-blust.jpg",
"sameAs": [
"https://github.com/robertblust",
"https://www.linkedin.com/in/robertblust/",
"https://substack.com/@robertblust"
],
"subjectOf": {
"@id": "https://blust.ch/#model"
}
},
{
"@type": "Dataset",
"@id": "https://blust.ch/#model",
"name": "Robert Blust — mental model",
"description": "One person described in CompanyGraph: the profile, its experiences, the skills it claims and the evidence under each.",
"url": "https://blust.ch/model/",
"license": "https://creativecommons.org/licenses/by/4.0/",
"creator": {
"@id": "https://blust.ch/id/01a03d9b-e108-7712-830f-6e315eeab5c9"
},
"about": {
"@id": "https://blust.ch/id/01a03d9b-e108-7712-830f-6e315eeab5c9"
},
"isBasedOn": "https://github.com/robertblust/mental-model",
"distribution": {
"@type": "DataDownload",
"contentUrl": "https://blust.ch/model.json",
"encodingFormat": "application/json"
}
},
{
"@type": "WebSite",
"@id": "https://blust.ch/#website",
"name": "Robert Blust",
"url": "https://blust.ch/",
"inLanguage": "en",
"publisher": {
"@id": "https://blust.ch/id/01a03d9b-e108-7712-830f-6e315eeab5c9"
}
},
{
"@type": "WebPage",
"@id": "https://blust.ch/#webpage",
"name": "Robert Blust — Software Engineer & Architect",
"url": "https://blust.ch/",
"inLanguage": "en",
"isPartOf": {
"@id": "https://blust.ch/#website"
},
"about": {
"@id": "https://blust.ch/id/01a03d9b-e108-7712-830f-6e315eeab5c9"
}
}
]
}
</script>
<script>
/* ─── theme boot · v6 · page ────────────────────────────────────
Set the theme before anything paints. Generated from @robertblust/design —
editing it here does nothing, because the next `npm run design` overwrites it.
This is the one script this package puts in <head>, and it has to be there: the package's
other scripts sit at the end of the body, and a page may paint before they have run.
Language arriving late costs a flash of English; a palette arriving late repaints the
whole page in the wrong one, on every navigation. A site may put a script of its own in
<head> as well, as each site does to start its fonts loading right after its @font-face
rules.
It duplicates the storage key and the URL pattern that `theme` also carries. That is
deliberate — nothing is in scope up here, and the block must stand alone. Both blocks are
generated from the same package, so the duplicate cannot drift.
It also sets `data-embed` on a framed page whose address asks for it, for the same reason
it sets the theme here: the chat embeds a model page in its dialog, and that page must never
paint its header and footer before the stage's rules hide them.
And it marks a page arriving with the chat open, for the reason it sets the theme here too:
the panel is built by chat.js, which runs after the page's other scripts, and a page may
paint before it has. `data-chat-waiting` holds the host the panel's title bar names, and
chat.css paints the panel's empty frame and title bar at its size from it, so the frame
and the bar are there on the first paint and only the conversation arrives after.
chat.js takes the mark off once the panel stands, or once it has found nothing to show.
Dark is the default and `prefers-color-scheme` is never read: dark is the design, and
light is something a visitor asks for. A visitor whose system is set to light still
arrives on dark until they say otherwise.
*/
(function(){
try {
var m = /[?&]theme=(light|dark)(&|$)/.exec(location.search);
var t = m ? m[1] : localStorage.getItem("theme");
if (t === "light") document.documentElement.setAttribute("data-theme", "light");
} catch (e) {}
// A page asked for `embed`, inside a frame, draws its stage alone, in the chat's dialog on
// another page; the flag is set here so the page never paints its own header first. Its own
// try, because the theme's storage read throws where site data is blocked and the flag must
// not go with it; and only in a frame, because a page opened on its own is an ordinary page.
try {
if (window.top !== window && /[?&]embed(&|$)/.test(location.search)) document.documentElement.setAttribute("data-embed", "");
} catch (e) {}
// A conversation the tab keeps open: the keys are chat.js's, `chat` in the tab's storage and
// `chat-size` in the browser's, read the way it reads them. Never in a frame, which takes no
// chat of its own.
try {
var r = document.documentElement, c = JSON.parse(sessionStorage.getItem("chat") || "null");
if (c && c.open && window.top === window) {
r.setAttribute("data-chat-waiting", location.host);
var z = JSON.parse(localStorage.getItem("chat-size") || "null");
if (z && z.w && z.h) { r.style.setProperty("--rbchat-kept-w", z.w + "px"); r.style.setProperty("--rbchat-kept-h", z.h + "px"); }
}
} catch (e) {}
})();
/* ─── end theme boot ─────────────────────────────────────────────────── */
</script>
<link rel="stylesheet" href="tokens.css">
<link rel="stylesheet" href="page.css">
<link rel="preload" as="font" type="font/woff2" href="fonts/Bricolage-var.woff2" crossorigin>
<link rel="preload" as="font" type="font/woff2" href="fonts/InstrumentSans-var.woff2" crossorigin>
<link rel="preload" as="font" type="font/woff2" href="fonts/PlexMono-400.woff2" crossorigin>
<link rel="preload" as="font" type="font/woff2" href="fonts/PlexMono-600.woff2" crossorigin>
<style>
/* Self-hosted, same origin: the "no external assets" rule kept the third party out
but left the type falling back to system-ui for every visitor. These files fix
that without letting anyone else see a visitor, and resolve from file:// too. */
@font-face{font-family:"Bricolage Grotesque";src:url(fonts/Bricolage-var.woff2) format("woff2-variations"),url(fonts/Bricolage-var.woff2) format("woff2");font-weight:200 800;font-display:fallback}
@font-face{font-family:"Instrument Sans";src:url(fonts/InstrumentSans-var.woff2) format("woff2-variations"),url(fonts/InstrumentSans-var.woff2) format("woff2");font-weight:400 700;font-display:fallback}
@font-face{font-family:"Plex Mono";src:url(fonts/PlexMono-400.woff2) format("woff2");font-weight:400;font-display:fallback}
@font-face{font-family:"Plex Mono";src:url(fonts/PlexMono-600.woff2) format("woff2");font-weight:600;font-display:fallback}
/* Structure and type are guestgraph.io's, deliberately — the two pages are siblings and
should stay maintainable side by side. What is absent here is absent on purpose: no
.split and no .figure, because this page has no diagram to balance its text against. */
header{padding:2rem 0}
.bar{display:flex; align-items:center; justify-content:space-between; gap:2rem; flex-wrap:wrap}
/* The wordmark is one thing. At 390px the bar squeezed it until the mark and the
name stacked on two lines, on the two pages that had no mobile rule at all. */
.brand{white-space:nowrap; flex:0 0 auto; display:flex; align-items:center; gap:.7rem; text-decoration:none}
.brand svg{width:28px; height:28px; color:var(--c-mid); flex:0 0 auto}
/* the mark is favicon.svg inlined, not linked: this site takes no external assets, and
the decks have to open from file://. Its colors come from the tokens rather than the
favicon's hard-coded hexes, so the two stay in step through a palette change. */
.brand .plate{fill:var(--raise); stroke:var(--rule); stroke-width:1.5}
.brand .rb{fill:var(--c-mid)}
.brand b{font-weight:600; font-size:1.06rem; letter-spacing:-.01em}
.brand b span{color:var(--c-mid)}
.switchers{display:flex; align-items:center; gap:.6rem}
/* The hover underline hangs below the text, so the link's box is taller under the text
than over it. Centering boxes then rides the text high — it sat 5px above the brand and
the language control. Mirror the space above so each box is symmetric about its own
text, and one horizontal line runs through the middle of every word in the header. */
/* The page is longer than a screen now: it no longer centers one pitch vertically. */
main{flex:1; padding:2vh 0 8vh}
/* Bricolage is variable, and the weight axis does the arguing: the solved half
set light, the unsolved half set heavy. The sentence is legible as a shape
before it is read as words. */
.title h1{font-family:"Bricolage Grotesque", ui-sans-serif, system-ui, sans-serif;
letter-spacing:-.035em; line-height:1.0;
font-size:clamp(2.3rem,6.4vw,5rem); max-width:20ch}
.title h1 .solved{display:block; font-weight:200; color:var(--dim)}
.title h1 .unsolved{display:block; font-weight:800}
.title h1 em{font-style:normal; color:var(--c-firm)}
.byline{margin-top:2.2rem; font-size:.82rem; font-weight:500; letter-spacing:.14em;
text-transform:uppercase; color:var(--dim)}
.byline b{color:var(--ink); font-weight:600}
/* where guestgraph.io has .split, this page has one column */
.pitch{margin-top:clamp(2.5rem,5vh,4.6rem)}
h2{font-weight:600; letter-spacing:-.025em; line-height:1.06;
font-size:clamp(1.9rem,3.7vw,3rem)}
h2 em{font-style:normal; color:var(--c-mid)}
.lede{margin-top:1.5rem; font-size:clamp(1rem,1.35vw,1.18rem); color:var(--dim);
max-width:52ch}
/* A project name, inline in prose: mark, then the wordmark with Graph in the accent.
Baseline-aligned so it sits in the line rather than on top of it. */
.pname{display:inline-flex; align-items:baseline; gap:.32em; text-decoration:none;
white-space:nowrap; border-bottom:1px solid transparent}
.pname:hover, .pname:focus-visible{border-color:var(--c-mid)}
.pname .mark{color:var(--c-mid); align-self:center; display:inline-flex}
.pname .mark svg{width:1.05em; height:1.05em; display:block}
.pname b{font-weight:600; color:var(--ink)}
.pname b span{color:var(--c-mid)}
.cta{margin-top:2.3rem; display:flex; flex-wrap:wrap; gap:1rem}
.btn{
font-size:.82rem; font-weight:500; letter-spacing:.07em;
text-decoration:none; padding:.9rem 1.25rem; border-radius:7px; border:1.5px solid var(--rule);
color:var(--ink); background:var(--raise);
transition:border-color .18s ease, color .18s ease, background-color .18s ease;
}
.btn:hover, .btn:focus-visible{border-color:var(--c-mid); color:var(--c-mid)}
.btn.primary{border-color:var(--c-mid); color:var(--c-mid)}
.btn.primary:hover, .btn.primary:focus-visible{background:var(--press)}
.sec .index{margin-top:2rem}
:focus-visible{outline:2px solid var(--c-mid); outline-offset:3px; border-radius:3px}
@media (max-width:900px){
.lede{max-width:100%}
main{padding:1vh 0 4vh}
}
@media (prefers-reduced-motion:reduce){
.btn, nav a{transition:none}
}
</style>
<script>/* Start the fonts before the first layout needs them, so the first frame has them. */try{document.fonts.forEach(function(f){f.load()})}catch(e){}</script>
<script type="module" blocking="render">/* Hold the first paint until the fonts have loaded, for at most 300ms: a page painted before them shows its text in another face and then moves it. */try{await Promise.race([Promise.all([...document.fonts].map(function(f){return f.load()})),new Promise(function(r){setTimeout(r,300)})])}catch(e){}</script>
<link rel="stylesheet" href="chat.css">
</head>
<body>
<div class="shell">
<header>
<div class="bar">
<a class="brand" href="./"><svg viewBox="0 0 32 32" aria-hidden="true"><rect class="plate" x=".75" y=".75" width="30.5" height="30.5" rx="7"/><text class="rb" x="16" y="22.5" text-anchor="middle" font-family="Plex Mono, ui-monospace, SFMono-Regular, Menlo, monospace" font-size="17" font-weight="600">rb</text></svg><b>Robert <span>Blust</span></b></a>
<nav>
<div class="navlinks" id="navlinks">
<a href="principles/" data-de="Prinzipien">Principles</a>
<a href="processes/" data-de="Prozesse">Processes</a>
<a href="surfaces/">Surfaces</a>
<a href="ideas/" data-de="Ideen">Ideas</a>
<a href="model/" data-de="Modell">Model</a>
<a href="timeline/" data-de="Werdegang">Timeline</a>
<a href="blog/" data-de="Blog">Blog</a>
<a href="talks/" data-de="Vorträge">Talks</a>
</div>
<button class="burger" id="burger" type="button" aria-expanded="false"
aria-controls="navlinks" aria-label="menu" data-de-aria="Menü">
<svg class="ibars" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
<svg class="ix" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>
</button>
<div class="switchers" id="langind"><div class="seg" role="group" aria-label="switch language" data-de-aria="Sprache wechseln"><button type="button" id="lde" aria-pressed="false">DE</button><button type="button" id="len" aria-pressed="true">EN</button></div><div class="seg theme" id="thmind" role="group" aria-label="switch appearance" data-de-aria="Erscheinungsbild wechseln"><button type="button" id="thLight" aria-pressed="false" aria-label="Light" data-de-aria="Hell"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg></button><button type="button" id="thDark" aria-pressed="true" aria-label="Dark" data-de-aria="Dunkel"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg></button></div></div>
</nav>
</div>
</header>
</div>
<main>
<div class="shell">
<div class="title">
<h1 data-de="<span class='solved'>Schnell bauen ist gelöst.</span><span class='unsolved'><em>Gut entscheiden</em> nicht.</span>"><span class="solved">Building fast is solved.</span><span class="unsolved"><em>Deciding well</em> is not.</span></h1>
<p class="byline" data-de="<b>Robert Blust</b> · Software Engineer & Architect"><b>Robert Blust</b> · Software Engineer & Architect</p>
</div>
<div class="pitch">
<p class="lede" data-de="Meine Arbeit kehrt immer wieder zu einer Idee zurück: Der Engpass hat sich vom schnellen Bauen zum guten Entscheiden verschoben. Damit zählt es mehr, eine Domäne präzise zu beschreiben, als sie schnell zu programmieren. Dahinter stehen über fünfundzwanzig Jahre Plattformarbeit – sechzehn davon bei der UBS, dann die Mitgründung von <a href='https://3ap.ch'>3AP</a> und <a href='https://likemagic.tech'>LIKE MAGIC</a>.">
My work keeps returning to one idea: the constraint has moved from building fast to
deciding well, which makes describing a domain precisely matter more than coding it
quickly. Over twenty-five years of platforms behind that — sixteen at UBS, then
co-founding <a href="https://3ap.ch">3AP</a> and
<a href="https://likemagic.tech">LIKE MAGIC</a>.
</p>
</div>
<section class="sec" id="vision">
<p class="kicker" data-de="Die Vision">The vision</p>
<!-- vision:start -->
<h2 data-de="Ein Modell, <em>das überall stimmt</em>.">One model, <em>true everywhere</em>.</h2>
<p class="lede" data-de="Wer nach meiner Arbeit fragt – ein Mensch, eine Suchmaschine, ein KI-Agent –, gelangt zur selben Antwort. Denn jede Surface, die er erreichen kann, geht aus einem einzigen Modell hervor, das ich pflege, und kein Anspruch darin steht ohne seinen Beleg da.">Whoever asks about my work — a person, a search engine, an agent — reaches the same answer, because every surface they can reach derives from one model I keep, and no claim in it stands without its evidence.</p>
<!-- vision:end -->
<div class="tiles">
<button class="tile" type="button" data-chat-open><b data-de="Das Modell fragen">Ask</b><span data-de="Ein KI-Agent, der nur aus dem Modell antwortet und unter jeder Antwort die Belege nennt.">An agent that answers only from the model, and names the evidence under each answer.</span><i data-de="Chat öffnen →">Open the chat →</i></button>
<a class="tile" href="model/"><b data-de="Das Modell ansehen">See the model</b><span data-de="Der ganze Graph: Erfahrungen, Fähigkeiten und die Belege, auf denen jeder Anspruch beruht.">The whole graph: experiences, skills and the evidence each claim rests on.</span><i data-de="Modell öffnen →">Open the model →</i></a>
<a class="tile" href="timeline/"><b data-de="Den Werdegang lesen">Read the timeline</b><span data-de="Über fünfundzwanzig Jahre, die Einträge nach Beginn geordnet – und sichtbar, wo sie sich überschnitten.">Over twenty-five years, in the order they began, and what ran during what.</span><i data-de="Werdegang öffnen →">Open the timeline →</i></a>
</div>
</section>
<section class="sec" id="values">
<p class="kicker" data-de="Woran ich mich halte">What I hold to</p>
<!-- values:start -->
<h2 data-de="Fünf Werte – und zu jedem, <em>was ich nie tue</em>.">Five values, each with the thing <em>I never do</em>.</h2>
<div class="values">
<a href="principles/#build-the-alternative-before-making-the-point"><b data-de="Erst die Alternative bauen, dann kritisieren">Build the alternative before making the point</b><span data-de="Ich lasse nie eine Beschwerde als Position stehen.">I never let a complaint stand as a position.</span></a>
<a href="principles/#decide-well-over-build-fast"><b data-de="Lieber gut entscheiden als schnell bauen">Decide well over build fast</b><span data-de="Ich liefere nie innert eines Tages ein Feature aus und streite dann ein Quartal lang darüber, weil niemand sagen kann, wofür es gedacht war.">I never ship a feature in a day and argue about it for a quarter because nobody can say what it was for.</span></a>
<a href="principles/#grow-the-people-with-the-platform"><b data-de="Die Menschen mit der Plattform wachsen lassen">Grow the people with the platform</b><span data-de="Ich lasse nie fünf Leute alles wissen, während zwanzig auf sie warten.">I never let five people know everything while twenty wait for them.</span></a>
<a href="principles/#model-it-before-you-build-it"><b data-de="Erst modellieren, dann bauen">Model it before you build it</b><span data-de="Ich lasse das Modell nie in drei Köpfen und einer Präsentation stecken, während jedes System eine leicht andere Fassung davon abbildet.">I never let the model live in three heads and a slide deck, with every system encoding a slightly different version of it.</span></a>
<a href="principles/#production-is-the-finish-line"><b data-de="Die Produktion ist das Ziel">Production is the finish line</b><span data-de="Ich halte nie einen Starttermin mit einem System ein, für das niemand Pikett hat.">I never meet a launch date with a system nobody is on call for.</span></a>
</div>
<!-- values:end -->
</section>
<section class="sec" id="latest">
<p class="kicker" data-de="Neueste Texte">Latest writing</p>
<div class="index">
<div class="row">
<!-- latest:start -->
<a class="entry" href="blog/one-question-answered/">
<span class="t" data-de="Eine Frage beantwortet. Eine noch offen.">One question answered. One still open.</span>
<span class="meta mono"><b data-de="5 min">5 min</b></span>
<span class="d" data-de="Auf der Ideenseite habe ich zu meinen zwei Ideen zwei Fragen gestellt. Fünfzehn Wochen später hat die erste eine Antwort mit einer Zahl. Die zweite hat einen Test – bestanden ist er noch nicht.">On the ideas page I asked two questions of my two ideas. Fifteen weeks later, the first has an answer with a number. The second has a test, and the test has not been passed yet.</span>
</a>
<!-- latest:end -->
<p class="dl"><a href="blog/" data-de="Alle Beiträge">All posts</a><span class="sep">·</span><a href="talks/" data-de="Alle Vorträge">All talks</a></p>
</div>
</div>
</section>
<section class="sec" id="now">
<p class="kicker" data-de="Was ich zurzeit baue">What I am building now</p>
<!-- The second half of the page, and the newer one: what the twenty-five years are
being spent on now. The career break is stated plainly rather than left as a gap
in a CV — it is the reason both ideas are built in the open instead of after
hours, which is the whole premise of the ideas page. -->
<!-- The two names are links, not words: each carries its own mark and jumps to its
section on the ideas page. They sit OUTSIDE the translated spans on purpose —
a data-de attribute holds its value as text, so an inline svg inside one would
have to be escaped, and the wordmark would be one bad quote away from vanishing.
The names do not translate, so nothing is lost by leaving them out of it. -->
<p class="lede">
<span data-de="Aus alldem sind zwei Ideen entstanden, und ich entwickle beide öffentlich –">Two ideas have come out of that work, and I am building both in the open —</span>
<a class="pname" href="ideas/#guestgraph"><span class="mark" aria-hidden="true"><svg viewBox="0 0 32 32" aria-hidden="true"> <g fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"> <path d="M8.6 7.2 L20.5 14.2"/><path d="M8.6 16 L19.4 16"/><path d="M8.6 24.8 L20.5 17.8"/> <circle cx="6" cy="7.2" r="2.6" fill="var(--ground)"/> <circle cx="6" cy="16" r="2.6" fill="var(--ground)"/> <circle cx="6" cy="24.8" r="2.6" fill="var(--ground)"/> </g> <circle cx="24" cy="16" r="5.4" fill="currentColor"/> </svg></span><b>Guest<span>Graph</span></b></a>
<span data-de="und">and</span>
<a class="pname" href="ideas/#companygraph"><span class="mark" aria-hidden="true"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="square"> <rect x="2" y="6.75" width="10.5" height="10.5" rx="1.5"/> <rect x="4.75" y="9.5" width="5" height="5" fill="currentColor" stroke="none"/> <path d="M12.5 12 h4.5"/> <rect x="17" y="9.5" width="5" height="5" fill="currentColor" stroke="none"/> </svg></span><b>Company<span>Graph</span></b></a><span data-de=". Derzeit nehme ich mir bewusst eine Auszeit. Sie ist kreativ und inspirierend – und der Grund, weshalb die beiden sorgfältig gebaut und öffentlich geprüft werden statt nebenbei am Abend.">. Right now I am on a deliberate career break: creative, inspiring and the reason the two get built properly and validated in public rather than in the evenings.</span>
</p>
<div class="cta">
<a class="btn primary" href="ideas/" data-de="Zu den Ideen">See the ideas</a>
</div>
</section>
</div>
</main>
<div class="shell">
<footer>
<span><a href="https://github.com/robertblust">GitHub</a></span>
<span><a href="https://github.com/robertblust/robertblust.github.io/blob/HEAD/LICENSE" data-de="Lizenz">License</a></span>
<span><a href="privacy/" data-de="Datenschutz">Privacy</a></span>
<span><a href="model.json">model.json</a></span>
</footer>
</div>
<script>
// English is the markup and German lives in data-de: this page is delivered, shared and
// indexed in English, so English is what it says before a line of JS has run. The choice
// is remembered per origin under lang, shared with the talks index and both decks —
// guestgraph.io is a different origin and keeps its own. Storage is guarded because
// file:// is an opaque origin in some browsers and throws.
(function(){
// page.js reads and writes lang/theme for its own purposes (link decoration, nav-fit,
// aria-i18n) in its own scope, which this page's script cannot see. This page still needs
// to compute its own initial lang and theme, from the same URL param and storage key, to
// hand to page.js through window.rbPage and to drive its own controls — so the read/write
// pair for each key is kept here too, exactly as page.js keeps its own.
var LANG_KEY = "lang";
function langStored(){ try { return localStorage.getItem(LANG_KEY); } catch (e) { return null; } }
function langRemember(v){ try { localStorage.setItem(LANG_KEY, v); } catch (e) {} }
function langFromUrl(){
var m = /[?&]lang=(de|en)(&|$)/.exec(location.search);
if (!m) return null;
try {
var q = location.search.replace(/([?&])lang=(de|en)(&|$)/, "$1").replace(/[?&]$/, "");
history.replaceState(null, "", location.pathname + q + location.hash);
} catch (e) {}
return m[1];
}
var THEME_KEY = "theme";
function themeStored(){ try { return localStorage.getItem(THEME_KEY); } catch (e) { return null; } }
function themeRemember(v){ try { localStorage.setItem(THEME_KEY, v); } catch (e) {} }
function themeFromUrl(){
var m = /[?&]theme=(light|dark)(&|$)/.exec(location.search);
if (!m) return null;
try {
var q = location.search.replace(/([?&])theme=(light|dark)(&|$)/, "$1").replace(/[?&]$/, "");
history.replaceState(null, "", location.pathname + q + location.hash);
} catch (e) {}
return m[1];
}
var urlLang = langFromUrl();
if (urlLang) langRemember(urlLang);
var lang = urlLang || (langStored() === "de" ? "de" : "en");
var urlTheme = themeFromUrl();
if (urlTheme) themeRemember(urlTheme);
var theme = urlTheme || (themeStored() === "light" ? "light" : "dark");
function applyTheme(){
if (theme === "light") document.documentElement.setAttribute("data-theme", "light");
else document.documentElement.removeAttribute("data-theme");
var l = document.getElementById("thLight"), d = document.getElementById("thDark");
if (l) l.setAttribute("aria-pressed", theme === "light" ? "true" : "false");
if (d) d.setAttribute("aria-pressed", theme === "dark" ? "true" : "false");
}
function setTheme(v){ theme = v; themeRemember(v); applyTheme(); }
var i18n = Array.prototype.slice.call(document.querySelectorAll("[data-de]"));
i18n.forEach(function(el){ el.setAttribute("data-en", el.innerHTML); });
var UI = {
de:{ title:"Robert Blust – Software Engineer & Architect",
desc:"Der Engpass hat sich vom schnellen Bauen zum guten Entscheiden verschoben. Über fünfundzwanzig Jahre Plattformarbeit – und die Vorträge, die das begründen." },
en:{ title:"Robert Blust — Software Engineer & Architect",
desc:"The constraint has moved from building fast to deciding well. Over twenty-five years of platforms, and the talks that argue the point." }
};
function applyLang(){
i18n.forEach(function(el){ el.innerHTML = el.getAttribute("data-" + lang); });
document.title = UI[lang].title;
document.getElementById("metadesc").setAttribute("content", UI[lang].desc);
document.getElementById("lde").setAttribute("aria-pressed", String(lang === "de"));
document.getElementById("len").setAttribute("aria-pressed", String(lang === "en"));
document.documentElement.lang = lang;
}
function toggle(){ lang = (lang === "de") ? "en" : "de"; langRemember(lang); applyLang(); }
var burger = document.getElementById("burger"),
navlinks = document.getElementById("navlinks"),
navbar = document.querySelector("nav");
function setMenu(open){
burger.setAttribute("aria-expanded", String(open));
navlinks.setAttribute("data-open", String(open));
}
burger.addEventListener("click", function(){
setMenu(burger.getAttribute("aria-expanded") !== "true");
});
// A tap anywhere else closes it, as does Escape and following a link. The button's own
// click is inside nav, so it toggles rather than being closed out from under itself.
document.addEventListener("click", function(e){ if (!navbar.contains(e.target)) setMenu(false); });
document.addEventListener("keydown", function(e){ if (e.key === "Escape") setMenu(false); });
navlinks.addEventListener("click", function(e){ if (e.target.closest("a")) setMenu(false); });
// Turning the phone can take the row back over the breakpoint, where the panel's own
// styles no longer apply; leaving it flagged open would strand aria-expanded as true.
window.addEventListener("resize", function(){ if (window.innerWidth > 640) setMenu(false); });
document.getElementById("lde").addEventListener("click", function(){ if (lang !== "de") toggle(); });
document.getElementById("len").addEventListener("click", function(){ if (lang !== "en") toggle(); });
document.getElementById("thLight").addEventListener("click", function(){ setTheme("light"); });
document.getElementById("thDark").addEventListener("click", function(){ setTheme("dark"); });
// L switches the language, but never while the visitor is typing: a letter typed into a field,
// the chat's among them, is text, and the language changes only when the visitor asks for it.
document.addEventListener("keydown", function(e){
if (e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey) return;
var t = e.target;
if (t && t.closest && t.closest("input, textarea, select, [contenteditable=''], [contenteditable='true']")) return;
if (e.key === "l" || e.key === "L") toggle();
});
applyTheme();
// page.js makes the initial applyLang() call itself, reading lang and applyLang off this
// object — set here, inside this closure, because that is the only place both are in scope.
window.rbPage = { lang: lang, applyLang: applyLang };
})();
</script>
<script src="page.js" defer></script>
<!-- The chat: one tag, the design package's widget. It loads nothing and sends nothing until a visitor opens it; the privacy page says what leaves the browser then. -->
<script src="chat.js" data-chat="https://chat.blust.ch/chat" data-model="/model/" data-questions="/model.json" data-questions-de="/questions.de.json"></script>
</body>
</html>