Repository navigation
Expand file tree
/
Copy pathindex.html
More file actions
599 lines (558 loc) · 37.3 KB
/
Copy pathindex.html
File metadata and controls
599 lines (558 loc) · 37.3 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
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>GuestGraph — the open-source guest identity graph</title>
<meta id="metadesc" name="description" content="One explainable guest profile, from data scattered across every system in the hotel. Open source, Apache 2.0.">
<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://guestgraph.io/">
<meta property="og:title" content="GuestGraph — the open-source guest identity graph">
<meta property="og:description" content="One explainable guest profile, from data scattered across every system in the hotel.">
<meta property="og:type" content="website">
<meta property="og:site_name" content="GuestGraph">
<meta property="og:url" content="https://guestgraph.io/">
<meta property="og:image" content="https://guestgraph.io/og.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="GuestGraph. Five strangers. One guest. — one explainable guest profile, from data scattered across every system in the hotel.">
<meta property="og:locale" content="en_US">
<meta property="og:locale:alternate" content="de_CH">
<meta name="twitter:card" content="summary_large_image">
<!-- The publisher, the site and this page — nothing about what the software does or when
it does it, which is the same rule the prose follows. Each page carries the whole graph
it references: Google resolves @graph within one document, so a bare { "@id": ... }
naming a node defined on another page resolved to nothing, which is what this replaced. -->
<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/"
},
{
"@type": "Organization",
"@id": "https://guestgraph.io/id/01a0d2e2-f788-788d-ac26-56e9ef94fdd2",
"name": "GuestGraph",
"url": "https://guestgraph.io/",
"logo": "https://guestgraph.io/logo.svg",
"sameAs": [
"https://github.com/guestgraph"
]
},
{
"@type": "WebSite",
"@id": "https://guestgraph.io/#website",
"name": "GuestGraph",
"url": "https://guestgraph.io/",
"inLanguage": "en",
"publisher": {
"@id": "https://guestgraph.io/id/01a0d2e2-f788-788d-ac26-56e9ef94fdd2"
}
},
{
"@type": "SoftwareSourceCode",
"@id": "https://guestgraph.io/#software",
"name": "GuestGraph",
"description": "An open-source guest identity graph for hospitality: one explainable guest profile, from data scattered across every system in the hotel.",
"codeRepository": "https://github.com/guestgraph/engine",
"license": "https://www.apache.org/licenses/LICENSE-2.0",
"author": {
"@id": "https://blust.ch/id/01a03d9b-e108-7712-830f-6e315eeab5c9"
},
"publisher": {
"@id": "https://guestgraph.io/id/01a0d2e2-f788-788d-ac26-56e9ef94fdd2"
}
},
{
"@type": "WebPage",
"@id": "https://guestgraph.io/#webpage",
"name": "GuestGraph — the open-source guest identity graph",
"url": "https://guestgraph.io/",
"inLanguage": "en",
"isPartOf": {
"@id": "https://guestgraph.io/#website"
},
"publisher": {
"@id": "https://guestgraph.io/id/01a0d2e2-f788-788d-ac26-56e9ef94fdd2"
},
"about": {
"@id": "https://guestgraph.io/#software"
}
},
{
"@type": "Dataset",
"@id": "https://guestgraph.io/model/#dataset",
"name": "GuestGraph — mental model",
"description": "GuestGraph described in CompanyGraph: its vision, values, objectives and strategies, the products it ships and the concepts they use, how its work is done, and where the model is published.",
"url": "https://guestgraph.io/model/",
"license": "https://creativecommons.org/licenses/by/4.0/",
"isBasedOn": "https://github.com/guestgraph/mental-model",
"creator": {
"@id": "https://guestgraph.io/id/01a0d2e2-f788-788d-ac26-56e9ef94fdd2"
},
"distribution": [
{
"@type": "DataDownload",
"encodingFormat": "application/json",
"contentUrl": "https://guestgraph.io/model.json"
}
]
}
]
}
</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="stylesheet" href="chat.css">
<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>
<link rel="preload" as="font" type="font/woff2" href="fonts/Bricolage-var.woff2" crossorigin>
<style>
/* Self-hosted, same origin: no third party sees a visitor, and the files resolve
from file:// as well as from the domain. */
@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}
@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}
header{padding:2rem 0}
/* three nav items plus the language control no longer fit beside the lockup on a
phone: let the row wrap rather than clip the control off the right edge. */
.bar{display:flex; align-items:center; justify-content:space-between; gap:1rem 2rem; flex-wrap:wrap}
/* The wordmark is one thing and never breaks: two pages had no wrap on the bar,
and it squeezed the mark and the name onto separate lines at phone widths. */
.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}
.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 box is taller under the text than over
it. Mirror the space above, and the box is symmetric about its own text. */
/* the language control is a nav item, not a floating badge: it belongs with the
other things you can do to the page rather than on top of the page */
main{flex:1; display:flex; align-items:center; padding:2vh 0 5vh}
/* the title, as the talk opens */
/* One axis carries the argument: the strangers arrive light and unresolved, the guest
lands heavy. This was once a typeface whose grades encode decay, set degraded and
then clean — a good idea on paper that kept being read as a page that had failed to
load, which on a site whose one real bug was exactly that is the worst thing type
can say. Weight makes the same point and never has to be explained. */
.title h1{font-family:"Bricolage Grotesque", ui-sans-serif, system-ui, sans-serif;
letter-spacing:-.035em; line-height:1.0;
font-size:clamp(2.4rem,7vw,5.4rem)}
.title h1 .r70{display:block; font-weight:300; color:var(--dim)}
.title h1 .rcl{display:block; font-weight:800}
.title h1 em{font-style:normal; color:var(--c-firm)}
.tagline{margin-top:1.6rem; font-size:clamp(1.1rem,1.9vw,1.5rem); color:var(--dim);
max-width:44ch; line-height:1.4}
/* No margin-top of its own: the figure now opens a .sec, whose own margin-top spaces it
from the section before, same as every other section here. A margin here too doubled
that gap. */
.figure{margin-top:0}
/* Five across is inherently wide, and a phone is not. The band used to keep its landscape
canvas below the breakpoint and scroll inside its own box; that put two of the five
records on screen and cut the profile they resolve into in half, so the one thing the
figure exists to say was the thing you had to swipe to find. It has two compositions
instead. Same records, same classes, same staged animation — only the coordinates
differ, so every rule below is written once and serves both.
The switch is at 900 because that is where the landscape canvas stops fitting: 884px is
the last viewport whose figure box still measures the 760 the band needs, which is the
exact width the old `min-width` was propping up. Nothing here scrolls sideways at any
width. */
.figure .tall{display:none}
/* records resolving into one profile */
.figure svg{width:100%; height:auto; display:block; overflow:visible}
.chip rect{fill:var(--raise); stroke:var(--rule); stroke-width:1.5}
/* A record value is data, so it takes the data face. That these arrive mangled is
carried by the strings themselves — MUSTER/ERIKA MRS, “E. M.”, xy7k2@… — which is the
argument the diagram is actually making. */
.chip text{font-family:"Plex Mono",monospace; font-size:15px; fill:var(--dim);
text-anchor:middle; dominant-baseline:middle}
.chip .src{font-family:"Plex Mono",monospace; font-size:11px; letter-spacing:.16em;
fill:var(--dim); opacity:.75}
.wire{stroke:var(--c-mid); stroke-width:1.5; fill:none; opacity:.85}
/* a record below the threshold keeps a faint link: considered, not accepted */
.wire.candidate{stroke:var(--c-weak); opacity:1}
.profile rect{fill:var(--raise); stroke:var(--c-firm); stroke-width:2.2}
.pname{font-family:"Bricolage Grotesque",sans-serif; font-weight:700; font-size:26px; fill:var(--ink);
text-anchor:middle; dominant-baseline:middle}
.pmeta{font-family:"Plex Mono",monospace; font-size:12.5px; fill:var(--dim);
text-anchor:middle; dominant-baseline:middle; letter-spacing:.06em}
/* One thing happens at a time, left to right: the five records arrive, then each is
connected in turn, then the profile they resolve into. Everything firing at once
read as noise rather than as an argument being assembled. */
.chip{opacity:0; animation:pop .34s ease forwards}
.chips .chip:nth-of-type(1){animation-delay:.08s}
.chips .chip:nth-of-type(2){animation-delay:.18s}
.chips .chip:nth-of-type(3){animation-delay:.28s}
.chips .chip:nth-of-type(4){animation-delay:.38s}
.chips .chip:nth-of-type(5){animation-delay:.48s}
@keyframes pop{from{opacity:0; transform:translateY(9px)} to{opacity:1; transform:none}}
.wire{stroke-dasharray:1; stroke-dashoffset:1; animation:draw .30s ease forwards}
/* The diagram's group is `rwires`, not `wires`: page.css carries the family's `.wires` rules
for the surfaces lineage this site never draws, which would grey these wires and hide the
whole figure under 640px. A shared name met here is a shared rule met here. */
.rwires .wire:nth-of-type(1){animation-delay:.95s}
.rwires .wire:nth-of-type(2){animation-delay:1.21s}
.rwires .wire:nth-of-type(3){animation-delay:1.47s}
.rwires .wire:nth-of-type(4){animation-delay:1.73s}
.rwires .wire:nth-of-type(5){animation-delay:1.99s}
@keyframes draw{to{stroke-dashoffset:0}}
/* last, and only once every record has been joined */
.profile{opacity:0; animation:pop .5s cubic-bezier(.2,.7,.2,1) forwards 2.35s}
:focus-visible{outline:2px solid var(--c-mid); outline-offset:3px; border-radius:3px}
@media (max-width:900px){
main{padding:2vh 0 6vh}
.tagline{max-width:100%}
.figure .wide{display:none}
.figure .tall{display:block}
/* The portrait canvas is 344 wide, so on a 390px phone it renders at 0.975 and a 15px
record value lands at 14.6px on glass. The old arrangement rendered an 18px value at
11.4. The cap keeps that true upward: past 380 the chips only inflate, and a diagram
whose type grows with the window is the same bug pointing the other way. */
.figure .tall{max-width:380px}
.chip rect{stroke-width:2}
/* A column is read down its left edge, so the values hang on one rather than centring
inside boxes of five different fullnesses. The ragged right that leaves is the point:
it is the five formats disagreeing, which is the argument the figure is making. */
.tall .chip text{text-anchor:start}
}
@media (prefers-reduced-motion:reduce){
.chip,.profile{opacity:1; animation:none}
.wire{stroke-dashoffset:0; animation: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>
</head>
<body>
<div class="shell">
<header>
<div class="bar">
<a class="brand" href="./">
<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>
<b>Guest<span>Graph</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="api/">API</a>
<a href="model/" data-de="Modell">Model</a>
<a href="talks/" data-de="Vorträge">Talks</a>
<a href="billing/" data-de="Abrechnung">Billing</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='r70'>Fünf Fremde.</span><span class='rcl'>Ein <em>Gast</em>.</span>"><span class="r70">Five strangers.</span><span class="rcl">One <em>guest</em>.</span></h1>
<p class="tagline" data-de="Ein erklärbares Gastprofil aus Daten, die über alle Systeme im Hotel verstreut sind.">One explainable guest profile, from data scattered across every system in the hotel.</p>
</div>
<section class="sec" id="vision">
<p class="kicker" data-de="Die Vision">The vision</p>
<!-- vision:start -->
<h2 data-de="Ein Gast, <em>nicht fünf Fremde</em>.">One guest, <em>not five strangers</em>.</h2>
<p class="lede" data-de="Wir arbeiten darauf hin, dass Hotels einen wiederkehrenden Gast als eine Person kennen, gleich in welchem ihrer Systeme er erfasst wurde – mit einem Profil, das auf Nachfrage sagt, warum es annimmt, was es annimmt, und das sich korrigieren lässt, wenn es falsch liegt.">We are working toward hotels that know a returning guest as one person, whichever of their systems the guest passed through, from a profile they can ask why it believes what it does, and correct when it is wrong.</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 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="Was GuestGraph ist, wohin es will und wie es funktioniert – gezeichnet als der Graph, den seine Dateien bilden.">What GuestGraph is, where it is going and how it works, drawn as the graph its files form.</span><i data-de="Modell öffnen →">Open the model →</i></a>
<a class="tile" href="api/"><b data-de="Die API kennenlernen">Read the API</b><span data-de="Alles, was die Engine tut, und der Connector, der den Graphen füllt.">Everything the engine does, and the connector that fills it.</span><i data-de="API öffnen →">Open the API →</i></a>
</div>
</section>
<section class="sec" id="values">
<p class="kicker" data-de="Woran wir uns halten">What we hold to</p>
<!-- values:start -->
<h2 data-de="Sieben Werte – jeder mit dem, <em>was wir nie tun</em>.">Seven values, each with the thing <em>we never do</em>.</h2>
<div class="values">
<a href="principles/#a-match-is-not-trusted-until-it-is-justified"><b data-de="Einem Treffer wird erst vertraut, wenn er begründet ist">A match is not trusted until it is justified</b><span data-de="Wir schalten nie eine Zusammenführung ein, ohne dass ein Mensch ihr ausdrücklich vertraut.">We never switch on a merge that no person asked to trust.</span></a>
<a href="principles/#every-merge-can-be-explained-and-undone"><b data-de="Jede Zusammenführung lässt sich erklären und zurücknehmen">Every merge can be explained and undone</b><span data-de="Wir liefern nie ein Verfahren zur Zusammenführung von Gästen aus, dessen Entscheide sich nicht nachlesen und zurücknehmen lassen.">We never ship a way of merging guests whose decisions cannot be read back and undone.</span></a>
<a href="principles/#everything-the-engine-does-is-on-its-api"><b data-de="Alles, was die Engine tut, läuft über ihre API">Everything the engine does is on its API</b><span data-de="Wir bauen nie eine Fähigkeit der Engine, die sich nur über einen Job, einen internen Aufruf oder die Datenbank erreichen lässt.">We never build an engine capability that can only be reached through a job, an internal call or the database.</span></a>
<a href="principles/#never-drop-what-can-be-parsed"><b data-de="Nie verwerfen, was sich parsen lässt">Never drop what can be parsed</b><span data-de="Wir verwerfen nie einen Datensatz, der sich parsen lässt, weil er anders aussieht, als wir erwartet haben.">We never discard a parseable record because it does not look the way we expected.</span></a>
<a href="principles/#store-what-happened-derive-who-it-was"><b data-de="Speichern, was geschah – ableiten, wer es war">Store what happened, derive who it was</b><span data-de="Wir überschreiben nie einen Quelldatensatz, um zu korrigieren, was im Profil eines Gastes steht.">We never overwrite a source record to fix what a guest's profile says.</span></a>
<a href="principles/#tenants-never-meet"><b data-de="Mandanten begegnen sich nie">Tenants never meet</b><span data-de="Wir bauen nie einen Weg ein – weder für einen Betreiber noch für uns selbst –, über den sich die Gäste eines Mandanten aus einem anderen lesen lassen.">We never add a path, for an operator or for ourselves, that reads one tenant's guests from another's.</span></a>
<a href="principles/#the-core-stays-open"><b data-de="Der Kern bleibt offen">The core stays open</b><span data-de="Wir halten nie eine Fähigkeit des Kerns zurück oder verschlechtern sie, um den gehosteten Dienst zu verkaufen.">We never hold back or degrade a capability of the core to sell the hosted service.</span></a>
</div>
<!-- values:end -->
</section>
<section class="sec" id="idea">
<p class="kicker" data-de="Die Idee">The idea</p>
<h2 data-de="Fünf Datensätze, <em>ein Profil</em>.">Five records, <em>one profile</em>.</h2>
<!-- The band runs the full width because five records across is what the argument
looks like; boxed into a column beside the headline the two competed and
neither won. The lede sits under it as the caption it always was. -->
<div class="figure">
<svg class="wide" viewBox="0 0 1200 286" role="img"
aria-label="Five differently-formatted records from five hotel systems, all resolving into one derived profile for Erika Muster">
<g class="rwires">
<path class="wire" d="M110,74 C110,132 404,148 588,206" pathLength="1"/>
<path class="wire" d="M355,74 C355,132 528,164 596,206" pathLength="1"/>
<path class="wire" d="M600,74 C600,132 600,168 600,206" pathLength="1"/>
<path class="wire" d="M845,74 C845,132 672,164 604,206" pathLength="1"/>
<path class="wire" d="M1090,74 C1090,132 796,148 612,206" pathLength="1"/>
</g>
<g class="chips">
<g class="chip"><text class="src" x="110" y="12">PMS</text>
<rect x="0" y="28" width="220" height="46" rx="9"/>
<text x="110" y="52">MUSTER/ERIKA MRS</text></g>
<g class="chip"><text class="src" x="355" y="12">BOOKING.COM</text>
<rect x="245" y="28" width="220" height="46" rx="9"/>
<text x="355" y="52">e.muster@example.com</text></g>
<g class="chip"><text class="src" x="600" y="12">POS</text>
<rect x="490" y="28" width="220" height="46" rx="9"/>
<text x="600" y="52">Table 12</text></g>
<g class="chip"><text class="src" x="845" y="12">WLAN</text>
<rect x="735" y="28" width="220" height="46" rx="9"/>
<text x="845" y="52">+41 79 ••• 12</text></g>
<g class="chip"><text class="src" x="1090" y="12">REVIEWS</text>
<rect x="980" y="28" width="220" height="46" rx="9"/>
<text x="1090" y="52">“E. M.”</text></g>
</g>
<g class="profile">
<rect x="470" y="206" width="260" height="76" rx="15"/>
<text class="pname" x="600" y="234">Erika Muster</text>
<text class="pmeta" x="600" y="261" data-de="ein abgeleitetes Profil">one derived profile</text>
</g>
</svg>
<!-- The portrait composition. The same five records read down the page; their wires
turn into a five-lane gutter, descend in formation and enter the one profile
through its top edge. Landscape fans outward to a centre — this merges, which on
a phone is the clearer way to say the same thing, and the lanes make "five of
them" countable even where the boxes have scrolled off the top of the screen.
Class names are deliberately identical to the wide composition's, so every fill,
stroke, type rule and the whole staged animation above applies to both and only
the coordinates live here. Both carry the same aria-label: `display:none` takes
the inactive one out of the accessibility tree, so exactly one is ever read. -->
<svg class="tall" viewBox="0 0 344 600" role="img"
aria-label="Five differently-formatted records from five hotel systems, all resolving into one derived profile for Erika Muster">
<g class="rwires">
<path class="wire" pathLength="1" d="M236,44 C288,44 332,54 332,88 L332,452 C332,484 200,492 200,512"/>
<path class="wire" pathLength="1" d="M236,132 C280,132 316,142 316,176 L316,452 C316,484 184,492 184,512"/>
<path class="wire" pathLength="1" d="M236,220 C271,220 300,230 300,264 L300,452 C300,484 168,492 168,512"/>
<path class="wire" pathLength="1" d="M236,308 C262,308 284,318 284,352 L284,452 C284,484 152,492 152,512"/>
<path class="wire" pathLength="1" d="M236,396 C253,396 268,406 268,440 L268,452 C268,484 136,492 136,512"/>
</g>
<g class="chips">
<g class="chip"><text class="src" x="0" y="12">PMS</text>
<rect x="0" y="22" width="236" height="44" rx="9"/>
<text x="14" y="44">MUSTER/ERIKA MRS</text></g>
<g class="chip"><text class="src" x="0" y="100">BOOKING.COM</text>
<rect x="0" y="110" width="236" height="44" rx="9"/>
<text x="14" y="132">e.muster@example.com</text></g>
<g class="chip"><text class="src" x="0" y="188">POS</text>
<rect x="0" y="198" width="236" height="44" rx="9"/>
<text x="14" y="220">Table 12</text></g>
<g class="chip"><text class="src" x="0" y="276">WLAN</text>
<rect x="0" y="286" width="236" height="44" rx="9"/>
<text x="14" y="308">+41 79 ••• 12</text></g>
<g class="chip"><text class="src" x="0" y="364">REVIEWS</text>
<rect x="0" y="374" width="236" height="44" rx="9"/>
<text x="14" y="396">“E. M.”</text></g>
</g>
<g class="profile">
<rect x="52" y="512" width="240" height="76" rx="15"/>
<text class="pname" x="172" y="540">Erika Muster</text>
<text class="pmeta" x="172" y="567" data-de="ein abgeleitetes Profil">one derived profile</text>
</g>
</svg>
<p class="lede" data-de="Dieselbe Person bucht, checkt ein, isst, meldet sich im WLAN an und schreibt eine Bewertung – und jedes System hält sie für eine andere. GuestGraph führt diese Datensätze zu einem Profil zusammen und kann jederzeit sagen, warum.">
The same person books, checks in, eats, joins the wifi and leaves a review — and every
system stores someone different. GuestGraph resolves those records into one profile,
and can always tell you why.
</p>
</div>
</section>
</main>
<div class="shell">
<footer>
<span><a class="credit" href="https://blust.ch/"><span class="rbmark"><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></span><b>Robert <em>Blust</em></b></a></span>
<span><a href="https://github.com/guestgraph">GitHub</a></span>
<span><a href="https://github.com/guestgraph/engine/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 — the other way round from the decks,
// and deliberately. This page is delivered, shared and indexed in English, so English is
// what it should say before a single line of JS has run: no swap on load, no flash, and a
// crawler without JS reads the same language the og tags promise. The choice is remembered
// per origin,
// so it carries from here to the talks index and into the deck — all one origin. Every
// storage access is guarded: file:// is an opaque origin in some browsers and throws,
// and a page that cannot remember a preference must still work without one.
(function(){
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 urlLang = langFromUrl();
if (urlLang) langRemember(urlLang);
var lang = urlLang || (langStored() === "de" ? "de" : "en");
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];
}
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 urlTheme = themeFromUrl();
if (urlTheme) themeRemember(urlTheme);
var theme = urlTheme || (themeStored() === "light" ? "light" : "dark");
var i18n = Array.prototype.slice.call(document.querySelectorAll("[data-de]"));
i18n.forEach(function(el){ el.setAttribute("data-en", el.innerHTML); });
var UI = {
de:{ title:"GuestGraph – der Open-Source-Identitätsgraph für Gäste",
desc:"Ein erklärbares Gastprofil aus Daten, die über alle Systeme im Hotel verstreut sind. Quelloffen, Apache 2.0." },
en:{ title:"GuestGraph — the open-source guest identity graph",
desc:"One explainable guest profile, from data scattered across every system in the hotel. Open source, Apache 2.0." }
};
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.guestgraph.io/chat" data-model="/model/" data-questions="/model.json" data-questions-de="/questions.de.json"></script>
</body>
</html>