@@ -55,7 +71,7 @@ export function Orbit({
style={{ '--a': `${(index * 360) / members.length}deg` } as CSSProperties}
>
-
+
{member.name.split(' ')[0]}
diff --git a/site/app/components/kit/panels.tsx b/site/app/components/kit/panels.tsx
index b7c9e12..ae3edbc 100644
--- a/site/app/components/kit/panels.tsx
+++ b/site/app/components/kit/panels.tsx
@@ -1,5 +1,6 @@
import { Link } from 'react-router';
import { day, shortTag } from '../../format';
+import { useOnline } from '../../live';
import { changelogPath, releaseLine } from '../../model';
import type { Member, Message, Project, Release, Running } from '../../types';
import Status from '../Status';
@@ -12,12 +13,13 @@ export function MembersPanel({
members: Member[];
projectsOf: (member: Member) => number;
}) {
+ const online = useOnline();
return (
{members.map((member) => (
-
+
{member.name}
{member.headline ?? `@${member.github}`}
diff --git a/site/app/components/kit/posts.tsx b/site/app/components/kit/posts.tsx
index 62b2d68..dc5f94b 100644
--- a/site/app/components/kit/posts.tsx
+++ b/site/app/components/kit/posts.tsx
@@ -6,6 +6,7 @@ import { useMotion, useSpotlight, useTilt } from '../../motion/motion';
import type { PostSummary } from '../../posts';
import TagList from '../TagList';
import { accentStyle, Avatar, Cover } from './basics';
+import { Views } from './live';
export function PostCard({ post, compact = false }: { post: PostSummary; compact?: boolean }) {
const hub = useHub();
@@ -38,6 +39,7 @@ export function PostCard({ post, compact = false }: { post: PostSummary; compact
{post.readingMinutes} min read
+
{project && (
{project.name}
diff --git a/site/app/components/kit/projects.tsx b/site/app/components/kit/projects.tsx
index d4882a5..25753ea 100644
--- a/site/app/components/kit/projects.tsx
+++ b/site/app/components/kit/projects.tsx
@@ -7,6 +7,7 @@ import type { Member, Project } from '../../types';
import Status from '../Status';
import TagList from '../TagList';
import { accentStyle, Avatar, Cover } from './basics';
+import { Views } from './live';
interface Props {
project: Project;
@@ -65,12 +66,15 @@ export function Window({ project, owner }: Props) {
{project.summary}
- {owner && (
-
-
- {owner.name}
-
- )}
+
+ {owner && (
+
+
+ {owner.name}
+
+ )}
+
+
diff --git a/site/app/styles/graphite.css b/site/app/styles/graphite.css
index db9d099..f1db9b3 100644
--- a/site/app/styles/graphite.css
+++ b/site/app/styles/graphite.css
@@ -62,6 +62,99 @@
.frame-search .kbd {
line-height: 1.3;
}
+.live-pulse {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+ color: var(--color-faint);
+ white-space: nowrap;
+ font-variant-numeric: tabular-nums;
+ transition: color 0.3s;
+}
+.live-pulse b {
+ font-weight: 600;
+ color: var(--color-muted);
+}
+.live-pulse i {
+ position: relative;
+ flex: none;
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--color-live);
+ transition: background 0.3s;
+}
+.live-pulse i::after {
+ content: '';
+ position: absolute;
+ inset: -1px;
+ border-radius: 50%;
+ border: 1px solid var(--color-live);
+ opacity: 0;
+}
+.live-pulse[data-state='reconnecting'] {
+ color: var(--color-wip);
+}
+.live-pulse[data-state='reconnecting'] i {
+ background: var(--color-wip);
+ animation: state-pulse 1.2s ease-in-out infinite;
+}
+.badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+ padding: 0.2rem 0.65rem;
+ border: 1px solid var(--color-line-soft);
+ border-radius: 999px;
+ background: var(--color-surface);
+ font-family: var(--font-mono);
+ font-size: 0.72rem;
+ font-weight: 400;
+ letter-spacing: 0;
+ white-space: nowrap;
+ color: var(--color-muted);
+}
+.badge.reading i {
+ position: relative;
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--color-live);
+}
+.badge.reading i::after {
+ content: '';
+ position: absolute;
+ inset: -1px;
+ border-radius: 50%;
+ border: 1px solid var(--color-live);
+ opacity: 0;
+}
+.badge .pens {
+ display: inline-flex;
+}
+.badge .pens i {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--accent);
+ box-shadow: 0 0 0 1.5px var(--color-surface);
+}
+.badge .pens i + i {
+ margin-left: -3px;
+}
+.views {
+ white-space: nowrap;
+ font-variant-numeric: tabular-nums;
+}
+.window .who {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ font-family: var(--font-mono);
+ font-size: 0.82rem;
+ color: var(--color-faint);
+}
.account {
position: relative;
display: flex;
@@ -864,6 +957,7 @@ textarea.field {
}
.avatar {
+ position: relative;
flex: none;
width: 48px;
height: 48px;
@@ -872,7 +966,6 @@ textarea.field {
background: var(--color-paper);
display: grid;
place-items: center;
- overflow: hidden;
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--color-muted);
@@ -881,9 +974,23 @@ textarea.field {
.avatar img {
width: 100%;
height: 100%;
+ border-radius: 50%;
object-fit: cover;
display: block;
}
+.avatar[data-online]::after {
+ content: '';
+ position: absolute;
+ right: -2px;
+ bottom: -2px;
+ width: 26%;
+ min-width: 7px;
+ max-width: 12px;
+ aspect-ratio: 1;
+ border-radius: 50%;
+ background: var(--color-live);
+ box-shadow: 0 0 0 2px var(--color-paper);
+}
.avatar.xs {
width: 20px;
height: 20px;
@@ -1667,6 +1774,13 @@ textarea.field {
.week i {
animation: dot-pop 0.35s cubic-bezier(0.16, 1, 0.3, 1) both;
}
+ .live-pulse i::after,
+ .badge.reading i::after {
+ animation: beat-ring 1.1s cubic-bezier(0.16, 1, 0.3, 1) both;
+ }
+ .avatar[data-online]::after {
+ animation: dot-pop 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
+ }
}
@keyframes orbit-spin {
to {
diff --git a/site/app/styles/pages/live.css b/site/app/styles/pages/live.css
index c683405..8708748 100644
--- a/site/app/styles/pages/live.css
+++ b/site/app/styles/pages/live.css
@@ -181,6 +181,100 @@
.board .console .lines {
padding-inline: 1.25rem;
}
+.atlas {
+ grid-column: 1 / -1;
+ display: grid;
+ gap: 0.75rem;
+ padding-bottom: 1rem;
+ border-top: 1px solid var(--color-line-soft);
+}
+.atlas .head {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.55rem 1.25rem;
+ border-bottom: 1px solid var(--color-line-soft);
+ font-family: var(--font-mono);
+ font-size: 0.74rem;
+ color: var(--color-faint);
+}
+.atlas .head b {
+ font-weight: 600;
+ color: var(--color-ink);
+ font-variant-numeric: tabular-nums;
+}
+.atlas .head .end {
+ margin-left: auto;
+}
+.atlas svg {
+ display: block;
+ width: min(100%, 64rem);
+ margin: 0.25rem auto 0;
+ padding-inline: 1rem;
+ overflow: visible;
+}
+.atlas .land {
+ fill: none;
+ stroke: var(--color-line);
+ stroke-width: 0.58;
+ stroke-linecap: round;
+}
+.atlas .lit .core {
+ fill: var(--color-live);
+}
+.atlas .lit .halo {
+ fill: var(--color-live);
+ opacity: 0.14;
+}
+.atlas .lit .ring {
+ fill: none;
+ stroke: var(--color-live);
+ stroke-width: 0.25;
+ opacity: 0;
+}
+.atlas .lit-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem 1.25rem;
+ padding-inline: 1.25rem;
+ font-family: var(--font-mono);
+ font-size: 0.74rem;
+ color: var(--color-muted);
+}
+.atlas .lit-list b {
+ font-weight: 600;
+ color: var(--color-ink);
+ font-variant-numeric: tabular-nums;
+}
+@media (prefers-reduced-motion: no-preference) {
+ .atlas .lit {
+ animation: dot-pop 0.5s cubic-bezier(0.16, 1, 0.3, 1) both;
+ }
+ .atlas .lit .ring {
+ animation: map-ring 2.4s ease-out infinite;
+ }
+ .atlas .lit .halo {
+ animation: map-breathe 3.2s ease-in-out infinite alternate;
+ }
+}
+@keyframes map-ring {
+ from {
+ opacity: 0.8;
+ transform: scale(1);
+ }
+ to {
+ opacity: 0;
+ transform: scale(3.2);
+ }
+}
+@keyframes map-breathe {
+ from {
+ opacity: 0.1;
+ }
+ to {
+ opacity: 0.22;
+ }
+}
@media (max-width: 1000px) {
.board {
grid-template-columns: minmax(0, 1fr);
From bc2b1a50f10c78e2f245d56001f95e288f0bc264 Mon Sep 17 00:00:00 2001
From: Amr Mohammed El-Sheraey <141947355+AmrMsCLL@users.noreply.github.com>
Date: Sat, 7 Nov 2026 02:13:00 +0200
Subject: [PATCH 4/4] fix(live): a restart forgets the previous process's tabs
---
site/server/presence.test.ts | 12 ++++++++++++
site/server/presence.ts | 12 ++++++++++--
site/server/testing.ts | 14 +++++++++++++-
3 files changed, 35 insertions(+), 3 deletions(-)
diff --git a/site/server/presence.test.ts b/site/server/presence.test.ts
index 851cfe7..baad1da 100644
--- a/site/server/presence.test.ts
+++ b/site/server/presence.test.ts
@@ -66,6 +66,18 @@ describe('presence', () => {
assert.deepEqual(last(published, 'map'), { country: 'EG', count: 0 });
});
+ it("forgets the previous process's tabs on start and keeps the map fading", async () => {
+ const { presence, redis } = setup();
+ await presence.join('t1', { reading: 'post-1', handle: 'amrmscll', country: 'DE' });
+ const restarted = createPresence(redis, recordingBus().bus, () => 1_762_500_005_000);
+ await restarted.start();
+ restarted.stop();
+ assert.deepEqual(restarted.current(), { here: 0, online: [] });
+ assert.equal(restarted.reading('post-1'), 0);
+ assert.deepEqual(restarted.countries(), [{ code: 'DE', count: 1 }]);
+ assert.deepEqual(redis.keys(), ['map:countries']);
+ });
+
it('keeps nothing but counters in Redis', async () => {
const { presence, redis } = setup();
await presence.join('t1', { reading: 'post-1', handle: 'amrmscll', country: 'DE' });
diff --git a/site/server/presence.ts b/site/server/presence.ts
index e8cda1d..12ba38f 100644
--- a/site/server/presence.ts
+++ b/site/server/presence.ts
@@ -156,9 +156,17 @@ export function createPresence(redis: Redis, bus: Bus, now = () => Date.now()) {
sweep,
- start() {
+ async start() {
+ let cursor = '0';
+ do {
+ const [next, keys] = await redis.scan(cursor, 'MATCH', 'presence:*', 'COUNT', 200);
+ cursor = next;
+ if (keys.length > 0) {
+ await redis.del(...keys);
+ }
+ } while (cursor !== '0');
sweeper = setInterval(() => void sweep().catch(() => undefined), SWEEP_MS);
- return sweep();
+ await sweep();
},
stop() {
diff --git a/site/server/testing.ts b/site/server/testing.ts
index 4cb2915..8376d82 100644
--- a/site/server/testing.ts
+++ b/site/server/testing.ts
@@ -54,7 +54,19 @@ export function fakeRedis() {
async get(key: string) {
return strings.get(key) ?? null;
},
- keys: () => [...sorted.keys(), ...sets.keys(), ...strings.keys()],
+ async scan(_cursor: string, _match: string, pattern: string) {
+ const prefix = pattern.replace(/\*$/, '');
+ return ['0', fake.keys().filter((key) => key.startsWith(prefix))];
+ },
+ async del(...keys: string[]) {
+ return keys.filter((key) => sorted.delete(key) || sets.delete(key) || strings.delete(key))
+ .length;
+ },
+ keys: () => [
+ ...[...sorted].filter(([, members]) => members.size > 0).map(([key]) => key),
+ ...sets.keys(),
+ ...strings.keys(),
+ ],
};
return fake as unknown as Redis & { keys(): string[] };
}