Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added profile/assets/ros2_medkit/header-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added profile/assets/ros2_medkit/header-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added profile/assets/ros2_medkit/how-it-works-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added profile/assets/ros2_medkit/how-it-works-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added profile/assets/ros2_medkit/logo-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added profile/assets/ros2_medkit/logo-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
216 changes: 216 additions & 0 deletions profile/design/images/wordmark-dark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
216 changes: 216 additions & 0 deletions profile/design/images/wordmark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
81 changes: 52 additions & 29 deletions profile/design/render.mjs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
#!/usr/bin/env node
// Renders every [data-asset] in assets.html to profile/assets/ with
// headless Chrome over the DevTools protocol. No npm install needed: Node 22+
// ships a WebSocket client.
// Renders every [data-asset] in each sheet below with headless Chrome over
// the DevTools protocol. No npm install needed: Node 22+ ships a WebSocket
// client.
//
// node profile/design/render.mjs
// node profile/design/render.mjs every sheet
// node profile/design/render.mjs ros2_medkit one sheet, by name
//
// CHROME=/path/to/chrome overrides the browser.

Expand All @@ -15,8 +16,15 @@ import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';

const here = dirname(fileURLToPath(import.meta.url));
const page = pathToFileURL(join(here, 'assets.html')).href;
const out = join(here, '..', 'assets');
// assets.html draws the org profile; ros2_medkit.html the images the
// ros2_medkit README links to, so that repository carries no binaries
const SHEETS = [
{ name: 'profile', page: 'assets.html', out: join(here, '..', 'assets') },
{ name: 'ros2_medkit', page: 'ros2_medkit.html', out: join(here, '..', 'assets', 'ros2_medkit') },
];
const only = process.argv[2];
const sheets = SHEETS.filter((sheet) => !only || sheet.name === only);
if (!sheets.length) throw new Error(`No sheet named ${only}, try one of: ${SHEETS.map((sheet) => sheet.name).join(', ')}`);
const SCALE = 2;
const TIMEOUT_MS = 60_000;

Expand Down Expand Up @@ -108,32 +116,47 @@ try {
return result.value;
};

await mkdir(out, { recursive: true });
const written = [];
for (const theme of ['light', 'dark']) {
const loaded = once('Page.loadEventFired', s);
await send('Page.navigate', { url: `${page}?theme=${theme}` }, s);
await loaded;
// Rejects when a font or an image did not load, so nothing renders with a fallback
await evaluate('window.ready');
for (const { page, out } of sheets) {
await mkdir(out, { recursive: true });
for (const theme of ['light', 'dark']) {
const loaded = once('Page.loadEventFired', s);
await send('Page.navigate', { url: `${pathToFileURL(join(here, page)).href}?theme=${theme}` }, s);
await loaded;
// Rejects when a font or an image did not load, so nothing renders with a fallback
await evaluate('window.ready');

const assets = await evaluate(`[...document.querySelectorAll('[data-asset]')].map((el) => {
const r = el.getBoundingClientRect();
return { name: el.dataset.asset, only: el.dataset.themes ?? null, format: el.dataset.format ?? 'png', x: r.x + scrollX, y: r.y + scrollY, width: r.width, height: r.height };
})`);
// Every asset to whole pixels, top to bottom, so a text-sized button keeps
// its right border and nothing below a fractional block lands between pixels
await evaluate(`document.querySelectorAll('[data-asset]').forEach((el) => {
const r = el.getBoundingClientRect();
el.style.width = Math.ceil(r.width) + 'px';
el.style.height = Math.ceil(r.height) + 'px';
}); true`);

for (const a of assets) {
// Theme-independent assets render once, from their own theme's pass
if (a.only && a.only !== theme) continue;
const file = a.only ? `${a.name}.${a.format}` : `${a.name}-${theme}.${a.format}`;
const { data } = await send('Page.captureScreenshot', {
format: a.format,
...(a.format === 'webp' && { quality: 90 }),
captureBeyondViewport: true,
clip: { x: a.x, y: a.y, width: a.width, height: a.height, scale: 1 },
}, s);
await writeFile(join(out, file), Buffer.from(data, 'base64'));
written.push(`${file} ${Math.round(a.width)}x${Math.round(a.height)}`);
const assets = await evaluate(`[...document.querySelectorAll('[data-asset]')].map((el) => {
const r = el.getBoundingClientRect();
return { name: el.dataset.asset, only: el.dataset.themes ?? null, format: el.dataset.format ?? 'png', x: r.x + scrollX, y: r.y + scrollY, width: r.width, height: r.height };
})`);

for (const a of assets) {
// Theme-independent assets render once, from their own theme's pass
if (a.only && a.only !== theme) continue;
const file = a.only ? `${a.name}.${a.format}` : `${a.name}-${theme}.${a.format}`;
// Snapped out to whole pixels, in case a box is still fractional
const x = Math.floor(a.x);
const y = Math.floor(a.y);
const width = Math.ceil(a.x + a.width) - x;
const height = Math.ceil(a.y + a.height) - y;
const { data } = await send('Page.captureScreenshot', {
format: a.format,
...(a.format === 'webp' && { quality: 90 }),
captureBeyondViewport: true,
clip: { x, y, width, height, scale: 1 },
}, s);
await writeFile(join(out, file), Buffer.from(data, 'base64'));
written.push(`${join(out, file).slice(join(here, '..').length + 1)} ${width}x${height}`);
}
}
}
console.log(written.join('\n'));
Expand Down
176 changes: 176 additions & 0 deletions profile/design/ros2_medkit.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
<!doctype html>
<!--
The few images in the ros2_medkit README, drawn with the same brand system
as assets.html: Oxanium bold headings, Inter body, white cards on a chalk
panel. They live here, next to the org profile, so the ros2_medkit
repository does not carry binary assets; its README links to them on
raw.githubusercontent.com.

The README itself stays plain GitHub markdown, as engineers expect: badges
on top, real headings, commands as text. Only the logo, the header and the
how-it-works diagram are drawn.

render.mjs saves each [data-asset] here as
profile/assets/ros2_medkit/<name>-<theme>.png. The ros2_medkit README carries
the same words in each image's alt text, so change both.
-->
<html lang="en">
<head>
<meta charset="utf-8">
<title>ros2_medkit README assets</title>
<style>
@font-face {
font-family: Oxanium;
font-weight: 200 800;
src: url("fonts/oxanium-latin.woff2") format("woff2");
}
@font-face {
font-family: Inter;
font-weight: 100 900;
src: url("fonts/inter-latin.woff2") format("woff2");
}

/* Light: the site's white ground. Dark: the site's dark act. */
:root {
--ink: #121413;
--body: #363a37;
--muted: #5d645e;
--accent-text: #2f6b3f;
--surface: #eeeeea;
--card: #ffffff;
--line: rgb(18 20 19 / 0.1);
--hero-ink: #0f3b24;
}
:root.dark {
--ink: #f4f5f1;
--body: #c9cdc4;
--muted: #a8b2a8;
--accent-text: #c8ff3e;
--surface: #1a1c1a;
--card: #242724;
--line: rgb(255 255 255 / 0.08);
--hero-ink: #f4f5f1;
}

* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { background: transparent; }
body {
font-family: Inter, system-ui, sans-serif;
color: var(--body);
-webkit-font-smoothing: antialiased;
padding: 40px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 40px;
}
h1, h4, .display {
font-family: Oxanium, system-ui, sans-serif;
font-weight: 700;
letter-spacing: -0.03em;
color: var(--ink);
}
h1 { letter-spacing: -0.04em; }

/* The README shows the wide assets at 100% */
.wide { width: 800px; }

/* Logo: the website's wordmark, graphite on light, white on dark */
.logo { display: flex; }
.logo img { height: 30px; width: auto; display: block; }
.logo .on-dark, .dark .logo .on-light { display: none; }
.dark .logo .on-dark { display: block; }

/* Header: the name and the claim beside the Medkit drawing */
.header { display: grid; grid-template-columns: 1fr 170px; gap: 32px; align-items: center; }
.header h1 { font-size: 64px; line-height: 1; color: var(--hero-ink); }
.header p { font-size: 22px; line-height: 1.4; color: var(--ink); margin-top: 14px; max-width: 30ch; }
.header img { display: block; width: 100%; height: auto; }

/* How it works: white cards on a chalk panel, as the website draws diagrams */
.panel { background: var(--surface); border-radius: 16px; padding: 28px; }
.flow { display: grid; grid-template-columns: 1fr 26px 1.5fr 26px 1fr; align-items: stretch; }
.flow .to { display: flex; justify-content: center; align-items: center; color: var(--muted); }
.flow .to svg { width: 18px; height: 18px; }
.card { background: var(--card); border-radius: 12px; padding: 20px 22px; }
.card .k { font-size: 14px; color: var(--muted); }
.card .name { font-size: 22px; line-height: 1.1; margin-top: 4px; }
.items { list-style: none; margin-top: 12px; }
.items li { font-size: 15.5px; line-height: 1.4; padding: 8px 0; border-top: 1px solid var(--line); color: var(--ink); }
.stages { list-style: none; margin-top: 12px; }
.stages li { padding: 10px 0; border-top: 1px solid var(--line); }
.stages h4 { font-size: 18px; line-height: 1.2; }
.stages p { font-size: 15px; line-height: 1.45; margin-top: 2px; }
</style>
</head>
<body>

<div class="logo" data-asset="logo">
<img class="on-light" src="images/wordmark-dark.svg" alt="">
<img class="on-dark" src="images/wordmark.svg" alt="">
</div>

<section class="wide header" data-asset="header">
<div>
<h1>ros2_medkit</h1>
<p>See what broke on any ROS 2 robot, from one REST API.</p>
</div>
<img src="images/medkit.webp" alt="">
</section>

<!-- Your robot, then what ros2_medkit does with it, then where you read it -->
<div class="wide panel" data-asset="how-it-works">
<div class="flow">
<div class="card">
<p class="k">Your robot</p>
<ul class="items">
<li>Error logs</li>
<li>Failed actions</li>
<li>/diagnostics</li>
<li>Your own nodes, through FaultReporter</li>
</ul>
</div>
<div class="to" data-arrow></div>
<div class="card">
<p class="k">ros2_medkit</p>
<ol class="stages">
<li><h4>Listens</h4><p>to logs, actions and diagnostics</p></li>
<li><h4>Records</h4><p>each fault, with its history, a snapshot and a rosbag</p></li>
<li><h4>Serves</h4><p>it all on one REST API, with every node it found</p></li>
</ol>
</div>
<div class="to" data-arrow></div>
<div class="card">
<p class="k">You</p>
<ul class="items">
<li>Browser</li>
<li>curl</li>
<li>Web UI</li>
<li>AI assistant</li>
</ul>
</div>
</div>
</div>

<script>
const arrow = '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3"/></svg>';
document.querySelectorAll('[data-arrow]').forEach((el) => { el.innerHTML = arrow; });

const theme = new URLSearchParams(location.search).get('theme') || 'light';
document.documentElement.classList.add(theme);

// Fails the render when a face or an image is missing, so no asset ships
// with a fallback font or a hole where a drawing should be
window.ready = document.fonts.ready.then(async () => {
await Promise.all([...document.images].map((img) => img.decode().catch(() => null)));
const loaded = [...document.fonts].filter((ff) => ff.status === 'loaded').map((ff) => ff.family);
const missingFaces = ['Oxanium', 'Inter'].filter((f) => !loaded.includes(f));
const missingImages = [...document.images].filter((img) => !img.naturalWidth).map((img) => img.src);
if (missingFaces.length || missingImages.length) {
throw new Error(`Not loaded: ${[...missingFaces, ...missingImages].join(', ')}`);
}
return true;
});
</script>
</body>
</html>