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
33 changes: 28 additions & 5 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

Dyno is named after a dynamometer: the rolling road where a car runs at full throttle while instruments record what it does. The reporter is the instrument panel for that cell. Every run is a *pull*, the overview is the *dyno sheet* printed from it, and status marks come from the hardware in the room (gauges, rollers, sheet notes, hazard tape).

The app gets the screen while tests run. The reporter helps you find a failure, read its output, and get back to the app. Everything below serves those three jobs. When you add UI, start from this doc and the tokens in [reporter.css](../src/browser/reporter.css), not from a UI kit.
The same brand runs through the docs site, so reading about Dyno feels like standing in the same cell.

The app gets the screen while tests run. The reporter helps you find a failure, read its output, and get back to the app. Everything below serves those three jobs. When you add UI, start from this doc and [brand.css](../src/browser/brand.css), not from a UI kit.

## The rule

Expand Down Expand Up @@ -77,7 +79,9 @@ Never put a circle, chip, or background behind these marks. Always pair color wi

## Tokens

All colors live at the top of [reporter.css](../src/browser/reporter.css) and follow the system light or dark preference. Use the tokens. Don't hard-code hex values in components; the only exceptions are the hazard-stripe black and the light-mode roller yellow.
All colors live in [brand.css](../src/browser/brand.css), which the reporter, the control room, saved reports, and the docs site all load. Each token is a `light-dark()` pair, so a page picks its theme by setting `color-scheme`. The reporter follows the system preference, the control room stays dark, and the docs follow their theme toggle. Use the tokens and don't hard-code hex values in components.

`brand.css` also holds the shared pieces: the wordmark, roller, tach, sheet, sheet notes, legend, `--roller` (the roller as a CSS mask), and `--hazard` (the stripe). Pages size these for their layout but don't restyle them.

Dark is the night-shift dyno cell, in deep steel blue. Light is the printed sheet: cool paper with a blue grid.

Expand All @@ -94,7 +98,9 @@ Dark is the night-shift dyno cell, in deep steel blue. Light is the printed shee
| `--trace` | `#63a9ff` | `#1a5bc4` | The pull trace, passed points, links, focus rings |
| `--accent` | `#ffc629` | `#ffc629` | Hazard yellow: the roller, primary buttons, anything live |
| `--accent-text` | `#ffc629` | `#845e00` | Yellow used as text, readable on paper |
| `--on-accent` | `#1d1600` | `#1d1600` | Text on yellow |
| `--accent-hover` | `#ffd457` | `#ffd457` | Primary buttons on hover |
| `--on-accent` | `#1d1600` | `#1d1600` | Text on yellow, and the dark band of the hazard stripe |
| `--mark` | `#ffc629` | `#e0a400` | The roller, a little deeper on paper |
| `--pass` | `#4fd08a` | `#11804a` | Passed |
| `--fail` | `#ff5c61` | `#c8192e` | Failed |
| `--fail-tint` | `--fail` at 8% | `--fail` at 5% | The background of the failing source line |
Expand Down Expand Up @@ -149,7 +155,21 @@ Motion means something is live. There are only a few moving parts:
- The running row's load trace sweeps back and forth.
- The tach needle and sweep ease to new progress.

Nothing animates on page load, and nothing moves on hover except color. `prefers-reduced-motion` turns all of it off.
In the reporter, nothing animates on page load, and nothing moves on hover except color. The docs site gets two playful exceptions, described below. `prefers-reduced-motion` turns all of it off.

## The docs site

The docs are a [Starlight](https://starlight.astro.build) site in [site](../site). They import [brand.css](../src/browser/brand.css), so they use the same tokens, type, wordmark, roller, tach, and sheet as the reporter. [docs.css](../site/src/styles/docs.css) maps Starlight's variables onto the brand.

- **Tokens:** Starlight's grays map to the reporter's ink, muted, faint, line, surface, and background. `--sl-color-accent` is hazard yellow and `--sl-color-text-accent` is the trace blue, so links stay blue and primary buttons are yellow plates. Dark is the dyno cell and light is the printed sheet. Only the few shades Starlight needs that the reporter doesn't (two extra grays, the page background, and accent tints) live in docs.css.
- **Wordmark:** the site title is the shared wordmark from [markup.js](../src/browser/markup.js). Its roller turns as you scroll, like the drum under a car on the dyno, using a scroll-driven animation where the browser supports one.
- **Homepage:** a splash hero with the title and two actions, next to an example pull drawn by the real `dyno-chart.js`. On load, the trace draws in left to right, the tach sweeps, and the sheet note prints. That's the site's one orchestrated moment. **Run it again** replays the suite after its slow test got faster, with the first pull as a ghost, so the hero teaches the ghost feature by doing it. A faint sheet grid sits behind the hero.
- **404:** "Not on the sheet," with a big tach whose needle never left zero. The tach is the hero image, passed as data, so the 404 uses Starlight's own hero.
- **Sidebar:** the current page sits on a small roller, the same mark as a running test. Group labels are wide and heavy. There are no left border lines on nested lists.
- **Callouts:** tinted plates with a border and no left rail. Caution and danger callouts get the hazard tape along the top, the cell's "stop and look" sign. This is the only other place hazard stripes appear.
- **Tables and code:** row dividers only, muted header text, and tabular numerals. Code frames keep Starlight's Expressive Code with an 8px radius and no shadow.

[Hero.astro](../site/src/components/Hero.astro) draws the example pull for a hero without its own image, and defers to Starlight's hero otherwise. The generator in [generate.js](../site/scripts/generate.js) writes the homepage and 404 frontmatter, so the overview copy still comes from source JSDoc. Selectors in docs.css that follow Starlight's internal markup are grouped together, so check them after a Starlight upgrade.

## Copy

Expand Down Expand Up @@ -179,11 +199,14 @@ Explain a failure and the next useful step without hype: "The test controller cl

| File | What it owns |
| --- | --- |
| [reporter.css](../src/browser/reporter.css) | Tokens, type, components, and responsive layout |
| [brand.css](../src/browser/brand.css) | Tokens, the wordmark, roller, tach, and sheet, shared by every Dyno page and the docs |
| [reporter.css](../src/browser/reporter.css) | The reporter's type, components, and responsive layout |
| [reporter.html](../src/browser/reporter.html) and [reporter.js](../src/browser/reporter.js) | The reporter's views and rendering |
| [dyno-chart.js](../src/browser/dyno-chart.js) | The sheet, ghost, sheet-note position, and tachometer, for live and saved reports |
| [timing-format.js](../src/browser/timing-format.js) | Durations, phases, and failure location text |
| [report-format.js](../src/report-format.js) and [saved-report.css](../src/browser/saved-report.css) | Saved HTML reports |
| [host.html](../src/browser/host.html) and [host.css](../src/browser/host.css) | The control room page that hosts the app |
| [bridge.js](../src/browser/bridge.js) | The reporter iframe and the in-app "Show test results" button |
| [reporter-client.js](../src/browser/reporter-client.js) and [shell.js](../src/browser/shell.js) | Reporter navigation and messages, keeping the app and overlay separate |
| [markup.js](../src/browser/markup.js) | The shared wordmark and HTML escaping |
| [docs.css](../site/src/styles/docs.css), [SiteTitle.astro](../site/src/components/SiteTitle.astro), and [Hero.astro](../site/src/components/Hero.astro) | The docs site's theme, wordmark, homepage hero, and 404 |
5 changes: 4 additions & 1 deletion site/astro.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@ export default defineConfig({
site: 'https://robdel12.github.io',
base,
outDir: './dist',
// In dev, the browser loads the reporter's shared font and brand.css from the package's src/browser.
vite: { server: { fs: { allow: ['.', '../src/browser'] } } },
integrations: [starlight({
title: 'Dyno',
description: 'Browser tests that run in the browser.',
social: [{ icon: 'github', label: 'GitHub', href: 'https://github.com/Robdel12/dyno' }],
customCss: ['./src/styles/docs.css'],
components: { Header: './src/components/Header.astro' },
expressiveCode: { styleOverrides: { borderRadius: '8px', frames: { frameBoxShadowCssValue: 'none' } } },
components: { Header: './src/components/Header.astro', SiteTitle: './src/components/SiteTitle.astro', Hero: './src/components/Hero.astro' },
plugins: [starlightTypeDoc({
entryPoints: ['./.generated/types/dyno.d.ts', './.generated/types/config.d.ts', './.generated/types/fixtures.d.ts'],
tsconfig: './tsconfig.json',
Expand Down
27 changes: 20 additions & 7 deletions site/scripts/generate.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { sourceDocs, documentedTypes } from './source.js';
import { guideOrder } from '../navigation.js';
import { tachometer } from '../../src/browser/dyno-chart.js';

let exec = promisify(execFile);
export let root = fileURLToPath(new URL('../../', import.meta.url));
Expand Down Expand Up @@ -38,6 +39,7 @@ export async function generate() {
anchors.set(slug(guide.title), `guides/${key}/`);
for (let [, heading] of guide.body.matchAll(/^#{2,3} (.+)$/gm)) anchors.set(slug(heading), `guides/${key}/#${slug(heading)}`);
}
let base = process.env.DYNO_DOCS_BASE ?? '/dyno/next/';
function links(body) {
return body.replace(/\]\(([^)]+)\)/g, (match, href) => {
let [file, hash] = href.split('#');
Expand All @@ -52,27 +54,38 @@ export async function generate() {
return `](https://github.com/Robdel12/dyno/${kind}/${process.env.DYNO_DOCS_COMMIT ?? 'main'}/${resource}${hash ? `#${hash}` : ''})`;
}
if (!route) return match;
return `](${process.env.DYNO_DOCS_BASE ?? '/dyno/next/'}${route})`;
return `](${base}${route})`;
});
}
let page = async (path, title, body) => write(join(output, `${path}.md`), `---\ntitle: ${JSON.stringify(title)}\n---\n\n${links(body)}\n`);
await page('index', overview.title, overview.body);
// Frontmatter is written as JSON, which is valid YAML.
let page = async (path, frontmatter, body) => write(join(output, `${path}.md`), `---\n${JSON.stringify(frontmatter, null, 2)}\n---\n\n${links(body)}\n`);
let action = (text, route, variant) => ({ text, link: base + route, variant });
await page('index', { title: overview.title, template: 'splash', hero: {
tagline: 'Dyno runs your tests inside the browser, right next to your app, and plots every run as it happens.',
actions: [action('Write your first test', 'guides/getting-started/', 'primary'), action('Read the API', 'api/readme/', 'secondary')],
} }, overview.body);
// Starlight renders this entry at /404. Its build warns that [...slug] also lists it; the /404 route wins.
await page('404', { title: 'Not on the sheet', template: 'splash', pagefind: false, hero: {
tagline: "This page isn't part of these docs. Search for it, or start from the top.",
image: { html: `<figure class="stalled">${tachometer({ phase: 'connecting', progress: 0 })}<figcaption>The needle hasn't moved. Nothing ran here.</figcaption></figure>` },
actions: [action('Back to the docs', '', 'primary')],
} }, '');
for (let key of guideOrder) {
let { title, body } = guides.get(key);
markdown += `\n## ${title}\n\n${body.replace(/^(#{2,5}) /gm, '#$1 ')}\n`;
await page(`guides/${key}`, title, body);
await page(`guides/${key}`, { title }, body);
}
for (let [path, target] of [['docs/browsers.md', 'guides/browsers'], ['docs/architecture.md', 'contributing/architecture'], ['docs/design.md', 'contributing/design'], ['docs/ci.md', 'contributing/ci'], ['docs/documentation.md', 'contributing/documentation'], ['vision.md', 'contributing/vision']]) {
let [heading, ...body] = (await read(path)).split('\n');
await page(target, heading.replace(/^# /, ''), body.join('\n'));
await page(target, { title: heading.replace(/^# /, '') }, body.join('\n'));
}
await page('example', 'A complete test', `This is the demo test we run in CI. Copy the pattern and change the workflow for your app.\n\n\`\`\`js\n${(await read('examples/demo/tests/contact.test.js')).trim()}\n\`\`\`\n`);
await page('example', { title: 'A complete test' }, `This is the demo test we run in CI. Copy the pattern and change the workflow for your app.\n\n\`\`\`js\n${(await read('examples/demo/tests/contact.test.js')).trim()}\n\`\`\`\n`);
let cli = guides.get('cli').body + '\n';
for (let command of ['', 'run', 'init', 'doctor', 'browsers', 'install']) {
let { stdout } = await exec(process.execPath, [join(root, 'bin/dyno.js'), ...(command ? [command] : []), '--help'], { cwd: root });
cli += `\n## dyno${command ? ` ${command}` : ''}\n\n\`\`\`text\n${stdout.trim()}\n\`\`\`\n`;
}
await page('cli', 'Commands and flags', cli);
await page('cli', { title: 'Commands and flags' }, cli);
await write(join(site, '.generated/overview.md'), links(guides.get('api-overview').body) + '\n');
let reference = '# API reference\n\n' + guides.get('api-overview').body.replace(/\[([^\]]+)\]\(api\/[^)]+\)/g, '`$1`') + '\n\nSignatures: [browser types](../types/index.d.ts), [configuration](../types/config.d.ts), [app-process fixtures](../types/fixtures.d.ts). See the [guide](guide.md) for examples.\n';
let groups = [
Expand Down
108 changes: 108 additions & 0 deletions site/src/components/Hero.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
---
import { LinkButton } from '@astrojs/starlight/components';
import Default from '@astrojs/starlight/components/Hero.astro';
import { pullCurve, pullSheet, sheetHead, ghostCurve, tachometer, testCount, sheetNote, sheetSpan } from '../../../src/browser/dyno-chart.js';
import { timingDuration } from '../../../src/browser/timing-format.js';

// A hero without its own image gets the example pull. Heroes with an image, like the 404's stalled
// tach, use Starlight's hero.
let { data } = Astro.locals.starlightRoute.entry;
let { title = data.title, tagline, image, actions = [] } = data.hero ?? {};
let home = !image;

// An example pull: one slow import test leaves a long flat stretch. The second pull is the same
// suite after that test got faster, drawn over the first one's ghost.
let names = ['opens the inbox', 'saves a contact', 'validates the email field', 'archives a contact', 'imports 500 contacts', 'filters by name', 'shows an empty state', 'applies a coupon', 'rejects an expired card', 'sends a receipt', 'exports a CSV', 'signs out'];
let slow = [120, 95, 140, 88, 1240, 110, 76, 130, 64, 98, 150, 82];
let fast = slow.map((duration, index) => (index === 4 ? 180 : duration));
let pull = (durations, failed) => pullCurve(names.map((name, index) => ({ id: String(index), index, name, duration: durations[index], status: index === failed ? 'failed' : 'passed' })));
let first = pull(slow, 7);
let second = pull(fast);
let ghost = ghostCurve(first, 'first');
let pulls = [
{ name: 'first', curve: first, caption: 'Steep is fast. That flat stretch is one slow import test.' },
{ name: 'second', curve: second, ghost, caption: `The import got faster. This pull beat the last one by ${timingDuration(first.time - second.time)}.` }
].map((state) => ({ ...state, head: sheetHead(state.curve, state.ghost), failed: state.curve.points.some((point) => point.status === 'failed') }));
// Starlight's skip link and table of contents point at the page title by this id.
let titleId = '_top';
---
{home ? (
<div class="hero">
<div class="copy">
<h1 id={titleId} data-page-title set:html={title} />
{tagline && <p class="tagline" set:html={tagline} />}
<div class="actions">
{actions.map(({ link: href, text, variant }) => <LinkButton {href} {variant}>{text}</LinkButton>)}
</div>
</div>
<figure class="instrument" data-pull="first">
{pulls.map(({ name, curve, ghost, head, failed, caption }) => (
<div class="pull" data-state={name}>
<div class="instrument-head">
<Fragment set:html={tachometer({ phase: 'completed', failed, progress: 1 })} />
<p class="instrument-title">An example pull</p>
</div>
<div class="sheet-frame">
<div class="sheet-canvas" set:html={pullSheet(curve, { ghost })} />
<p class="sheet-flag" data-side={head.side} style={`left:${head.left}%;top:${head.top}%`}><span>{sheetNote(curve)}</span></p>
<span class="sheet-axis sheet-axis-y">{testCount(curve.total)}</span>
<span class="sheet-axis sheet-axis-x">{sheetSpan(curve, ghost)}</span>
</div>
<figcaption>
<span class="sheet-legend"><span class="legend-trace">This pull</span>{ghost && <span class="legend-ghost">Last pull, {timingDuration(ghost.time)}</span>}</span>
<span class="caption">{caption}</span>
</figcaption>
</div>
))}
<button type="button" class="pull-again">Run it again</button>
</figure>
</div>
) : (
<Default />
)}

<script>
// Showing the second pull restarts its draw-in, sweep, and note animations.
for (let button of document.querySelectorAll('.pull-again'))
button.addEventListener('click', () => {
let figure = button.closest('.instrument');
if (figure instanceof HTMLElement) figure.dataset.pull = 'second';
});
</script>

<style>
.hero { display: grid; gap: 2.5rem; align-items: center; padding-block: clamp(1.5rem, 6vmin, 4.5rem) 1rem; }
@media (min-width: 64rem) { .hero { grid-template-columns: minmax(0, 5fr) minmax(0, 6fr); gap: 3.5rem; } }
.copy { display: flex; flex-direction: column; align-items: flex-start; gap: 1.25rem; }
.actions { display: flex; flex-wrap: wrap; gap: .75rem 1rem; margin-top: .5rem; }

.instrument { position: relative; margin: 0; padding: 1.25rem 1.25rem 1rem; background: var(--surface); border: 1px solid var(--line); border-radius: 10px; }
.instrument[data-pull=first] [data-state=second], .instrument[data-pull=second] [data-state=first] { display: none; }
.instrument-head { display: flex; align-items: center; gap: .75rem; min-height: 34px; margin-bottom: 1.75rem; padding-right: 7.5rem; }
.instrument-head :global(.tach) { width: 52px; height: 30px; }
.instrument-title { margin: 0; color: var(--ink); font-weight: 700; font-stretch: 112%; }
.pull-again { position: absolute; top: 1.25rem; right: 1.25rem; padding: .35rem .8rem; color: var(--ink); background: transparent; border: 1px solid var(--line); border-radius: 6px; font: inherit; font-size: .8125rem; font-weight: 650; cursor: pointer; }
.pull-again:hover { border-color: var(--faint); }
.sheet-frame { height: clamp(170px, 26vw, 250px); margin: 0 0 2rem; }
.sheet-axis { font-size: .75rem; }
.sheet-flag { font-size: .8125rem; }
figcaption { display: flex; flex-wrap: wrap; justify-content: space-between; gap: .5rem 1.25rem; color: var(--muted); font-size: .8125rem; }
.caption { color: var(--sl-color-gray-2); }

/* One orchestrated moment: the pull draws in left to right, the needle sweeps, then the note prints. */
@media (prefers-reduced-motion: no-preference) {
.sheet-canvas :global(.sheet-plot) { animation: draw 1.6s cubic-bezier(.45, 0, .2, 1) both; }
.instrument-head :global(.tach) { animation: sweep 1.6s cubic-bezier(.45, 0, .2, 1) both; }
.sheet-flag { animation: print .3s 1.5s ease-out both; }
}
@keyframes draw { from { clip-path: inset(-12px 100% -12px -12px); } to { clip-path: inset(-12px); } }
@keyframes sweep { from { --progress: 0; } }
@keyframes print { from { opacity: 0; translate: var(--flag-x) -90%; } }

@media (max-width: 40rem) {
.instrument { padding: 1rem .875rem .875rem; }
.pull-again { top: 1rem; right: .875rem; }
.instrument-title { font-size: .9375rem; }
.sheet-frame { height: 160px; }
}
</style>
Loading
Loading