New design
Build and Deploy / build-deploy (push) Successful in 38s

This commit is contained in:
2026-06-30 00:45:38 +01:00
parent 4e676503a2
commit ece3fc2e44
12 changed files with 450 additions and 277 deletions
+36 -117
View File
@@ -1,135 +1,54 @@
.container {
.page {
height: 100vh;
height: 100svh;
overflow-y: auto;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
font-family: system-ui, -apple-system, sans-serif;
background: var(--paper);
}
.header {
padding: 3rem 2rem 2rem;
text-align: center;
color: #fff;
}
.title {
margin: 0;
font-size: 2.5rem;
font-weight: 700;
}
.main {
display: grid;
grid-template-columns: 1fr 1fr;
max-width: 1400px;
margin: 0 auto;
padding: 0 2rem 2rem;
gap: 2rem;
align-items: start;
}
.clockGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
align-content: start;
}
.sidebar {
position: relative;
}
.sidebar:has(> *) {
background: #fff;
border-radius: 12px;
padding: 0 1.5rem 1.5rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.sidebarHeader {
position: absolute;
top: 1.5rem;
right: 1.5rem;
.masthead {
display: flex;
align-items: center;
gap: 1rem;
align-items: baseline;
justify-content: space-between;
gap: 1.5rem;
max-width: 64rem;
margin: 0 auto;
padding: clamp(2.5rem, 6vw, 5rem) clamp(1.5rem, 4vw, 3rem) 2.5rem;
border-bottom: 1.5px solid var(--ink);
}
.launchButton {
display: inline-block;
padding: 0.75rem 1.5rem;
background: #2196f3;
color: #fff;
text-decoration: none;
border-radius: 8px;
font-weight: 500;
.wordmark {
margin: 0;
font-weight: 600;
font-size: clamp(2.75rem, 8vw, 5rem);
line-height: 0.95;
letter-spacing: -0.025em;
}
.closeButton {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #666;
.readout {
font-family: var(--font-mono);
font-size: 0.95rem;
letter-spacing: 0.08em;
font-variant-numeric: tabular-nums;
color: var(--ink-soft);
white-space: nowrap;
}
@media (max-width: 1280px) {
.main {
grid-template-columns: 1fr 2fr;
}
.entries {
max-width: 64rem;
margin: 0 auto;
padding: 0 clamp(1.5rem, 4vw, 3rem) clamp(3rem, 6vw, 5rem);
}
/* Mobile styles */
@media (max-width: 768px) {
.container[data-sidebar-open="true"] {
overflow: hidden;
.entries > :first-child {
border-top: none;
}
@media (max-width: 760px) {
.wordmark {
font-size: clamp(2.5rem, 14vw, 3.5rem);
}
.header {
padding: 2rem 1rem 1rem;
}
.title {
font-size: 1.75rem;
}
.main {
grid-template-columns: 1fr;
padding: 0 1rem 1rem;
gap: 1rem;
}
.clockGrid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
.sidebar {
display: none;
}
.sidebar:has(> *) {
display: flex;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
max-height: none;
border-radius: 0;
z-index: 1000;
padding: 0;
flex-direction: column;
overflow-y: auto;
}
.sidebarHeader {
position: sticky;
top: 0;
background: #fff;
padding: 1rem;
margin-bottom: 0;
justify-content: space-between;
z-index: 1;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
.readout {
font-size: 0.8rem;
}
}
+59 -86
View File
@@ -1,97 +1,70 @@
import { useEffect, useState } from 'react';
import styles from './app.module.css';
import { ClockCard } from './components/clock-card';
import { MarkdownRenderer } from './components/markdown-renderer';
import { ClockEntry, type ClockInfo } from './components/clock-entry';
import { LiveTime } from './components/live-time';
// Per-clock preview tuning, in curated reading order. `aspect` is the frame
// shape; `innerWidth`, when set, renders the iframe as if its viewport were that
// many pixels wide (then scales it to fit), so clocks that branch on viewport
// size keep their desktop layout.
const CLOCK_META: { id: string; aspect: string; innerWidth?: number }[] = [
{ id: 'simple', aspect: '16 / 9' },
{ id: 'wall-o-clocks', aspect: '21 / 9' },
{ id: 'precalculated-grid', aspect: '16 / 9' },
{ id: 'lemniscate', aspect: '2 / 1' },
{ id: 'spiral', aspect: '16 / 9' },
{ id: 'custom', aspect: '4 / 3' },
{ id: 'daylight', aspect: '16 / 9', innerWidth: 1280 },
{ id: 'crowd', aspect: '16 / 9' },
];
const DEFAULT_ASPECT = '16 / 9';
const META = new Map(CLOCK_META.map((meta, order) => [meta.id, { ...meta, order }]));
function parseClock(path: string, content: string): ClockInfo | null {
const id = path.match(/\.\/clocks\/(.+)\/README\.md/)?.[1];
const name = content.match(/^#\s+(.+)$/m)?.[1];
if (!id || !name) {
return null;
}
const tagline = content.match(/^>\s*(.+)$/m)?.[1];
const body = content
.replace(/^#\s+.+$/m, '')
.replace(/^>\s*.+$/m, '')
.trim();
return {
id,
name,
tagline,
body,
path: `/${id}/`,
aspect: META.get(id)?.aspect ?? DEFAULT_ASPECT,
innerWidth: META.get(id)?.innerWidth,
};
}
const CLOCKS = Object.entries(import.meta.glob('./clocks/*/README.md', { as: 'raw', eager: true }))
.map(([path, content]) => {
const id = path.match(/\.\/clocks\/(.+)\/README\.md/)?.[1];
const name = content.match(/^#\s+(.+)$/m)?.[1];
const description = content.match(/^>\s*(.+)$/m)?.[1];
if (!id || !name) {
return null;
}
return {
id,
name,
description,
content,
path: `/${id}/`,
};
})
.filter((clock) => !!clock);
.map(([path, content]) => parseClock(path, content))
.filter((clock): clock is ClockInfo => !!clock)
.sort(
(a, b) =>
(META.get(a.id)?.order ?? CLOCK_META.length) - (META.get(b.id)?.order ?? CLOCK_META.length),
);
export function App() {
const [selectedClock, setSelectedClock] = useState<string | null>(() => {
const hash = window.location.hash.slice(1);
return CLOCKS.some((c) => c.id === hash) ? hash : null;
});
const selectedClockInfo = CLOCKS.find((c) => c.id === selectedClock);
useEffect(() => {
if (selectedClock) {
window.history.replaceState(null, '', `#${selectedClock}`);
} else {
window.history.replaceState(null, '', window.location.pathname);
}
}, [selectedClock]);
useEffect(() => {
const handleHashChange = () => {
const hash = window.location.hash.slice(1);
if (hash && CLOCKS.some((c) => c.id === hash)) {
setSelectedClock(hash);
} else {
setSelectedClock(null);
}
};
window.addEventListener('hashchange', handleHashChange);
return () => window.removeEventListener('hashchange', handleHashChange);
}, []);
return (
<div className={styles.container} data-sidebar-open={!!selectedClock}>
<header className={styles.header}>
<h1 className={styles.title}>Clocks</h1>
<div className={styles.page}>
<header className={styles.masthead}>
<h1 className={styles.wordmark}>Clocks</h1>
<LiveTime className={styles.readout} />
</header>
<main className={styles.main}>
<section className={styles.clockGrid}>
{CLOCKS.map((clock) => (
<ClockCard
key={clock.id}
clock={clock}
isSelected={selectedClock === clock.id}
onSelect={() => setSelectedClock(selectedClock === clock.id ? null : clock.id)}
/>
))}
</section>
<aside className={styles.sidebar}>
{selectedClock && selectedClockInfo && (
<>
<div className={styles.sidebarHeader}>
<a href={selectedClockInfo.path} className={styles.launchButton}>
Launch Clock
</a>
<button
onClick={() => setSelectedClock(null)}
className={styles.closeButton}
type="button"
>
×
</button>
</div>
<MarkdownRenderer content={selectedClockInfo.content} />
</>
)}
</aside>
<main className={styles.entries}>
{CLOCKS.map((clock) => (
<ClockEntry key={clock.id} clock={clock} />
))}
</main>
</div>
);
+19
View File
@@ -0,0 +1,19 @@
export function ArrowRight({ className }: { className?: string }) {
return (
<svg
className={className}
width="1em"
height="1em"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M2 8h11" />
<path d="M9 4l4 4-4 4" />
</svg>
);
}
-29
View File
@@ -1,29 +0,0 @@
.card {
appearance: none;
display: flex;
flex-direction: column;
align-content: start;
padding: 1.5rem;
background: #fff;
border: 2px solid #e0e0e0;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s ease;
}
.card[data-selected="true"] {
background: #e3f2fd;
border: 2px solid #2196f3;
}
.name {
font-size: 1rem;
font-weight: 600;
color: #333;
}
.description {
margin: 0.5rem 0 0;
font-size: 0.85rem;
color: #666;
}
-25
View File
@@ -1,25 +0,0 @@
import styles from './clock-card.module.css';
interface ClockInfo {
id: string;
name: string;
description?: string;
path: string;
}
interface ClockCardProps {
clock: ClockInfo;
isSelected: boolean;
onSelect: () => void;
}
export function ClockCard({ clock, isSelected, onSelect }: ClockCardProps) {
return (
<button onClick={onSelect} className={styles.card} data-selected={isSelected} type="button">
<h3 className={styles.name}>{clock.name}</h3>
{!!clock.description?.length && <p className={styles.description}>{clock.description}</p>}
</button>
);
}
export type { ClockInfo };
+70
View File
@@ -0,0 +1,70 @@
.entry {
padding: clamp(2.5rem, 5vw, 4.5rem) 0;
border-top: 1px solid var(--rule);
}
.head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1.25rem;
flex-wrap: wrap;
}
.name {
margin: 0;
font-weight: 600;
font-size: clamp(1.7rem, 3.5vw, 2.4rem);
line-height: 1.05;
letter-spacing: -0.015em;
}
.nameLink {
color: var(--ink);
text-decoration: none;
transition: color 0.15s ease;
}
.nameLink:hover {
color: var(--accent);
}
.launch {
display: inline-flex;
align-items: center;
gap: 0.55em;
padding: 0.55rem 1rem;
border: 1px solid var(--ink);
font-family: var(--font-mono);
font-size: 0.82rem;
letter-spacing: 0.04em;
color: var(--ink);
text-decoration: none;
white-space: nowrap;
transition:
background-color 0.15s ease,
border-color 0.15s ease,
color 0.15s ease;
}
.launch:hover {
background: var(--accent);
border-color: var(--accent);
color: var(--paper);
}
.arrow {
transition: transform 0.15s ease;
}
.launch:hover .arrow {
transform: translateX(3px);
}
.tagline {
margin: 1rem 0 0;
font-style: italic;
font-size: clamp(1.2rem, 2.5vw, 1.4rem);
line-height: 1.4;
color: var(--ink);
}
+50
View File
@@ -0,0 +1,50 @@
import { ArrowRight } from './arrow-right';
import styles from './clock-entry.module.css';
import { ClockPreview } from './clock-preview';
import { MarkdownRenderer } from './markdown-renderer';
interface ClockInfo {
id: string;
name: string;
tagline?: string;
body: string;
path: string;
aspect: string;
innerWidth?: number;
}
interface ClockEntryProps {
clock: ClockInfo;
}
export function ClockEntry({ clock }: ClockEntryProps) {
return (
<article className={styles.entry}>
<header className={styles.head}>
<h2 className={styles.name}>
<a href={clock.path} className={styles.nameLink}>
{clock.name}
</a>
</h2>
<a href={clock.path} className={styles.launch}>
Launch
<ArrowRight className={styles.arrow} />
</a>
</header>
{clock.tagline && <p className={styles.tagline}>{clock.tagline}</p>}
<ClockPreview
src={clock.path}
title={clock.name}
aspect={clock.aspect}
innerWidth={clock.innerWidth}
/>
<MarkdownRenderer content={clock.body} />
</article>
);
}
export type { ClockInfo };
+59
View File
@@ -0,0 +1,59 @@
.frame {
position: relative;
display: block;
width: 100%;
margin: clamp(1.5rem, 3vw, 2.5rem) 0;
background: var(--paper-plate);
border: 1px solid var(--ink);
overflow: hidden;
transition: border-color 0.2s ease;
}
.frame:hover {
border-color: var(--accent);
}
.iframe {
width: 100%;
height: 100%;
border: 0;
display: block;
pointer-events: none;
}
.placeholder {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
font-size: 0.85rem;
letter-spacing: 0.04em;
color: rgba(243, 239, 230, 0.32);
}
.cue {
position: absolute;
bottom: 0;
right: 0;
display: inline-flex;
align-items: center;
gap: 0.45em;
padding: 0.4rem 0.7rem;
background: var(--accent);
color: var(--paper);
font-family: var(--font-mono);
font-size: 0.72rem;
letter-spacing: 0.04em;
opacity: 0;
transform: translateY(100%);
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
.frame:hover .cue {
opacity: 1;
transform: translateY(0);
}
+87
View File
@@ -0,0 +1,87 @@
import { useEffect, useRef, useState } from 'react';
import { ArrowRight } from './arrow-right';
import styles from './clock-preview.module.css';
interface ClockPreviewProps {
src: string;
title: string;
aspect: string;
innerWidth?: number;
}
export function ClockPreview({ src, title, aspect, innerWidth }: ClockPreviewProps) {
const ref = useRef<HTMLAnchorElement>(null);
const [active, setActive] = useState(false);
const [frame, setFrame] = useState<{ width: number; height: number } | null>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
// Mount the live clock only while it's near the viewport, and unmount it
// once scrolled well away — keeps at most the on-screen previews running.
const observer = new IntersectionObserver(([entry]) => setActive(entry.isIntersecting), {
rootMargin: '40% 0px',
});
observer.observe(el);
return () => observer.disconnect();
}, []);
useEffect(() => {
const el = ref.current;
if (!el || !innerWidth) return;
const observer = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
setFrame({ width, height });
});
observer.observe(el);
return () => observer.disconnect();
}, [innerWidth]);
// Render the iframe at `innerWidth` logical pixels, then scale the whole thing
// down to fill the frame — the embedded page sees a desktop-width viewport.
const iframeStyle =
innerWidth && frame
? {
position: 'absolute' as const,
top: 0,
left: 0,
width: `${innerWidth}px`,
height: `${(frame.height * innerWidth) / frame.width}px`,
transform: `scale(${frame.width / innerWidth})`,
transformOrigin: 'top left',
}
: undefined;
return (
<a
ref={ref}
href={src}
className={styles.frame}
aria-label={`Launch ${title}`}
tabIndex={-1}
style={{ aspectRatio: aspect }}
>
{active ? (
<iframe
src={src}
title={`${title} preview`}
className={styles.iframe}
style={iframeStyle}
scrolling="no"
tabIndex={-1}
/>
) : (
<span className={styles.placeholder}>{title}</span>
)}
<span className={styles.cue} aria-hidden="true">
Launch
<ArrowRight />
</span>
</a>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { useEffect, useState } from 'react';
function format(date: Date): string {
return date.toLocaleTimeString('en-GB', { hour12: false });
}
export function LiveTime({ className }: { className?: string }) {
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(id);
}, []);
return (
<time className={className} dateTime={now.toISOString()}>
{format(now)}
</time>
);
}
+25 -16
View File
@@ -1,25 +1,34 @@
.markdown {
line-height: 1.6;
color: #333;
margin-top: 1.1rem;
font-family: var(--font-serif);
font-size: 1.0625rem;
line-height: 1.75;
color: var(--ink);
}
@media (min-width: 769px) {
.markdown h1:first-of-type {
max-width: calc(100% - 208px);
}
.markdown p {
margin: 1rem 0 0;
}
@media (max-width: 768px) {
.markdown {
padding: 0 1rem 1rem;
}
.markdown p:first-child {
margin-top: 0;
}
.markdown blockquote {
font-size: 0.9em;
.markdown a {
color: var(--accent);
text-decoration: underline;
text-underline-offset: 0.15em;
text-decoration-thickness: 1px;
}
.markdown a:hover {
color: var(--accent-strong);
}
.markdown em {
font-style: italic;
color: #666;
margin: 1rem 0;
padding-left: 1rem;
border-left: 3px solid #ddd;
}
.markdown strong {
font-weight: 600;
}
+25 -4
View File
@@ -1,9 +1,30 @@
body {
margin: 0;
overflow: hidden;
:root {
--paper: #f3efe6;
--paper-plate: #0a0a0a;
--ink: #1c1b18;
--ink-soft: #57534a;
--accent: #b23a2e;
--accent-strong: #8f2b21;
--rule: rgba(28, 27, 24, 0.16);
--font-serif: ui-serif, Georgia, "Iowan Old Style", "Times New Roman", serif;
--font-mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
}
* {
box-sizing: border-box;
font-family: system-ui, monospace;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
overflow: hidden;
background: var(--paper);
color: var(--ink);
font-family: var(--font-serif);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}